Files
decision/crates/ld-server/tests/ballots.rs
T
YvvandClaude Opus 5.5 3f1f968f16 v3 : serveur des objections, des coquilles validées et de la messagerie (lot F)
Objections « redit » / « contredit » à la place des signalements : la
dernière arrivée des deux versions n'est plus votable (repli, panier,
chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un
texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article
visé (proposition liée) ou en reformulant ; la personne qui objecte lève
ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection
est levée. Un administrateur peut lever.

Coquilles : la sienne passe tout de suite ; celle d'un autre attend
l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur
garde la main tant que le texte n'est pas en vigueur. En vigueur, seule
une coquille validée corrige (auteur de la version, administrateur pour
un texte semé). Deux coquilles en attente : la seconde se mesure au texte
voté ; à l'application, celle qui ne colle plus est périmée.

Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque
personne ne lit que les siens. Échéances par le balayage existant, avec
rattrapage au démarrage et horloge injectable (route de dev). Migration
0008 additive : les signalements deviennent des « contredit », sans perte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:45:48 +01:00

841 lines
38 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,
}
#[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)>,
}
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 hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
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)
.cloned()
.collect();
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).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
}
}
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 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 bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, 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 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()
}
}
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) {
let t = &find(doc, id)["tally"];
(t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against"))
}
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", "inForce");
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: pending, nothing counted.
assert_eq!(c.status(&b).await["status"], "pending");
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 0));
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), (1, 0));
assert_eq!(tally(&doc, &e3), (0, 1));
// The session that asked sees « ton vote », confirmed on the chain.
assert_eq!(find(&doc, &i1)["myVote"], "for");
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 (C = 50).
assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50);
// 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;
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
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", "inForce");
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", "inForce");
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");
}
#[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", "inForce");
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
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!(tally(&c.doc("monnaie-libre").await, &i1), (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", "inForce");
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", "inForce");
let e1 = version(&doc, "E1", "inForce");
// 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", "inForce");
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", "inForce");
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");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
assert_eq!(c.status(&b).await["status"], "expired");
// 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", "inForce");
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", "inForce"), version(&doc, "E1", "inForce"));
// 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.
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(33), Some(32)));
}
#[tokio::test]
async fn fifty_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", "inForce");
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
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 == 49 {
// The last ballot is signed; before the chain confirms it, an objection holds
// E5 back against the text in force E1 (ADR 0010): 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 == 48 {
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e4), (49, 0));
assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50");
}
}
ballots::sweep(&state).await.expect("sweep");
let s = c.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());
// A ballot sealed before the objection counts, as a vote already cast: kept, but
// the version held back cannot enter into force.
assert_eq!(tally(&d, &e5), (50, 0));
assert_eq!(find(&d, &e5)["votable"], false);
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
}
// ---------------------------------------------------------------------------
// Without the chain, and in managed groups
// ---------------------------------------------------------------------------
#[tokio::test]
async fn without_a_vote_account_the_chain_vote_is_off_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;
assert_eq!(doc["vote"]["way"], "chain");
assert_eq!(doc["vote"]["open"], false);
assert_eq!(doc["vote"]["reason"], "chain_off");
assert!(doc["vote"]["chain"].is_null());
assert_eq!(doc["canVote"], false);
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
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)));
let p = version(&essai, "I1", "inForce");
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
}
#[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", "inForce");
let essai = c.doc("essai").await;
let elsewhere = version(&essai, "I1", "inForce");
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;
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 never makes a text pass");
// 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);
}