Files
decision/crates/ld-server/tests/ballots.rs
T
YvvandClaude Opus 5.5 a32912373d Lot T2 : les trois phrases de F1.1, F1.2 et N1.1 écrites justes (la part du cran, la plus haute au quorum, baisse quand la participation monte ; le quorum se fixe tant que l'article ne garde la trace d'aucun vote ; le quorum n'est pas un nombre minimal de votants), dans le seed et par un amendement gardé qui suit celui de M6b
- seed_amend : 2026-11-03-f1-1-phrases, -f1-2-phrases, -n1-1-phrases ; la garde admet la révision écrite par la chaîne des amendements suivis (earlier), dans leur ordre, le dernier étant celui de M6b (F1.1 en révision 2, F1.2 et N1.1 en révision 3) ; les amendements de M6b dépassés ; le commentaire dit les deux votes qui ne laissent pas de trace (vote direct retiré dans le groupe d'essai, votes simulés après « supprime »)
- tests : base faite comme la production après M6b (fixture seed-quorum-m6b.json), appliqué une fois, refus votes et ballots, second démarrage sans rejeu, base née le 02/11 ; les phrases et leur preuve par le moteur ; le quorum de nouveau réglable après un vote direct retiré et après « supprime »
- moteur : la part exigée au quorum en production (W 5 707) : 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % (la relecture disait 74 au cran 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:12:12 +01:00

2316 lines
112 KiB
Rust

//! The vote, grouped, and the vote on the Ğ1 chain (ADR 0008): sealed by the
//! server, confirmed by reading an indexer — here a small simulated indexer that
//! answers the GraphQL query in the shape of the real one
//! (`tests/fixtures/indexer-merci.json`, relevé on indexer.duniter.org).
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use http_body_util::BodyExt;
use ld_engine::address::{self, G1_PREFIX};
use ld_engine::ballot::{blake2_256, to_hex};
use ld_server::config::VoteChain;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, ballots, open_db, seed};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tower::ServiceExt;
// Substrate's well-known development keys, as Ğ1 addresses: no real account is touched.
const VOTE_ACCOUNT: &str = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; // Dave
const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
const ALICE_GENERIC: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
const BOB: &str = "g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g";
const CHARLIE: &str = "g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi";
const OTHER: &str = "g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj"; // Eve
// ---------------------------------------------------------------------------
// A simulated indexer
// ---------------------------------------------------------------------------
#[derive(Default)]
struct Chain {
comments: Vec<Value>,
silent: bool,
asked: usize,
/// The last final block, served as `squidStatus.height`; `None`: every block is final.
finalized: Option<u32>,
/// An indexer that ignores the filter it is given (the hash only holds).
lax: bool,
/// The filters the server asked with, in order.
filters: Vec<Value>,
}
#[derive(Clone)]
struct Indexer {
chain: Arc<Mutex<Chain>>,
url: String,
}
/// Who signed, and the identity of that account, as the indexer serves it.
struct Signer<'a> {
account: &'a str,
identity: Option<(u32, &'a str, bool)>,
}
/// What the real indexer's `filter` keeps, for the two filters the server may ask.
fn kept_by(node: &Value, filter: &Value) -> bool {
if let Some(author) = filter["authorId"]["equalTo"].as_str()
&& node["author"]["id"] != author
{
return false;
}
let some = &filter["transfersByCommentId"]["some"];
if some.is_null() {
return true;
}
let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0);
node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| {
some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to)
&& t["amount"].as_str().and_then(|a| a.parse::<u64>().ok()).is_some_and(|a| a >= min)
})
}
/// The size of a page: `first: N` in the query, as the server writes it.
fn page_size(query: &str) -> usize {
query
.split("first:")
.nth(1)
.and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next())
.and_then(|n| n.parse().ok())
.unwrap_or(100)
}
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
let mut c = chain.lock().expect("lock");
c.asked += 1;
if c.silent {
return StatusCode::BAD_GATEWAY.into_response();
}
let vars = &body["variables"];
let hash = vars["hash"].as_str().unwrap_or_default().to_owned();
let filter = vars["filter"].clone();
c.filters.push(filter.clone());
let mut nodes: Vec<Value> = c
.comments
.iter()
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
.filter(|n| c.lax || kept_by(n, &filter))
.cloned()
.collect();
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
// Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one.
let start: usize =
vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0);
let page: Vec<Value> =
nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect();
let end = start + page.len();
Json(json!({ "data": {
"squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) },
"txComments": {
"pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") },
"nodes": page,
},
} }))
.into_response()
}
impl Indexer {
async fn start() -> Self {
let chain = Arc::new(Mutex::new(Chain::default()));
let router = Router::new().route("/v1/graphql", post(graphql)).with_state(chain.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
Self { chain, url: format!("http://{addr}/v1/graphql") }
}
/// A transaction on the chain: the remark, signed by `signer`, with its transfers.
fn add(&self, block: u32, event: u32, remark: &str, signer: Signer<'_>, transfers: &[(&str, u64)]) {
let identity =
signer.identity.map(|(index, name, member)| json!({ "index": index, "name": name, "isMember": member }));
let transfers: Vec<Value> = transfers
.iter()
.map(|(to, amount)| json!({ "fromId": signer.account, "toId": to, "amount": amount.to_string() }))
.collect();
self.chain.lock().expect("lock").comments.push(json!({
"id": format!("{block:010}-abcde-{event:06}"), "blockNumber": block, "remark": remark,
"author": { "id": signer.account, "identity": identity },
"transfersByCommentId": { "nodes": transfers },
}));
}
/// The usual vote: 0.1 Ğ1 to the vote account.
fn pay(&self, block: u32, remark: &str, signer: Signer<'_>) {
self.add(block, 1, remark, signer, &[(VOTE_ACCOUNT, 10)]);
}
fn silent(&self, on: bool) {
self.chain.lock().expect("lock").silent = on;
}
fn asked(&self) -> usize {
self.chain.lock().expect("lock").asked
}
/// The chain is final up to this block.
fn finalized(&self, block: Option<u32>) {
self.chain.lock().expect("lock").finalized = block;
}
fn lax(&self, on: bool) {
self.chain.lock().expect("lock").lax = on;
}
fn filters(&self) -> Vec<Value> {
self.chain.lock().expect("lock").filters.clone()
}
}
fn alice() -> Signer<'static> {
Signer { account: ALICE, identity: Some((9001, "Alice", true)) }
}
fn chain_config(indexer: &Indexer) -> Config {
let mut cfg = Config::for_tests();
cfg.indexers = vec![indexer.url.clone()];
cfg.vote = VoteChain::new(VOTE_ACCOUNT, 10, vec!["wss://node.invalid".into()], None);
cfg
}
// ---------------------------------------------------------------------------
// A client of the API
// ---------------------------------------------------------------------------
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
async fn new(cfg: Config) -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let (status, _, value) = self.call_with_headers(method, uri, body).await;
(status, value)
}
async fn call_with_headers(
&mut self,
method: &str,
uri: &str,
body: Option<Value>,
) -> (StatusCode, axum::http::HeaderMap, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let headers = res.headers().clone();
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
let (s, v) = self.call(method, uri, body).await;
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
}
async fn login_admin(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.ok("GET", &format!("/api/docs/{id}"), None).await
}
/// A pending ballot for `lines` (version id, choice).
async fn ballot(&mut self, doc: &Value, lines: &[(&str, Option<&str>)], author: Option<&str>) -> Value {
let id = doc["doc"]["id"].as_str().expect("id");
let lines: Vec<Value> = lines.iter().map(|(p, c)| json!({ "proposalId": p, "choice": c })).collect();
self.ok("POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": lines, "author": author }))).await["ballot"]
.clone()
}
async fn status(&mut self, ballot: &Value) -> Value {
let id = ballot["id"].as_str().expect("id");
self.ok("GET", &format!("/api/ballots/{id}"), None).await["ballot"].clone()
}
}
/// The quorum of an article set to 2, as its author — an administrator, for a text of the
/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum
/// of every level is 1 and one voice passes, one voice of two then no longer does.
async fn two_voices(state: &AppState, group: &str, code: &str) {
let n = sqlx::query(
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)",
)
.bind(code)
.bind(group)
.execute(&state.db)
.await
.expect("quorum")
.rows_affected();
assert_eq!(n, 1, "{group} {code}");
}
/// A web of two members and the quorum 2 at every level of a document, for the test
/// only (ADR 0013): two voices pass, one never does.
async fn small_web(state: &AppState, doc_id: &str) {
sqlx::query("UPDATE documents SET vote_floor = 1, quorums = '[2,2,2,2,2]' WHERE id = ?")
.bind(doc_id)
.execute(&state.db)
.await
.expect("quorums");
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z')
ON CONFLICT(key) DO UPDATE SET value = '2'",
)
.execute(&state.db)
.await
.expect("web");
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
/// The id of a version: `inForce`, or the n-th open proposal.
fn version(doc: &Value, code: &str, which: &str) -> String {
let u = unit(doc, code);
let v = if which == "inForce" { &u["inForce"] } else { &u["proposals"][which.parse::<usize>().expect("n")] };
v["id"].as_str().expect("id").to_owned()
}
/// The version as the document shows it now, by id.
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
doc["units"]
.as_array()
.expect("units")
.iter()
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.find(|p| p["id"] == id)
.expect("version")
}
fn tally(doc: &Value, id: &str) -> (u64, u64) {
pair(&find(doc, id)["tally"])
}
fn pair(t: &Value) -> (u64, u64) {
(t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against"))
}
/// The part of the totals shown still waiting for its proof (ADR 0012).
fn pending(doc: &Value, id: &str) -> (u64, u64) {
pair(&find(doc, id)["tallyPending"])
}
/// What adoption counts: never an intention without proof.
async fn counted(state: &AppState, doc: &Value, id: &str) -> (u32, u32) {
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
let t = st.tally(id);
(t.for_votes, t.against)
}
/// « Ton vote » and where it stands.
fn mine<'a>(doc: &'a Value, id: &str) -> (Option<&'a str>, Option<&'a str>) {
let p = find(doc, id);
(p["myVote"].as_str(), p["myVoteState"].as_str())
}
/// The messages of a session about its ballots (ADR 0012): (kind, ballot id).
async fn ballot_messages(c: &mut Client) -> Vec<(String, String)> {
let m = c.ok("GET", "/api/messages", None).await;
m["messages"]
.as_array()
.expect("messages")
.iter()
.filter(|m| m["topic"] == "ballot")
.map(|m| (m["kind"].as_str().expect("kind").to_owned(), m["ballot"]["id"].as_str().expect("id").to_owned()))
.collect()
}
async fn rewind_check(state: &AppState) {
// The page asks often, the indexer is read at most every few seconds: tests rewind that clock.
sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind");
}
// ---------------------------------------------------------------------------
// The vote on the chain
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// Monnaie libre votes on the chain; this session may ask for a ballot.
assert_eq!(doc["vote"]["way"], "chain");
assert_eq!(doc["vote"]["open"], true);
assert_eq!(doc["canVote"], true);
assert_eq!(doc["vote"]["chain"]["account"], VOTE_ACCOUNT);
assert_eq!(doc["vote"]["chain"]["amount"], 10);
assert_eq!(doc["vote"]["chain"]["test"], false);
let i1 = version(&doc, "I1", "0");
let e3 = version(&doc, "E3", "0");
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
let comment = b["comment"].as_str().expect("comment");
assert!(comment.starts_with("ld1:") && comment.len() == 68 && comment[4..].bytes().all(|x| x.is_ascii_hexdigit()));
assert_eq!(b["status"], "pending");
assert_eq!(b["account"], VOTE_ACCOUNT);
assert_eq!(b["author"], ALICE, "the bound account, written as a Ğ1 address");
assert_eq!(b["lines"], 2);
// What the server keeps: the lines, the salt, and the seal they make.
let (digest, salt): (String, String) =
sqlx::query_as("SELECT digest, salt FROM ballots").fetch_one(&state.db).await.expect("ballot");
assert_eq!(format!("ld1:{digest}"), comment);
assert_eq!(salt.len(), 32);
let doc_id = doc["doc"]["id"].as_str().expect("id");
let lines = [
ld_engine::ballot::Line { proposal_id: i1.clone(), revision: 1, choice: ld_engine::ballot::Choice::For },
ld_engine::ballot::Line { proposal_id: e3.clone(), revision: 1, choice: ld_engine::ballot::Choice::Against },
];
assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment);
// Nothing on the chain yet: the intention is seen at once — her vote, the totals
// shown — and waits for its proof; adoption does not count it (ADR 0012).
assert_eq!(c.status(&b).await["status"], "pending");
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
assert_eq!((tally(&doc, &e3), pending(&doc, &e3)), ((0, 1), (0, 1)));
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
assert_eq!(mine(&doc, &i1), (Some("for"), Some("pending")));
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending");
assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for");
// The transaction lands: one transaction for both lines.
indexer.pay(3_400_000, comment, alice());
rewind_check(&state).await;
let s = c.status(&b).await;
assert_eq!(s["status"], "confirmed", "{s}");
assert_eq!(s["voter"], "Alice");
assert_eq!(s["block"], 3_400_000);
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (0, 0)));
assert_eq!(tally(&doc, &e3), (0, 1));
assert_eq!(counted(&state, &doc, &i1).await, (1, 0));
// The session that asked sees « ton vote », confirmed on the chain.
assert_eq!(mine(&doc, &i1), (Some("for"), Some("recorded")));
assert_eq!(find(&doc, &i1)["myVoteChain"], true);
assert!(find(&doc, &i1)["myBallot"].is_null());
assert_eq!(find(&doc, &e3)["myVote"], "against");
// The vote counts for the Ğ1 identity of the signer, created as a member.
let row: (String, String, bool, Option<String>) =
sqlx::query_as("SELECT kind, display_name, g1_member, address FROM identities WHERE id = 'idty:9001'")
.fetch_one(&state.db)
.await
.expect("identity");
assert_eq!(row, ("g1".into(), "Alice".into(), true, Some(ALICE.into())));
let voters: Vec<(String, String)> = sqlx::query_as("SELECT identity_id, source FROM votes ORDER BY proposal_id")
.fetch_all(&state.db)
.await
.expect("votes");
assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}");
// One voice never makes a text pass: at level 2, the quorum 12, the 11 voters missing
// counted as against — 9 « pour » are asked (ADR 0013).
let e = &find(&doc, &i1)["evaluation"];
assert_eq!((e["quorum"].as_u64(), e["required"].as_u64(), e["yesNeeded"].as_u64()), (Some(12), Some(9), Some(9)));
assert_eq!(e["reached"], false);
// Someone else sees the totals, not the vote.
let mut yvv = c.other();
yvv.login("Yvv").await;
let seen = yvv.doc("monnaie-libre").await;
assert_eq!(tally(&seen, &i1), (1, 0));
assert!(find(&seen, &i1)["myVote"].is_null());
// Asked again, a confirmed ballot stays as it is.
rewind_check(&state).await;
assert_eq!(c.status(&b).await["status"], "confirmed");
assert_eq!(
yvv.call("GET", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await.0,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Yvv").await;
let doc = c.doc("monnaie-libre").await;
let p = version(&doc, "E3", "0");
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!(s["status"], "confirmed");
assert_eq!(s["entered"], json!([]));
let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await;
// Level 3, the quorum 50: 39 « pour » of 50 voters, the 49 missing counted as against.
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
assert_eq!(d["proposal"]["evaluation"]["required"], 39);
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
assert_eq!(d["proposal"]["myVote"], "for");
assert_eq!(d["proposal"]["myVoteChain"], true);
}
#[tokio::test]
async fn a_silent_indexer_keeps_the_ballot_pending() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("comment"), alice());
indexer.silent(true);
assert_eq!(c.status(&b).await["status"], "pending");
assert!(indexer.asked() >= 1);
// Asked again at once: the indexer is not read again before a few seconds.
let asked = indexer.asked();
assert_eq!(c.status(&b).await["status"], "pending");
assert_eq!(indexer.asked(), asked);
// The background task reads it again; silent still, nothing changes.
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
assert_eq!(c.status(&b).await["status"], "pending");
// The indexer is back.
indexer.silent(false);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
}
#[tokio::test]
async fn a_signer_who_is_not_a_member_is_refused_and_told() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(
100,
former["comment"].as_str().expect("c"),
Signer { account: BOB, identity: Some((42, "Bob", false)) },
);
let nobody = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(101, nobody["comment"].as_str().expect("c"), Signer { account: BOB, identity: None });
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&former).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("not_member")));
let s = c.status(&nobody).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("no_identity")));
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 0));
// The page says so on the version.
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "refused");
assert_eq!(find(&doc, &i1)["myBallot"]["reason"], "no_identity");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE id = 'idty:42'")
.fetch_one(&state.db)
.await
.expect("n");
assert_eq!(n, 0, "no identity is created for a refused ballot");
// And in her messages, once each (ADR 0012).
let mut told = ballot_messages(&mut c).await;
told.sort();
let mut expected = vec![
("ballot_refused".to_owned(), id_of(&former).to_owned()),
("ballot_refused".to_owned(), id_of(&nobody).to_owned()),
];
expected.sort();
assert_eq!(told, expected);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(ballot_messages(&mut c).await.len(), 2);
}
#[tokio::test]
async fn a_wrong_amount_or_recipient_is_refused() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
// Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free.
let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
ballots::sweep(&state).await.expect("sweep");
for b in [&short, &elsewhere, &remark_only] {
let s = c.status(b).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
}
assert_eq!(c.status(&gecko).await["status"], "pending");
// The refused ones are set aside; the Ğecko code still waits: its intention shows, uncounted.
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
}
#[tokio::test]
async fn confirming_twice_changes_nothing() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
// The page and the background task at once.
let row = sqlx::query_as::<_, ballots::BallotRow>(
"SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status, reason, voter_id, block,
entered, created_at, expires_at, checked_at FROM ballots",
)
.fetch_one(&state.db)
.await
.expect("row");
let (a, b2) = tokio::join!(ballots::check(&state, &row), ballots::check(&state, &row));
a.expect("check");
b2.expect("check");
ballots::check(&state, &row).await.expect("again");
assert_eq!(c.status(&b).await["status"], "confirmed");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("n");
assert_eq!(n, 1);
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
}
#[tokio::test]
async fn the_last_one_stands_in_the_order_of_the_chain() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let e1 = version(&doc, "E1", "0");
// Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1).
let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await;
let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
let third = c.ballot(&doc, &[(&e1, None)], None).await;
// The most recent is confirmed first; the oldest last, as when an indexer lags behind.
indexer.pay(300, third["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
indexer.add(200, 4, second["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
ballots::sweep(&state).await.expect("sweep");
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), tally(&doc, &e1)), ((0, 1), (0, 0)));
indexer.add(200, 2, first["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
ballots::sweep(&state).await.expect("sweep");
for b in [&first, &second, &third] {
assert_eq!(c.status(b).await["status"], "confirmed");
}
// Block 200 event 2 comes before block 200 event 4, before block 300: the first ballot
// decides nothing any more. I1 stays « against »; E1 stays withdrawn, never brought back.
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 1));
assert_eq!(tally(&doc, &e1), (0, 0));
assert_eq!(find(&doc, &i1)["myVote"], "against");
assert!(find(&doc, &e1)["myVote"].is_null());
// A new ballot, later on the chain, decides again.
let fourth = c.ballot(&doc, &[(&e1, Some("against"))], None).await;
indexer.pay(301, fourth["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(tally(&c.doc("monnaie-libre").await, &e1), (0, 1));
}
#[tokio::test]
async fn a_revision_changed_meanwhile_leaves_the_vote_to_reconfirm() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let created = c
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true })),
)
.await;
let p = created["id"].as_str().expect("id").to_owned();
let doc = c.doc("monnaie-libre").await;
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
// The author changes its meaning before the transaction is confirmed.
let edit = c
.ok(
"PATCH",
&format!("/api/proposals/{p}"),
Some(
json!({ "title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours." }),
),
)
.await;
assert_eq!(edit["edit"], "substantive");
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
// The vote was cast on revision 1: it waits to be reconfirmed, as any vote does.
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &p), (0, 0));
assert_eq!(find(&doc, &p)["myVote"], "for");
assert_eq!(find(&doc, &p)["myVoteStale"], true);
let rev: i64 = sqlx::query_scalar("SELECT revision FROM votes WHERE proposal_id = ?")
.bind(&p)
.fetch_one(&state.db)
.await
.expect("vote");
assert_eq!(rev, 1);
}
#[tokio::test]
async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
let comment = b["comment"].as_str().expect("c");
// Charlie copies the remark seen in the pool and gets it in first.
indexer.pay(99, comment, Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "pending", "a copy settles nothing");
indexer.pay(100, comment, alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")));
let voters: Vec<String> =
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
assert_eq!(voters, ["idty:9001"]);
}
#[tokio::test]
async fn a_ballot_not_seen_in_time_is_let_go() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let expires = b["expiresAt"].as_str().expect("expires");
assert!(
expires > ld_server::util::in_seconds(23 * 3600).as_str()
&& expires <= ld_server::util::in_seconds(24 * 3600).as_str()
);
sqlx::query("UPDATE ballots SET expires_at = ?")
.bind(ld_server::util::in_seconds(-1))
.execute(&state.db)
.await
.expect("age");
// Read one last time at its end: nothing on the chain, it is let go.
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
assert_eq!(c.status(&b).await["status"], "expired");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
// Seen on the chain afterwards: too late, it no longer counts.
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "expired");
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired");
}
#[tokio::test]
async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]);
// Another session may not let it go.
let mut other = c.other();
other.login("Yvv").await;
let (s, _) = other.call("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Its own session does: no longer waiting, and it says nothing under the article.
let dropped = c.ok("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
assert_eq!(
(dropped["ballot"]["status"].as_str(), dropped["ballot"]["reason"].as_str()),
(Some("expired"), Some("dropped"))
);
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], first["id"]);
// A confirmed ballot is never let go.
indexer.pay(100, first["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let kept = c.ok("DELETE", &format!("/api/ballots/{}", first["id"].as_str().expect("id")), None).await;
assert_eq!(kept["ballot"]["status"], "confirmed");
let now = c.doc("monnaie-libre").await;
assert_eq!((find(&now, &i1)["myVote"].as_str(), find(&now, &i1)["myBallot"].is_null()), (Some("against"), true));
}
#[tokio::test]
async fn a_session_sees_its_own_ballots_only() {
let indexer = Indexer::start().await;
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
chiara.login("Chiara").await;
let mut other = chiara.other();
other.login("Yvv").await;
let doc = chiara.doc("monnaie-libre").await;
let (i1, e1) = (version(&doc, "I1", "0"), version(&doc, "E1", "0"));
// Both sign with the same wallet (one person, two sessions): two ballots, two versions.
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await;
indexer.pay(100, a["comment"].as_str().expect("c"), alice());
indexer.pay(101, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let mine = chiara.doc("monnaie-libre").await;
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
let theirs = other.doc("monnaie-libre").await;
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
assert_eq!(d["canVote"], true);
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
}
#[tokio::test]
async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let e4 = version(&doc, "E4", "0");
let e5 = version(&doc, "E5", "0");
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
let e1 = version(&doc, "E1", "0");
// Level 3, the quorum 50 (ADR 0013): 39 « pour » with no « contre ».
let needed = find(&doc, &e4)["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded");
assert_eq!(needed, 39);
let needed = u8::try_from(needed).expect("n");
let keys: Vec<String> = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
let mut luna = c.other();
luna.login("Luna").await;
for (n, account) in keys.iter().enumerate() {
// The last voice comes from another session: its intention is seen at once.
let last_one = n + 1 == keys.len();
let asker = if last_one { &mut luna } else { &mut c };
let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
if last_one {
// Thirty-nine voices shown, thirty-eight proven: nothing enters before the proof.
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((39, 0), (1, 0)));
assert_eq!(counted(&state, &d, &e4).await, (38, 0));
let id = d["doc"]["id"].as_str().expect("id");
assert!(ld_server::adoption::run(&state, id).await.expect("run").is_empty());
assert_ne!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
}
let index = 10_000 + u32::try_from(n).expect("n");
indexer.pay(
1_000 + index,
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
if n + 1 == keys.len() {
// The last ballot is signed; before the chain confirms it, an objection holds
// E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it.
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let id = doc["doc"]["id"].as_str().expect("id");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{id}/ballots"),
Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")));
}
last = b;
if n % 10 == 9 {
// A session holds at most 20 pending ballots: the background task confirms on the way.
ballots::sweep(&state).await.expect("sweep");
}
if n + 2 == keys.len() {
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e4), (38, 0));
assert!(find(&d, &e4)["enteredAt"].is_null(), "38 voices: one short of the 39 the quorum 50 asks");
assert_eq!(find(&d, &e4)["evaluation"]["missing"], 1);
}
}
ballots::sweep(&state).await.expect("sweep");
let s = luna.status(&last).await;
assert_eq!(s["status"], "confirmed");
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
// Sealed before the objection, proven after it: its *for* on E5 is set aside
// (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told.
assert_eq!(tally(&d, &e5), (38, 0));
assert_eq!(find(&d, &e5)["votable"], false);
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
let outcomes: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT proposal_id, outcome FROM ballot_lines WHERE ballot_id = ? ORDER BY proposal_id = ?")
.bind(id_of(&last))
.bind(&e5)
.fetch_all(&state.db)
.await
.expect("lines");
assert_eq!(outcomes, [(e4.clone(), Some("counted".into())), (e5.clone(), Some("objection".into()))]);
assert_eq!(ballot_messages(&mut luna).await, [("ballot_objection".to_owned(), id_of(&last).to_owned())]);
let seen = luna.doc("monnaie-libre").await;
let held = &find(&seen, &e5)["myBallot"];
assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection")));
assert_eq!(mine(&seen, &e4), (Some("for"), Some("recorded")));
}
/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a
/// ballot against it or a withdrawal, never a *for* — alone or among free lines.
#[tokio::test]
async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e1, e4, e5) = (version(&doc, "E1", "0"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let uri = format!("/api/docs/{doc_id}/ballots");
let refused = (StatusCode::CONFLICT, Some("objection"));
for lines in [
json!([{ "proposalId": e5, "choice": "for" }]),
json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]),
] {
let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await;
assert_eq!((s, v["error"].as_str()), refused, "{lines}");
}
// Against the held one, for a free one: sealed, then confirmed by the chain.
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await;
indexer.pay(200, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1)));
assert_eq!(find(&d, &e5)["myVote"], "against");
// A withdrawal: the last ballot stands.
let w = c.ballot(&doc, &[(&e5, None)], None).await;
indexer.pay(201, comment_of(&w), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&w).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e5), (0, 0));
assert!(find(&d, &e5)["myVote"].is_null());
assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters");
}
// ---------------------------------------------------------------------------
// After the independent review of 74f96be (lot B2): its probes, kept as tests
// ---------------------------------------------------------------------------
async fn db_status(state: &AppState, id: &str) -> (String, Option<String>) {
sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row")
}
fn id_of(b: &Value) -> &str {
b["id"].as_str().expect("id")
}
fn comment_of(b: &Value) -> &str {
b["comment"].as_str().expect("comment")
}
async fn age(state: &AppState, set: &str, seconds: i64, id: &str) {
sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?"))
.bind(ld_server::util::in_seconds(seconds))
.bind(id)
.execute(&state.db)
.await
.expect("age");
}
/// P5: the `author` field was decoded in a time that grows as the square of its
/// length, on a thread of the server (250 000 characters: 32 s).
#[tokio::test]
async fn a_long_author_is_refused_at_once() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "0");
// The body may hold 256 KiB: an author of 250 000 characters passes it.
for n in [80_000usize, 250_000] {
let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) });
let t = std::time::Instant::now();
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author")));
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed());
}
}
/// P1: the background task read the same 50 oldest pending ballots at every pass;
/// a vote paid behind them was never confirmed, then expired.
#[tokio::test]
async fn the_background_task_reaches_every_pending_ballot() {
let indexer = Indexer::start().await;
let (mut a, state) = Client::new(chain_config(&indexer)).await;
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let mut b = a.other();
b.login("Bruno").await;
let mut c = a.other();
c.login("Carla").await;
for _ in 0..20 {
a.ballot(&doc, &[(&i1, Some("for"))], None).await;
b.ballot(&doc, &[(&i1, Some("for"))], None).await;
}
for _ in 0..10 {
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
}
let mut dora = a.other();
dora.login("Dora").await;
let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await;
indexer.pay(100, comment_of(&mine), alice());
// 51 pending, at most 50 read per pass: the least recently read come first.
ballots::sweep(&state).await.expect("sweep");
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed");
let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL")
.fetch_one(&state.db)
.await
.expect("n");
assert_eq!(unread, 0, "every pending ballot was read");
}
/// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines.
#[tokio::test]
async fn letting_ballots_go_does_not_lift_the_cap() {
let indexer = Indexer::start().await;
let (mut a, state) = Client::new(chain_config(&indexer)).await;
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "0");
for _ in 0..20 {
let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await;
}
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
let url = format!("/api/docs/{doc_id}/ballots");
let (s, v) = a.call("POST", &url, Some(one.clone())).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
// The cap holds for the identity, whatever the session.
let mut again = a.other();
again.login("Anna").await;
assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT);
// An hour later, there is room again.
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
.bind(ld_server::util::in_seconds(-3601))
.execute(&state.db)
.await
.expect("age");
let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
// Let go more than a day ago: purged, lines and all; the pending one stays.
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
.bind(ld_server::util::in_seconds(-(24 * 3600 + 1)))
.execute(&state.db)
.await
.expect("age");
ballots::sweep(&state).await.expect("sweep");
let count = || async {
sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)")
.fetch_one(&state.db)
.await
.expect("n")
};
assert_eq!(count().await, (1, 1));
// Not seen on the chain in time: it says so under the article for a week, then it is purged.
age(&state, "expires_at", -3601 - 5, id_of(&kept)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired");
age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(count().await, (1, 1));
age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(count().await, (0, 0));
}
/// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good.
#[tokio::test]
async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() {
let indexer = Indexer::start().await;
// An indexer that does not filter: the rule of the engine holds alone.
indexer.lax(true);
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]);
indexer.add(
99,
2,
comment_of(&b),
Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) },
&[(VOTE_ACCOUNT, 9)],
);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing");
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
}
/// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes
/// an unbound ballot — its lines count for the one who paid.
#[tokio::test]
async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie")));
let voters: Vec<String> =
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
assert_eq!(voters, ["idty:7"]);
}
/// Point 5 of the review: a pending ballot older than a confirmed vote on the same
/// line no longer shows under the article.
#[tokio::test]
async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
// A Ğecko code shown, never paid; then a vote from the wallet, confirmed.
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.pay(100, comment_of(&newer), alice());
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(find(&d, &i1)["myVote"], "for");
assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]);
let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
assert!(p["proposal"]["myBallot"].is_null());
// A ballot asked after the vote shows, waiting.
let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]);
}
/// Point 6: only a comment in a final block counts, or refuses.
#[tokio::test]
async fn a_comment_counts_once_its_block_is_final() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await;
indexer.finalized(Some(99));
indexer.pay(100, comment_of(&b), alice());
indexer.add(
100,
2,
comment_of(&r),
Signer { account: BOB, identity: Some((42, "Bob", false)) },
&[(VOTE_ACCOUNT, 10)],
);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
assert_eq!(db_status(&state, id_of(&r)).await.0, "pending");
// Her latest intention shows; nothing is counted before a final block.
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
indexer.finalized(Some(100));
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into())));
}
/// Point 6: the search by hash went no further than 20 comments; copies could hide the real one.
#[tokio::test]
async fn copies_never_hide_the_real_comment() {
let indexer = Indexer::start().await;
// An indexer that would not filter: pages, one after the other.
indexer.lax(true);
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
for n in 0..120 {
indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
}
indexer.pay(300, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
// The indexer is asked for what may settle: the comments of the bound account;
// for a Ğecko code, those paid to the vote account.
indexer.lax(false);
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
ballots::sweep(&state).await.expect("sweep");
let filters = indexer.filters();
assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}");
assert!(
filters.iter().any(|f| {
let some = &f["transfersByCommentId"]["some"];
some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10"
}),
"{filters:?}"
);
}
/// Point 5: a Ğecko code keeps its ballot; the background task settles it, the
/// page closed — even paid at the last moment, read once more at its end.
#[tokio::test]
async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
age(&state, "expires_at", -5, id_of(&b)).await;
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
// Its end come while no indexer answers: kept and read again, for an hour at most.
let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
age(&state, "expires_at", -5, id_of(&late)).await;
indexer.silent(true);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&late)).await.0, "pending");
age(&state, "expires_at", -3601, id_of(&late)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
}
/// Point 7: the research note promises everyone can check their own vote with its
/// salt. The receipt goes to the one who asked, never to anyone else.
#[tokio::test]
async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let (i1, e3) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"));
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
let url = format!("/api/ballots/{}/receipt", id_of(&b));
let (s, headers, r) = c.call_with_headers("GET", &url, None).await;
assert_eq!(s, StatusCode::OK, "{r}");
let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default();
assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}");
assert_eq!(
(r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()),
(Some("ld1"), Some("confirmed"), Some(100), Some("Alice"))
);
assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE)));
// It holds: its lines and its salt seal the remark the chain carries.
let lines: Vec<ld_engine::ballot::Line> = r["lines"]
.as_array()
.expect("lines")
.iter()
.map(|l| ld_engine::ballot::Line {
proposal_id: l["proposalId"].as_str().expect("p").into(),
revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"),
choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"),
})
.collect();
assert_eq!(lines.len(), 2);
let doc_id = r["doc"].as_str().expect("doc");
let salt = r["salt"].as_str().expect("salt");
let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal");
assert_eq!(
(sealed.comment.as_str(), sealed.remark_hash.as_str()),
(comment_of(&b), r["remarkHash"].as_str().expect("h"))
);
assert_eq!(
r["canonical"].as_str(),
Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str())
);
// Each line says which article it is.
assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1"));
// In her history, never in anyone else's.
let mine = c.ok("GET", "/api/ballots", None).await;
assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2)));
assert_eq!(mine["ballots"][0]["status"], "confirmed");
let mut yvv = c.other();
yvv.login("Yvv").await;
assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND);
assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([]));
let mut nobody = c.other();
assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED);
}
/// Point 8: an identity known only from a vote on the chain does not hold its name
/// against a person who joins by a team link; a Ğ1 identity that signed in does.
#[tokio::test]
async fn a_name_known_only_from_a_chain_vote_stays_free() {
let indexer = Indexer::start().await;
let (mut admin, state) = Client::new(chain_config(&indexer)).await;
admin.login_admin("Yvv").await;
let doc = admin.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })]
{
let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(block, comment_of(&b), signer);
}
ballots::sweep(&state).await.expect("sweep");
// Charlie has since signed in with his Ğ1 identity.
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'")
.bind(ld_server::util::now())
.execute(&state.db)
.await
.expect("seen");
let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 });
let v = admin.ok("POST", "/api/access/team-links", Some(link)).await;
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let mut charlie = admin.other();
let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
let mut alice = admin.other();
let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
#[tokio::test]
async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() {
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let order = doc["modules"]["orders"]
.as_array()
.expect("orders")
.iter()
.find(|o| o["section"] == "conclusion")
.expect("conclusion")
.clone();
let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2"));
assert_eq!(order["members"], json!([k1, k2]));
let o = c
.ok(
"POST",
&format!("/api/units/{}/order", order["id"].as_str().expect("id")),
Some(json!({ "order": [k2, k1] })),
)
.await["id"]
.as_str()
.expect("order")
.to_owned();
let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned();
let t = c
.ok(
"POST",
&format!("/api/units/{title_unit}/title"),
Some(json!({ "title": "Acte d'engagement de certification" })),
)
.await["id"]
.as_str()
.expect("title")
.to_owned();
let doc = c.doc("monnaie-libre").await;
// Each on the quorum of its level (ADR 0013): the order at level 2 (quorum 12) asks 9
// « pour », the title at level 4 (quorum 50) 43.
let mut needed = Vec::new();
for id in [&o, &t] {
let d = c.ok("GET", &format!("/api/proposals/{id}"), None).await;
needed.push(d["proposal"]["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded"));
}
assert_eq!(needed, [9, 43]);
let keys: Vec<String> = (1..=43u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut sent = Vec::new();
for (n, account) in keys.iter().enumerate() {
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
let index = 20_000 + u32::try_from(n).expect("n");
indexer.pay(
2_000 + index,
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
sent.push(b);
if n % 10 == 9 {
ballots::sweep(&state).await.expect("sweep");
}
}
ballots::sweep(&state).await.expect("sweep");
// What entered at each confirmation, in the order of the chain.
let mut entered: Vec<(usize, String, String)> = Vec::new();
for (n, b) in sent.iter().enumerate() {
let s = c.status(b).await;
assert_eq!(s["status"], "confirmed", "{s}");
for e in s["entered"].as_array().expect("entered") {
entered.push((n + 1, e["code"].as_str().expect("code").into(), e["kind"].as_str().expect("kind").into()));
}
}
assert_eq!(
entered,
[(9, "ordre.conclusion".into(), "order".into()), (43, "titre".into(), "title".into())],
"the ninth voice, then the forty-third"
);
let d = c.doc("monnaie-libre").await;
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
assert!(p2 < p1, "K2 now reads before K1");
assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification");
assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review");
// The adoptions keep what entered, by kind.
let kinds: Vec<String> =
sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k");
assert_eq!(kinds, ["order", "title"]);
}
// ---------------------------------------------------------------------------
// Without the chain, and in managed groups
// ---------------------------------------------------------------------------
#[tokio::test]
async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_group_still_votes() {
let (mut c, _) = Client::new(Config::for_tests()).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// The chain closed: the vote is simulated, written by the server (ADR 0012).
assert_eq!(doc["vote"]["way"], "server");
assert_eq!(doc["vote"]["open"], true);
assert_eq!(doc["vote"]["simulated"], true);
assert!(doc["vote"]["reason"].is_null());
assert!(doc["vote"]["chain"].is_null());
assert_eq!(doc["canVote"], true);
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "0");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/ballots"),
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
let essai = c.doc("essai").await;
assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true)));
assert_eq!(essai["vote"]["simulated"], false);
let p = version(&essai, "I1", "0");
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
let essai = c.doc("essai").await;
assert_eq!(mine(&essai, &p), (Some("for"), Some("recorded")));
}
#[tokio::test]
async fn in_monnaie_libre_nobody_votes_on_the_server() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let p = version(&doc, "E3", "0");
for choice in [json!("for"), Value::Null] {
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
}
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/votes"),
Some(json!({ "lines": [{ "proposalId": p, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
// And no ballot in a group that votes on the server.
let essai = c.doc("essai").await;
let id = essai["doc"]["id"].as_str().expect("id").to_owned();
let q = version(&essai, "E3", "0");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{id}/ballots"),
Some(json!({ "lines": [{ "proposalId": q, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("server_vote")));
}
#[tokio::test]
async fn what_a_ballot_may_not_hold() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "0");
let essai = c.doc("essai").await;
let elsewhere = version(&essai, "I1", "0");
for (lines, author, code) in [
(json!([]), Value::Null, "lines"),
(
json!([{ "proposalId": i1, "choice": "for" }, { "proposalId": i1, "choice": "against" }]),
Value::Null,
"lines",
),
(json!([{ "proposalId": elsewhere, "choice": "for" }]), Value::Null, "lines"),
(json!([{ "proposalId": i1, "choice": "maybe" }]), Value::Null, "choice"),
(json!([{ "proposalId": i1, "choice": "for" }]), json!("g1NotAnAddress"), "author"),
] {
let (s, v) = c
.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": lines, "author": author })))
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{lines}");
}
// A session holds a bounded number of pending ballots.
for _ in 0..20 {
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
}
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/ballots"),
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
}
#[tokio::test]
async fn a_grouped_vote_in_a_managed_group_is_one_write() {
let (mut a, state) = Client::new(Config::for_tests()).await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
for code in ["E4", "E5", "E6"] {
two_voices(&state, "essai", code).await;
}
let doc = a.doc("essai").await;
let id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "against" }, { "proposalId": e6, "choice": "for" },
]);
let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
assert_eq!(v["count"], 3);
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two, on a quorum of 2");
// All or nothing: one line that does not hold, and no line is written.
let (s, _) = b
.call(
"POST",
&format!("/api/docs/{id}/votes"),
Some(
json!({ "lines": [{ "proposalId": e4, "choice": "for" }, { "proposalId": "nope", "choice": "for" }] }),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE identity_id = 'dev:chiara'")
.fetch_one(&state.db)
.await
.expect("n");
assert_eq!(n, 0);
// The second voice of a group of two: E4 and E6 enter at once; withdrawing in the same gesture.
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e6, "choice": "for" }, { "proposalId": e5, "choice": null },
]);
let v = b.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
let mut codes: Vec<&str> = v["entered"].as_array().expect("e").iter().filter_map(|e| e["code"].as_str()).collect();
codes.sort_unstable();
assert_eq!(codes, ["E4", "E6"]);
let doc = a.doc("essai").await;
assert_eq!(tally(&doc, &e5), (0, 1));
assert_eq!(find(&doc, &e5)["myVote"], "against");
assert_eq!(find(&doc, &e5)["myVoteChain"], false);
}
// ---------------------------------------------------------------------------
// The buffer of the votes (ADR 0012): the intention at once, the proof of the
// chain, the reconciliation; the simulation while the chain is closed
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_simulated_vote_in_monnaie_libre_is_seen_at_once_in_my_vote_and_the_totals() {
let (mut c, state) = Client::new(Config::for_tests()).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (i1, e3, e4) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"), version(&doc, "E4", "0"));
// One vote, without a wallet: written by the server, simulated.
let v = c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!((v["simulated"].as_bool(), v["entered"].as_array().map(Vec::len)), (Some(true), Some(0)));
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &i1), (1, 0));
assert_eq!(pending(&d, &i1), (0, 0), "nothing waits for the chain: the simulated confirmer proved it");
assert_eq!(pair(&find(&d, &i1)["tallySimulated"]), (1, 0));
assert_eq!(mine(&d, &i1), (Some("for"), Some("simulated")));
assert_eq!(find(&d, &i1)["myVoteChain"], false);
// By default a simulated vote counts like a proven one, adoption included.
assert_eq!(counted(&state, &d, &i1).await, (1, 0));
// Through the same buffer: a sealed ballot, simulated, proven at block 0, for the session.
let row: (String, String, Option<i64>, Option<String>, String) =
sqlx::query_as("SELECT mode, status, block, voter_id, requested_by FROM ballots")
.fetch_one(&state.db)
.await
.expect("ballot");
assert_eq!((row.0.as_str(), row.1.as_str(), row.2), ("simulated", "confirmed", Some(0)));
assert_eq!(row.3.as_deref(), Some(row.4.as_str()), "the voice of the session: there is no wallet");
let source: String = sqlx::query_scalar("SELECT source FROM votes").fetch_one(&state.db).await.expect("vote");
assert_eq!(source, "simulated");
// A grouped vote: one ballot, every line; then a withdrawal.
let lines = json!([{ "proposalId": e3, "choice": "against" }, { "proposalId": e4, "choice": "for" }]);
let v = c.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
assert_eq!((v["count"].as_u64(), v["simulated"].as_bool()), (Some(2), Some(true)));
c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": null }))).await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), tally(&d, &e3), tally(&d, &e4)), ((0, 0), (0, 1), (1, 0)));
assert_eq!(mine(&d, &i1), (None, None));
assert_eq!(mine(&d, &e3), (Some("against"), Some("simulated")));
// Someone else sees the totals, never the vote.
let mut yvv = c.other();
yvv.login("Yvv").await;
let seen = yvv.doc("monnaie-libre").await;
assert_eq!((tally(&seen, &e3), mine(&seen, &e3)), ((0, 1), (None, None)));
// Her ballots, marked simulated, with a receipt.
let history = c.ok("GET", "/api/ballots", None).await;
let list = history["ballots"].as_array().expect("ballots");
assert_eq!(list.len(), 3);
assert!(
list.iter().all(|b| b["mode"] == "simulated" && b["status"] == "confirmed" && b["block"].is_null()),
"{history}"
);
let id = list[0]["id"].as_str().expect("id");
let r = c.ok("GET", &format!("/api/ballots/{id}/receipt"), None).await;
assert_eq!(r["mode"], "simulated");
// No ballot to sign while the chain is closed.
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(one)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
}
#[tokio::test]
async fn shown_but_never_adopted_when_simulated_votes_are_switched_off_for_adoption() {
// Yvv may choose « simulé = affiché, jamais compté pour l'adoption » (ADR 0012).
for adopts in [true, false] {
let mut cfg = Config::for_tests();
cfg.simulated_adopts = adopts;
let (mut a, state) = Client::new(cfg).await;
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let e4 = version(&doc, "E4", "0");
let mut b = a.other();
b.login("Bruno").await;
a.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
let v = b.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
let entered = !v["entered"].as_array().expect("entered").is_empty();
let d = a.doc("monnaie-libre").await;
assert_eq!(pair(&find(&d, &e4)["tallySimulated"]), (2, 0), "shown either way");
assert_eq!(counted(&state, &d, &e4).await, if adopts { (2, 0) } else { (0, 0) });
assert_eq!(entered, adopts, "adopts = {adopts}");
}
}
/// Yvv, 2026-11-02: « Il compte, puis s'efface ». Opening the chain sets nothing
/// aside; the act « supprime » does, and examines again what the simulated votes
/// brought into force, with the proven votes only (`simulation::discard`).
#[tokio::test]
async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_what_entered() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
// The seed proposal of I1 (ADR 0011), and a rival written before the votes.
let seed_i1 = version(&doc, "I1", "0");
let i1_unit = unit(&doc, "I1")["id"].as_str().expect("unit").to_owned();
let rival = c
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "unitId": i1_unit, "title": "Raison d'être", "body": "Un autre texte pour I1, voté en simulation.", "submit": true })),
)
.await["id"]
.as_str()
.expect("rival")
.to_owned();
// The chain open: one proven voice on E5, one on E6 — not enough to enter; two on
// the seed proposal of I1, which enters on proven votes alone.
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, charlie)] {
let b = c.ballot(&doc, &[(p, Some("for")), (&seed_i1, Some("for"))], None).await;
indexer.pay(block, comment_of(&b), signer);
}
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str());
assert!(unit(&d, "E5")["inForce"].is_null() && unit(&d, "E6")["inForce"].is_null());
// The chain closed: Luna, Bruno and Marc vote in simulation; E4, E5, E6 enter, and
// the rival of I1, three voices against two, takes the place of the seed proposal.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
let mut luna = c.other();
luna.login("Luna").await;
let mut bruno = c.other();
bruno.login("Bruno").await;
let mut marc = c.other();
marc.login("Marc").await;
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" },
{ "proposalId": e6, "choice": "for" }, { "proposalId": rival, "choice": "for" },
]);
luna.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
let lines = json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": rival, "choice": "for" }]);
bruno.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
marc.ok("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
let d = c.doc("monnaie-libre").await;
for (code, id) in [("E4", &e4), ("E5", &e5), ("E6", &e6), ("I1", &rival)] {
assert_eq!(unit(&d, code)["inForce"]["id"], id.as_str(), "{code}");
}
// The chain opens again: nothing is set aside by itself — the simulated votes still count, marked.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
let d = luna.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e5), pair(&find(&d, &e5)["tallySimulated"])), ((2, 0), (1, 0)));
assert_eq!(mine(&d, &e5), (Some("for"), Some("simulated")));
let (s, v) = luna.call("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
// A second proven voice on E6, after it entered.
let b = c.ballot(&d, &[(&e6, Some("for"))], None).await;
indexer.pay(200, comment_of(&b), Signer { account: OTHER, identity: Some((9004, "Eve", true)) });
ballots::sweep(&state).await.expect("sweep");
// Told first, nothing changed.
let told = ld_server::simulation::discard(&state, false).await.expect("dry run");
assert_eq!((told.applied, told.ballots, told.votes), (false, 3, 7));
let verdicts = |list: &[ld_server::simulation::Reexamined]| {
let mut v: Vec<(String, bool, bool)> = list.iter().map(|r| (r.code.clone(), r.holds, r.returned)).collect();
v.sort();
v
};
let expected_told: Vec<(String, bool, bool)> =
[("E4", false, false), ("E5", false, false), ("E6", true, false), ("I1", false, false)]
.iter()
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
.collect();
assert_eq!(verdicts(&told.reexamined), expected_told);
assert_eq!(unit(&c.doc("monnaie-libre").await, "E5")["inForce"]["id"], e5.as_str());
// Done: the simulated votes are gone; what does not hold on the proven votes returns.
let done = ld_server::simulation::discard(&state, true).await.expect("act");
let expected_done: Vec<(String, bool, bool)> =
[("E4", false, true), ("E5", false, true), ("E6", true, false), ("I1", false, true)]
.iter()
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
.collect();
assert_eq!(verdicts(&done.reexamined), expected_done);
let d = luna.doc("monnaie-libre").await;
assert!(unit(&d, "E4")["inForce"].is_null() && unit(&d, "E5")["inForce"].is_null());
assert_eq!(unit(&d, "E6")["inForce"]["id"], e6.as_str(), "two proven voices: it stays");
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str(), "the text it had displaced comes back");
assert_eq!(find(&d, &rival)["status"], "open");
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (None, None)));
let left: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated')",
)
.fetch_one(&state.db)
.await
.expect("left");
assert_eq!(left, (0, 0));
let mut adopted: Vec<String> =
sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions");
adopted.sort();
let mut kept = vec![seed_i1.clone(), e6.clone()];
kept.sort();
assert_eq!(adopted, kept, "the adoptions that do not hold are void; those on proven votes alone stay");
// Once is enough.
let again = ld_server::simulation::discard(&state, true).await.expect("again");
assert_eq!((again.ballots, again.reexamined.len()), (0, 0));
// Never without the dev login.
let mut cfg = chain_config(&indexer);
cfg.dev_login = false;
let (mut other, _) = Client::new(cfg).await;
let (s, _) = other.call("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_intention_never_confirmed_is_set_aside_and_told() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
// A vote proven first: « contre ».
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
indexer.pay(100, comment_of(&first), alice());
ballots::sweep(&state).await.expect("sweep");
// Then « pour », never paid: it takes the place of « contre » in the totals shown, for a while.
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
assert_eq!(mine(&d, &i1), (Some("for"), Some("pending")));
// Someone else sees the totals shown, intention included.
let mut yvv = c.other();
yvv.login("Yvv").await;
assert_eq!(tally(&yvv.doc("monnaie-libre").await, &i1), (1, 0));
// Its window passes: set aside, and said — under the article, and in her messages.
age(&state, "expires_at", -5, id_of(&b)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&b)).await.0, "expired");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), pending(&d, &i1)), ((0, 1), (0, 0)));
assert_eq!(mine(&d, &i1), (Some("against"), Some("recorded")), "the vote of before stays");
assert_eq!(find(&d, &i1)["myBallot"]["status"], "expired");
assert_eq!(ballot_messages(&mut c).await, [("ballot_expired".to_owned(), id_of(&b).to_owned())]);
let m = c.ok("GET", "/api/messages", None).await;
let message = &m["messages"][0];
assert_eq!((message["done"].as_bool(), message["gestures"].as_array().map(Vec::len)), (Some(true), Some(0)));
assert_eq!(message["ballot"]["lines"][0]["code"], "I1");
assert_eq!(message["ballot"]["lines"][0]["choice"], "for");
assert_eq!(m["unread"], 1);
// A ballot the wallet never signed is let go at once: nothing to tell.
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 1));
ballots::sweep(&state).await.expect("sweep");
assert_eq!(ballot_messages(&mut c).await.len(), 1);
// Purged a week later, its message goes with it.
age(&state, "settled_at", -8 * 24 * 3600, id_of(&b)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(ballot_messages(&mut c).await.len(), 0);
assert_eq!(c.ok("GET", "/api/messages/unread", None).await["unread"], 0);
}
#[tokio::test]
async fn two_votes_of_one_identity_from_two_sessions_the_chain_keeps_the_last() {
let indexer = Indexer::start().await;
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
chiara.login("Chiara").await;
let mut other = chiara.other();
other.login("Yvv").await;
let doc = chiara.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "0");
// One person, one wallet, two sessions: « pour » here, « contre » there.
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
let b = other.ballot(&doc, &[(&i1, Some("against"))], None).await;
// Before the proofs, each intention counts for its session (ADR 0012, the cost).
assert_eq!(tally(&chiara.doc("monnaie-libre").await, &i1), (1, 1));
// On the chain, « pour » comes last; the indexer reads it first.
indexer.pay(200, comment_of(&a), alice());
ballots::sweep(&state).await.expect("sweep");
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
let both = (db_status(&state, id_of(&a)).await.0, db_status(&state, id_of(&b)).await.0);
assert_eq!(both, ("confirmed".into(), "confirmed".into()));
// One voice: the last in the order of the chain.
let d = chiara.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (0, 0), (1, 0)));
let outcomes: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT ballot_id, outcome FROM ballot_lines ORDER BY ballot_id = ?")
.bind(id_of(&a))
.fetch_all(&state.db)
.await
.expect("lines");
assert_eq!(
outcomes,
[(id_of(&b).to_owned(), Some("superseded".into())), (id_of(&a).to_owned(), Some("counted".into()))]
);
let votes: Vec<(String, String)> =
sqlx::query_as("SELECT identity_id, choice FROM votes").fetch_all(&state.db).await.expect("votes");
assert_eq!(votes, [("idty:9001".to_owned(), "for".to_owned())]);
// The session whose vote was overtaken sees no vote of its own; nothing to tell.
let theirs = other.doc("monnaie-libre").await;
assert_eq!(mine(&theirs, &i1), (None, None));
assert!(ballot_messages(&mut other).await.is_empty());
}
#[tokio::test]
async fn a_for_proven_while_an_objection_holds_the_version_is_set_aside() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let (e1, e5) = (version(&doc, "E1", "0"), version(&doc, "E5", "0"));
// Proven first: « contre » on E5.
let before = c.ballot(&doc, &[(&e5, Some("against"))], None).await;
indexer.pay(100, comment_of(&before), alice());
ballots::sweep(&state).await.expect("sweep");
// « Pour », sealed: seen at once in place of « contre ».
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (Some("for"), Some("pending"))));
// An objection holds E5 back: the *for* still waiting weighs nothing.
let mut luna = c.other();
luna.login("Luna").await;
luna.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
)
.await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e5), pending(&d, &e5)), ((0, 1), (0, 0)));
// The chain proves it now: set aside; « contre » stays; she is told.
indexer.pay(200, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e5), counted(&state, &d, &e5).await), ((0, 1), (0, 1)));
assert_eq!(mine(&d, &e5), (Some("against"), Some("recorded")));
let held = &find(&d, &e5)["myBallot"];
assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection")));
assert_eq!(ballot_messages(&mut c).await, [("ballot_objection".to_owned(), id_of(&b).to_owned())]);
// A withdrawal, proven while held, counts.
let w = c.ballot(&doc, &[(&e5, None)], None).await;
indexer.pay(300, comment_of(&w), alice());
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((0, 0), (None, None)));
}
#[tokio::test]
async fn a_waiting_intention_counts_as_my_vote_in_decisions() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let without = |d: &Value| d["docs"][0]["withoutMyVote"].as_u64();
let before = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
let i1 = version(&doc, "I1", "0");
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
assert_eq!(after, before.map(|n| n - 1));
}
/// The evaluation as the engine gives it for these totals, on this version's letters.
async fn judged(state: &AppState, doc: &Value, id: &str, shown: (u32, u32)) -> Value {
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
let tally = ld_engine::threshold::Tally { for_votes: shown.0, against: shown.1 };
let e = ld_engine::threshold::evaluate(tally, st.electorate, &st.params(u, p)).expect("evaluation");
serde_json::to_value(e).expect("json")
}
/// What adoption judges: never an intention, simulated votes by default (ADR 0012).
async fn adopted_evaluation(state: &AppState, doc: &Value, id: &str) -> Value {
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
serde_json::to_value(st.evaluation(u, p).expect("evaluation")).expect("json")
}
/// The quorum (ADR 0013) is judged on the totals shown — votes recorded, pending and
/// simulated, the vote one sees as one's own replaced by one's intention: `quorum`,
/// `yesNeeded`, `noNeeded` and `missing` are those of the totals shown. Adoption counts
/// the proven votes and the simulated ones (`SIMULATED_ADOPTS`), never an intention.
#[tokio::test]
async fn the_quorum_is_judged_on_the_totals_shown_and_adoption_never_on_an_intention() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
// I1, a seed proposal at level 2: the quorum 12, 9 « pour » at no vote.
let i1 = version(&doc, "I1", "0");
assert_eq!(find(&doc, &i1)["evaluation"], judged(&state, &doc, &i1, (0, 0)).await);
assert_eq!(find(&doc, &i1)["evaluation"]["yesNeeded"], 9);
// Proven: « contre ». Then « pour », sealed, not yet proven: it takes the place of
// « contre » in the totals shown, and in the evaluation shown.
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
indexer.pay(100, comment_of(&first), alice());
ballots::sweep(&state).await.expect("sweep");
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
let shown = find(&d, &i1)["evaluation"].clone();
assert_eq!(shown, judged(&state, &d, &i1, (1, 0)).await, "on the totals shown");
assert_eq!((shown["cast"].as_u64(), shown["missing"].as_u64()), (Some(1), Some(8)));
let adoption = adopted_evaluation(&state, &d, &i1).await;
assert_eq!(adoption, judged(&state, &d, &i1, (0, 1)).await, "adoption: the proven « contre » only");
assert!(adoption["missing"].as_u64() > Some(8), "the intention never brings adoption closer: {adoption}");
// The detail says the same, and its five levels are asked at the votes shown.
let detail = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
assert_eq!(detail["proposal"]["evaluation"], shown);
assert!(detail["levels"].as_array().expect("levels").iter().all(|l| l["cast"] == 1), "{detail}");
// The chain closed: a simulated « pour » counts in both, like a proven vote.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
let mut luna = c.other();
luna.login("Luna").await;
luna.ok(
"POST",
&format!("/api/docs/{doc_id}/votes"),
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
)
.await;
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((2, 0), (1, 0), (1, 1)));
assert_eq!(find(&d, &i1)["evaluation"], judged(&state, &d, &i1, (2, 0)).await);
assert_eq!(adopted_evaluation(&state, &d, &i1).await, judged(&state, &d, &i1, (1, 1)).await);
}
/// « Un vote existe » (ADR 0013, `quorum::voted_units`), for every state of the buffer
/// (ADR 0012): an intention still pending, a proven line whatever became of it (a
/// withdrawal, a vote overtaken, a *for* set aside by an objection), a simulated vote,
/// a simulated withdrawal — the history of the ballots keeps their trace. A ballot the
/// wallet never signed, one never seen on the chain in time, one the chain refused, is
/// no vote.
#[tokio::test]
async fn a_vote_exists_for_every_state_of_the_buffer() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let unit_id = |code: &str| unit(&doc, code)["id"].as_str().expect("unit").to_owned();
let exists = |u: String| {
let state = state.clone();
async move { ld_server::quorum::a_vote_exists(&state, &u).await.expect("query") }
};
let (i1, e1, e2, e3, e4) = (
version(&doc, "I1", "0"),
version(&doc, "E1", "0"),
version(&doc, "E2", "0"),
version(&doc, "E3", "0"),
version(&doc, "E4", "0"),
);
for code in ["I1", "E1", "E2", "E3", "E4"] {
assert!(!exists(unit_id(code)).await, "{code}: nobody voted yet");
}
// A ballot the wallet never signed: nothing.
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
assert!(exists(unit_id("I1")).await, "an intention pending is a vote already");
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
assert!(!exists(unit_id("I1")).await, "let go at once: no vote");
// Never seen on the chain in time: no vote.
let late = c.ballot(&doc, &[(&e2, Some("for"))], None).await;
age(&state, "expires_at", -2 * 3600, id_of(&late)).await;
ballots::sweep(&state).await.expect("sweep");
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
assert!(!exists(unit_id("E2")).await);
// Proven, then withdrawn on the chain: no row in `votes`, the trace stays.
let a = c.ballot(&doc, &[(&e1, Some("for"))], None).await;
indexer.pay(100, comment_of(&a), alice());
ballots::sweep(&state).await.expect("sweep");
let w = c.ballot(&doc, &[(&e1, None)], None).await;
indexer.pay(101, comment_of(&w), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e1).await, (0, 0));
assert!(exists(unit_id("E1")).await, "a vote withdrawn leaves its trace");
// The chain closed: a simulated vote; a simulated vote, then withdrawn.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
c.ok("POST", &format!("/api/proposals/{e3}/vote"), Some(json!({ "choice": "against" }))).await;
assert!(exists(unit_id("E3")).await, "a simulated vote");
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e4).await, (0, 0));
assert!(exists(unit_id("E4")).await, "a simulated vote withdrawn leaves its trace");
// The quorum is held: the author (an administrator, for a text of the seed) may not set it.
let mut admin = c.other();
admin.login_admin("Yvv").await;
for code in ["E1", "E3", "E4"] {
let (s, v) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "{code}");
}
let (s, _) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E2")), Some(json!({ "quorum": 20 }))).await;
assert_eq!(s, StatusCode::OK, "E2: no vote");
// The act « supprime » sets every simulated vote aside: with them, their trace.
ld_server::simulation::discard(&state, true).await.expect("act");
assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await);
assert!(exists(unit_id("E1")).await, "a proven trace stays");
// And the quorum changes again where no trace stays — « tant que l'article ne garde la
// trace d'aucun vote » (F1.2, lot T2), not « ensuite, il ne change plus ».
for code in ["E3", "E4"] {
let (s, v) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
assert_eq!(s, StatusCode::OK, "{code}: {v}");
}
let (s, v) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E1")), Some(json!({ "quorum": 20 }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "E1: its proven trace holds it");
let _ = doc_id;
}
/// A bare repository of the texts, for the publication (ADR 0011, 0012).
struct Texts {
dir: std::path::PathBuf,
bare: std::path::PathBuf,
}
impl Texts {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("ld-v-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = dir.join("textes.git");
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
assert!(ok.success());
Self { dir, bare }
}
/// A configuration that publishes there; `simulated`: `LD_PUBLISH_SIMULATED`.
fn config(&self, mut cfg: Config, simulated: bool) -> Config {
cfg.data_dir = self.dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: self.bare.display().to_string(),
branch: "main".into(),
key: self.dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
simulated,
});
cfg
}
/// The French text of the Acte as the repository has it; `None`: nothing published.
fn french(&self) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&self.bare)
.args(["show", "main:monnaie-libre/acte-engagement-certification/fr.md"])
.output()
.expect("git show");
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
}
}
impl Drop for Texts {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// A body of a version, as the document shows it.
fn body_of(doc: &Value, id: &str) -> String {
find(doc, id)["body"].as_str().expect("body").to_owned()
}
/// Yvv, 2026-11-03: « Pas avant des votes prouvés ». A text that enters by simulated
/// votes is in force on the site, but sends nothing to the repository of the texts:
/// the publication waits until everything in effect holds on the proven votes alone —
/// the re-examination of the act « supprime » (`simulation`), told only.
#[tokio::test]
async fn a_text_entered_by_simulated_votes_is_in_force_but_never_published_before_proven_votes() {
let indexer = Indexer::start().await;
let texts = Texts::new("proven");
let (mut c, state) = Client::new(texts.config(chain_config(&indexer), false)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let (e4, e5) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"));
// The chain closed: two simulated « pour », E4 enters — in force on the site.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
for name in ["Luna", "Bruno"] {
let mut s = c.other();
s.login(name).await;
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
}
assert_eq!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
ld_server::publish::pass(&state).await.expect("pass");
assert!(texts.french().is_none(), "not before proven votes");
let publication = &c.doc("monnaie-libre").await["doc"]["publication"];
assert_eq!((publication["state"].as_str(), publication["waitsFor"].as_str()), (Some("suspended"), Some("proven")));
// The chain open: E5 enters on two proven votes — E4 still does not hold on them: nothing goes out.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
for (block, signer) in [(100, alice()), (101, charlie)] {
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
indexer.pay(block, comment_of(&b), signer);
}
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E5")["inForce"]["id"], e5.as_str());
ld_server::publish::pass(&state).await.expect("pass");
assert!(texts.french().is_none(), "E4 does not hold on the proven votes yet");
// The act « supprime »: E4 returns among the proposals; what is in force holds on the
// proven votes alone — it goes out.
ld_server::simulation::discard(&state, true).await.expect("act");
ld_server::publish::pass(&state).await.expect("pass");
let fr = texts.french().expect("published");
let (b4, b5) = (body_of(&d, &e4), body_of(&d, &e5));
assert!(fr.contains(b5.lines().next().expect("line")), "{fr}");
assert!(!fr.contains(b4.lines().next().expect("line")), "{fr}");
assert_eq!(c.doc("monnaie-libre").await["doc"]["publication"]["state"], "done");
}
/// Yvv may choose the other way (`LD_PUBLISH_SIMULATED=1`): published anyway.
#[tokio::test]
async fn with_the_switch_a_text_entered_by_simulated_votes_is_published() {
let texts = Texts::new("switch");
let (mut c, state) = Client::new(texts.config(Config::for_tests(), true)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let e4 = version(&doc, "E4", "0");
for name in ["Luna", "Bruno"] {
let mut s = c.other();
s.login(name).await;
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
}
ld_server::publish::pass(&state).await.expect("pass");
let fr = texts.french().expect("published");
assert!(fr.contains(body_of(&doc, &e4).lines().next().expect("line")), "{fr}");
}
/// Production runs the migrations up to 0008 (M5, `3f10a0e`), its vote on the chain off.
/// The image of now applies 0009, 0010 and 0011, in this order; 0010 rewrites no row —
/// a ballot of before stays a ballot of the chain, its lines without outcome, the votes
/// as they were — and the first start writes nothing of the buffer.
#[tokio::test]
async fn on_a_base_like_production_the_three_migrations_pass_in_order_and_0010_rewrites_nothing() {
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
let mut before = sqlx::migrate!("./migrations");
before.migrations = before.migrations.iter().filter(|m| m.version <= 8).cloned().collect::<Vec<_>>().into();
before.run(&pool).await.expect("up to 0008");
let tables = |pool: sqlx::SqlitePool| async move {
let ballots: Vec<(String, String, Option<i64>)> =
sqlx::query_as("SELECT id, status, block FROM ballots ORDER BY id")
.fetch_all(&pool)
.await
.expect("ballots");
let lines: Vec<(String, String, String)> =
sqlx::query_as("SELECT ballot_id, proposal_id, choice FROM ballot_lines ORDER BY ballot_id")
.fetch_all(&pool)
.await
.expect("lines");
let votes: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT proposal_id, identity_id, choice, source, ballot_id FROM votes ORDER BY proposal_id, identity_id",
)
.fetch_all(&pool)
.await
.expect("votes");
(ballots, lines, votes)
};
// The rows the image of before writes: a document, a ballot of the chain confirmed
// with its line and its vote, and one pending.
for sql in [
"INSERT INTO groups (slug, name, policy, hidden, position) VALUES ('monnaie-libre', 'Monnaie libre', 'g1-members', 0, 0)",
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, created_at)
VALUES ('d1', 'monnaie-libre', 'acte', '{\"fr\":\"Acte\"}', '{}', 'fr', 50, '2026-10-21T00:00:00Z')",
"INSERT INTO units (id, doc_id, code, section, position, inertia, created_at) VALUES ('u1', 'd1', 'E4', 'fondamental', 1, '3', '2026-10-21T00:00:00Z')",
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, revision, created_at, updated_at)
VALUES ('p1', 'u1', 'text', 'E4', 'Un texte.', 'open', 'seed', 1, '2026-10-21T00:00:00Z', '2026-10-21T00:00:00Z')",
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-21T00:00:00Z')",
"INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES ('idty:9001', 'g1', 'Alice', 1, '2026-10-21T00:00:00Z')",
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, voter_id, block,
event_index, created_at, expires_at, settled_at)
VALUES ('b1', 'd1', 'dev:chiara', 's1', 'h1', '0xr1', 'g1vote', 10, 'confirmed', 'idty:9001', 100, 1,
'2026-11-01T00:00:00Z', '2026-11-02T00:00:00Z', '2026-11-01T00:01:00Z')",
"INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', 'p1', 1, 'for')",
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id)
VALUES ('p1', 'idty:9001', 'for', 1, '2026-11-01T00:01:00Z', 'chain', 100, 1, 'b1')",
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, created_at, expires_at)
VALUES ('b2', 'd1', 'dev:chiara', 's2', 'h2', '0xr2', 'g1vote', 10, 'pending', '2026-11-03T00:00:00Z', '2099-01-01T00:00:00Z')",
"INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', 'p1', 1, 'against')",
] {
sqlx::query(sql).execute(&pool).await.unwrap_or_else(|e| panic!("{sql}: {e}"));
}
let written = tables(pool.clone()).await;
// The image of now.
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("0009 to 0011");
let order: Vec<i64> = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid")
.fetch_all(&pool)
.await
.expect("order");
assert_eq!(order, (1..=11).collect::<Vec<i64>>(), "0009, then 0010, then 0011");
assert_eq!(tables(pool.clone()).await, written, "no row rewritten");
let added: Vec<(String, String, Option<String>)> = sqlx::query_as(
"SELECT b.id, b.mode, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id ORDER BY b.id",
)
.fetch_all(&pool)
.await
.expect("columns");
assert_eq!(added, [("b1".into(), "chain".into(), None), ("b2".into(), "chain".into(), None)]);
let index: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'ballots_doc'")
.fetch_one(&pool)
.await
.expect("index");
assert_eq!(index, 1);
// The first start, the chain closed: the watcher passes, nothing of the buffer is written.
ballots::sweep(&state).await.expect("sweep");
assert_eq!(tables(pool.clone()).await, written);
let simulated: i64 = sqlx::query_scalar(
"SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated') + (SELECT COUNT(*) FROM votes WHERE source = 'simulated')
+ (SELECT COUNT(*) FROM messages WHERE topic = 'ballot')",
)
.fetch_one(&pool)
.await
.expect("simulated");
assert_eq!(simulated, 0);
// The ballots of before read as they did: a vote of the chain, recorded; the one
// pending, an intention that takes its place in the totals shown only.
let mine = ballots::my_votes(&state, "dev:chiara", "d1").await.expect("mine");
assert_eq!(mine.get("p1").map(|v| (v.choice.as_str(), v.source.as_str())), Some(("for", "chain")));
let st = ld_server::docs::load_state(&state, "d1").await.expect("state");
let pair = |t: ld_engine::threshold::Tally| (t.for_votes, t.against);
assert_eq!(
(pair(st.tally("p1")), pair(st.shown_tally("p1")), pair(st.pending_tally("p1"))),
((1, 0), (0, 1), (0, 1))
);
}