Files
decision/crates/ld-server/tests/quorum.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

413 lines
19 KiB
Rust

//! The quorum (ADR 0013). Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition
//! par défaut, variable modifiable par l'auteur initial du texte… c'est plutôt un
//! quorum » ; 2026-11-03, below the quorum: « Ils comptent comme non ». Each level has
//! its quorum — « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, 1 everywhere in the test
//! group; the author of an article's first version sets its own, « avant le 1er vote ».
//! An existing base gets the quorums by level through a guarded amendment, decided once.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
/// The quorums of Monnaie libre by level (Yvv, 2026-11-02).
const QUORUMS: [u64; 5] = [12, 12, 50, 50, 50];
/// W of Monnaie libre in the tests (`Config::for_tests`, no chain read).
const W: u64 = 5745;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
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 get(&mut self, uri: &str) -> Value {
self.ok("GET", uri, None).await
}
async fn login(&mut self, name: &str, admin: bool) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
}
async fn vote(&mut self, proposal: &str, choice: Option<&str>) -> Value {
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await
}
async fn doc(&mut self, group: &str) -> Value {
let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned();
self.get(&format!("/api/docs/{id}")).await
}
/// `PUT /units/{id}/quorum`.
async fn set(&mut self, unit: &str, quorum: Value) -> (StatusCode, Value) {
self.call("PUT", &format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": quorum }))).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn unit_by_id<'a>(doc: &'a Value, id: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["id"] == id).expect("unit")
}
fn numbers(levels: &Value, key: &str) -> Vec<u64> {
levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect()
}
/// (quorum, quorumDefault, canSetQuorum) of a unit.
fn quorum_of(u: &Value) -> (Option<u64>, Option<u64>, Option<bool>) {
(u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool())
}
/// (quorum, cast, required, yesNeeded, noNeeded, missing, reached) of an evaluation.
fn figures(e: &Value) -> (u64, u64, u64, u64, u64, u64, bool) {
let n = |k: &str| e[k].as_u64().unwrap_or_else(|| panic!("{k}: {e}"));
(n("quorum"), n("cast"), n("required"), n("yesNeeded"), n("noNeeded"), n("missing"), e["reached"] == true)
}
async fn fresh() -> AppState {
fresh_with(Config::for_tests()).await
}
async fn fresh_with(cfg: Config) -> 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");
state
}
#[tokio::test]
async fn each_level_has_its_quorum_and_every_evaluation_says_it() {
let state = fresh().await;
let mut c = Client::of(&state);
c.login("Chiara", false).await;
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["doc"]["quorums"], json!(QUORUMS));
assert_eq!(numbers(&doc["levels"], "quorum"), QUORUMS);
// I1, a seed proposal at level 2: its level's quorum, 12. Nobody voted: judged on 12
// voters, the 12 missing counted as against — 9 « pour » pass (the control figure).
let i1 = unit(&doc, "I1");
assert_eq!(quorum_of(i1), (None, Some(12), Some(false)), "not the author, no administrator");
assert_eq!(figures(&i1["proposals"][0]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
// E3 at level 3: the quorum 50, 39 « pour » at no vote.
let e3 = unit(&doc, "E3");
assert_eq!(e3["inertia"], 3);
assert_eq!(quorum_of(e3), (None, Some(50), Some(false)));
assert_eq!(figures(&e3["proposals"][0]["evaluation"]), (50, 0, 39, 39, 0, 39, false));
// The detail says the same, the five levels on their quorums.
let p = i1["proposals"][0]["id"].as_str().expect("i1");
let d = c.get(&format!("/api/proposals/{p}")).await;
assert_eq!(figures(&d["proposal"]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
assert_eq!(numbers(&d["levels"], "quorum"), QUORUMS);
assert_eq!(numbers(&d["levels"], "required"), [8, 9, 39, 43, 46]);
// The test group: 1 everywhere.
let essai = c.doc("essai").await;
assert_eq!(essai["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
assert_eq!(quorum_of(unit(&essai, "E3")), (None, Some(1), Some(false)));
assert_eq!(unit(&essai, "I1")["proposals"][0]["evaluation"]["quorum"], 1);
// What is published carries them (document.json).
let id = doc["doc"]["id"].as_str().expect("id");
let st = ld_server::docs::load_state(&state, id).await.expect("state");
let files = ld_server::publish::render(&state, &st).await.expect("render");
let json: Value = serde_json::from_str(&files.iter().find(|(n, _)| n == "document.json").expect("document.json").1)
.expect("json");
assert_eq!(json["quorums"], json!(QUORUMS));
}
#[tokio::test]
async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() {
let state = fresh().await;
let mut chiara = Client::of(&state);
chiara.login("Chiara", false).await;
let mut luna = chiara.other();
luna.login("Luna", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "fondamental", "title": "Nouveau", "body": "Un nouvel article.", "submit": true })),
)
.await;
let (u, text) =
(created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned());
// Its author may, another member may not; the test group: its level's quorum, 1.
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true)));
assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false)));
let (s, v) = luna.set(&u, json!(2)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::FORBIDDEN, Some("forbidden")));
// From 1 in the test group to W (2 members).
for bad in [json!(0), json!(3), json!(-1), json!(1.5)] {
let (s, v) = chiara.set(&u, bad.clone()).await;
assert!(s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, "{bad}: {s} {v}");
if s == StatusCode::BAD_REQUEST {
assert_eq!(v["error"], "quorum", "{bad}");
}
}
let (s, v) = chiara.set(&u, json!(2)).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v, json!({ "quorum": 2, "quorumDefault": 1, "canSetQuorum": true }));
let doc = chiara.doc("essai").await;
let mine = unit_by_id(&doc, &u);
assert_eq!(quorum_of(mine), (Some(2), Some(1), Some(true)));
assert_eq!(mine["proposals"][0]["evaluation"]["quorum"], 2);
// A change of its inertia is judged on it too.
let inertia = chiara.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await;
let d = chiara.get(&format!("/api/proposals/{}", inertia["id"].as_str().expect("id"))).await;
assert_eq!(d["proposal"]["evaluation"]["quorum"], 2);
assert_eq!(numbers(&d["levels"], "quorum"), [2; 5], "the article's own quorum, at every level");
// Back to its level's, then 2 again: each change is journaled.
let (s, v) = chiara.set(&u, Value::Null).await;
assert_eq!((s, &v), (StatusCode::OK, &json!({ "quorum": null, "quorumDefault": 1, "canSetQuorum": true })));
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
let journal: Vec<(Option<i64>, Option<i64>, String)> = sqlx::query_as(
"SELECT q.from_quorum, q.to_quorum, i.display_name FROM quorum_changes q
JOIN identities i ON i.id = q.identity_id WHERE q.unit_id = ? ORDER BY q.id",
)
.bind(&u)
.fetch_all(&state.db)
.await
.expect("journal");
let chiara_name = "Chiara".to_owned();
assert_eq!(
journal,
[(None, Some(2), chiara_name.clone()), (Some(2), None, chiara_name.clone()), (None, Some(2), chiara_name)]
);
// The first vote: 1 « pour » of 2 voters on a quorum of 2 does not pass, and the
// quorum is held from then on.
let v = luna.vote(&text, Some("for")).await;
assert!(v["entered"].as_array().expect("entered").is_empty(), "{v}");
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(false)));
let (s, v) = chiara.set(&u, json!(1)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
// A vote on its change of inertia counts as well: it is the same article.
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
// Her vote withdrawn: in the test group (no buffer) it leaves no trace, and the author
// sets the quorum again — « tant que l'article ne garde la trace d'aucun vote » (F1.2,
// lot T2), not « ensuite, il ne change plus ».
luna.vote(&text, None).await;
assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(true)));
let (s, v) = chiara.set(&u, json!(1)).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
/// A ballot of the chain on a proposal, as the server writes it when a wallet asks.
async fn ballot(state: &AppState, doc: &str, proposal: &str, status: &str, n: u32) {
let id = format!("ballot-{status}-{n}");
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status,
created_at, expires_at)
VALUES (?, ?, (SELECT id FROM identities LIMIT 1), 'salt', ?, ?, 'g1vote', 1, ?,
'2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')",
)
.bind(&id)
.bind(doc)
.bind(format!("digest-{id}"))
.bind(format!("remark-{id}"))
.bind(status)
.execute(&state.db)
.await
.expect("ballot");
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, 1, 'for')")
.bind(&id)
.bind(proposal)
.execute(&state.db)
.await
.expect("line");
}
#[tokio::test]
async fn in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pending() {
let state = fresh().await;
let mut admin = Client::of(&state);
admin.login("Yvv", true).await;
let mut chiara = admin.other();
chiara.login("Chiara", false).await;
let doc = admin.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = unit(&doc, "I1");
let (u, p) = (i1["id"].as_str().expect("u").to_owned(), i1["proposals"][0]["id"].as_str().expect("p").to_owned());
// A text of the seed has no author: an administrator answers for it.
assert_eq!(quorum_of(i1), (None, Some(12), Some(true)));
let (s, _) = chiara.set(&u, json!(20)).await;
assert_eq!(s, StatusCode::FORBIDDEN);
// At least 2: one voice never passes a text alone; at most W.
for bad in [1, 0, W + 1] {
let (s, v) = admin.set(&u, json!(bad)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("quorum")), "{bad}");
}
for good in [2, W, 20] {
let (s, v) = admin.set(&u, json!(good)).await;
assert_eq!((s, v["quorum"].as_u64()), (StatusCode::OK, Some(good)), "{v}");
}
let doc = admin.doc("monnaie-libre").await;
assert_eq!(unit(&doc, "I1")["proposals"][0]["evaluation"]["quorum"], 20);
// A refused ballot, an expired one, are no vote.
ballot(&state, &doc_id, &p, "refused", 1).await;
ballot(&state, &doc_id, &p, "expired", 2).await;
assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
assert_eq!(admin.set(&u, json!(12)).await.0, StatusCode::OK);
// A pending one is: the vote is cast, the chain has not said it yet.
ballot(&state, &doc_id, &p, "pending", 3).await;
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
assert_eq!(quorum_of(unit(&admin.doc("monnaie-libre").await, "I1")), (Some(12), Some(12), Some(false)));
let (s, v) = admin.set(&u, Value::Null).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
// A confirmed one, on another article.
let e1 = unit(&doc, "E1");
let (u1, p1) = (e1["id"].as_str().expect("u").to_owned(), e1["proposals"][0]["id"].as_str().expect("p").to_owned());
assert!(!ld_server::quorum::a_vote_exists(&state, &u1).await.expect("query"));
ballot(&state, &doc_id, &p1, "confirmed", 4).await;
assert_eq!(admin.set(&u1, json!(30)).await.1["error"], "voted");
}
// ---------------------------------------------------------------------------
// An existing base: the quorum amendment
// ---------------------------------------------------------------------------
/// The seed production runs on 2026-11-02: no quorum anywhere, the C of before.
fn production_config() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
cfg
}
async fn restart(before: &AppState, cfg: Config) -> AppState {
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
/// (group, from, to, outcome, reason) of each decision.
type Decision = (String, Option<String>, String, String, Option<String>);
async fn decisions(state: &AppState) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, q.from_quorums, q.to_quorums, q.outcome, q.reason
FROM quorum_amendments q JOIN documents d ON d.id = q.doc_id ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions")
}
async fn stored(state: &AppState, group: &str) -> Option<String> {
sqlx::query_scalar("SELECT quorums FROM documents WHERE group_slug = ?")
.bind(group)
.fetch_one(&state.db)
.await
.expect("quorums")
}
fn row(group: &str, from: Option<&str>, to: &str, outcome: &str, reason: Option<&str>) -> Decision {
(group.into(), from.map(Into::into), to.into(), outcome.into(), reason.map(Into::into))
}
const ML: &str = "[12,12,50,50,50]";
const ESSAI: &str = "[1,1,1,1,1]";
#[tokio::test]
async fn the_quorum_amendment_writes_the_quorums_by_level_once() {
// Production as it runs: no quorum written; every level reads the C of before.
let born = fresh_with(production_config()).await;
assert_eq!((stored(&born, "monnaie-libre").await, stored(&born, "essai").await), (None, None));
assert!(decisions(&born).await.is_empty(), "a seed without quorums decides nothing");
let mut c = Client::of(&born);
c.login("Chiara", false).await;
assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50]));
assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
// Started with today's seed: written, where none was.
let state = restart(&born, Config::for_tests()).await;
let first = decisions(&state).await;
assert_eq!(
first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
[row("essai", None, ESSAI, "applied", None), row("monnaie-libre", None, ML, "applied", None)]
);
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some(ML));
assert_eq!(stored(&state, "essai").await.as_deref(), Some(ESSAI));
let mut c = Client::of(&state);
c.login("Chiara", false).await;
assert_eq!(unit(&c.doc("monnaie-libre").await, "I1")["proposals"][0]["evaluation"]["quorum"], 12);
// Republished where the document publishes; never from the test group.
let queued: Vec<String> = sqlx::query_scalar(
"SELECT d.group_slug FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'quorums%'",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(queued, ["monnaie-libre"]);
// Once only.
let again = restart(&state, Config::for_tests()).await;
assert_eq!(decisions(&again).await, first);
// A new base is born with them: present.
let new = fresh().await;
let present: Vec<String> = decisions(&new).await.into_iter().map(|(_, _, _, o, _)| o).collect();
assert_eq!(present, ["present", "present"]);
}
#[tokio::test]
async fn quorums_written_otherwise_are_kept_and_the_refusal_is_kept() {
let born = fresh_with(production_config()).await;
sqlx::query("UPDATE documents SET quorums = '[3,3,3,3,3]' WHERE group_slug = 'monnaie-libre'")
.execute(&born.db)
.await
.expect("quorums");
let state = restart(&born, Config::for_tests()).await;
let got = decisions(&state).await;
assert_eq!(
got.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
[
row("essai", None, ESSAI, "applied", None),
row("monnaie-libre", Some("[3,3,3,3,3]"), ML, "refused", Some("quorums_set")),
]
);
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some("[3,3,3,3,3]"));
}