Files
decision/crates/ld-server/tests/overview.rs
T
YvvandClaude Opus 5.5 01b7841472 Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
  NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
  cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
  les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
  (l'auteur de la première version, un administrateur pour le seed), refusée dès
  qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
  en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
  il n'y en a pas, une fois par document, consigné, file de publication là où il
  publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
  quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
  publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
  d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
  auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
  cran 3, 43 au cran 4).

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

280 lines
12 KiB
Rust

//! A group at a glance: what is coming in each tool, and what is left to vote.
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;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
async fn new() -> Self {
Self::with_state().await.0
}
async fn with_state() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), 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 get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str, admin: bool) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
/// An invitation identity that opens only `groups`.
async fn invited(&mut self, admin: &mut Client, groups: &[&str]) {
let (s, v) =
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": groups }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let (s, v) = self.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
}
fn titles(items: &Value, lang: &str) -> Vec<String> {
items.as_array().expect("items").iter().map(|i| i["title"][lang].as_str().expect("title").to_owned()).collect()
}
async fn the_doc(c: &mut Client, group: &str) -> Value {
let v = c.get(&format!("/api/groups/{group}")).await;
let id = v["docs"][0]["id"].as_str().expect("doc").to_owned();
c.get(&format!("/api/docs/{id}")).await
}
fn version(doc: &Value, code: &str, which: &str) -> String {
let unit = doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit");
let v = if which == "inForce" { &unit["inForce"] } else { &unit["proposals"][which.parse::<usize>().expect("n")] };
v["id"].as_str().expect("id").to_owned()
}
/// The quorum of an article of the test group set to 2, as its author — an administrator,
/// for a text of the seed — sets it before the first vote (ADR 0013): 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, code: &str) {
let n = sqlx::query(
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
)
.bind(code)
.execute(&state.db)
.await
.expect("quorum")
.rows_affected();
assert_eq!(n, 1, "{code}");
}
async fn decisions(c: &mut Client, group: &str) -> Value {
c.get(&format!("/api/groups/{group}/decisions")).await
}
#[tokio::test]
async fn what_is_coming_is_served_with_the_group_in_three_languages() {
let mut c = Client::new().await;
c.login("Chiara", false).await;
let g = c.get("/api/groups/monnaie-libre").await;
let up = &g["upcoming"];
assert_eq!(titles(&up["docs"], "fr"), ["Acte d'engagement forgerons"]);
assert_eq!(titles(&up["mandats"], "fr"), ["Administration forum Duniter", "Administration forum Monnaie libre"]);
assert_eq!(titles(&up["protocoles"], "fr"), ["Runtime upgrade — décision on-chain"]);
for tool in ["docs", "mandats", "protocoles"] {
for lang in ["en", "es"] {
assert!(titles(&up[tool], lang).iter().all(|t| !t.is_empty()), "{tool} {lang}");
}
}
// The docs in progress are still there, and only Monnaie libre has something coming.
assert_eq!(g["docs"].as_array().expect("docs").len(), 1);
let essai = c.get("/api/groups/essai").await;
for tool in ["docs", "mandats", "protocoles"] {
assert_eq!(essai["upcoming"][tool], json!([]), "{tool}");
}
}
#[tokio::test]
async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() {
let mut admin = Client::new().await;
admin.login("Yvv", true).await;
let mut guest = admin.other();
guest.invited(&mut admin, &["essai"]).await;
let g = guest.get("/api/groups/axiom").await;
assert_eq!(g["group"]["open"], false);
assert_eq!(g["docs"], json!([]));
for tool in ["docs", "mandats", "protocoles"] {
assert_eq!(g["upcoming"][tool], json!([]), "{tool}");
}
let (s, _) = guest.call("GET", "/api/groups/axiom/decisions", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (s, _) = guest.other().call("GET", "/api/groups/monnaie-libre/decisions", None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
// In the test group, which votes on the server (Monnaie libre votes on the chain:
// tests/ballots.rs counts « sans ton vote » there).
let (mut c, state) = Client::with_state().await;
c.login("Chiara", false).await;
// Someone else, who will not vote: W(essai) = 2.
let mut yvv = c.other();
yvv.login("Yvv", false).await;
let d = decisions(&mut c, "essai").await;
assert_eq!(d["canVote"], true);
let docs = d["docs"].as_array().expect("docs");
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
// 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title
// of the document and the order of 7 sections (« ordonnancement » holds one article: nothing
// to decide).
assert_eq!(docs[0]["toVote"], 41);
assert_eq!(docs[0]["withoutMyVote"], 41);
let doc = the_doc(&mut c, "essai").await;
// Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two.
two_voices(&state, "E3").await;
two_voices(&state, "I1").await;
c.vote(&version(&doc, "E3", "0"), "for").await;
c.vote(&version(&doc, "E3", "1"), "against").await;
c.vote(&version(&doc, "I1", "0"), "for").await;
let d = decisions(&mut c, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
// Someone else has not voted yet.
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
}
#[tokio::test]
async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_count() {
let mut yvv = Client::new().await;
yvv.login("Yvv", false).await;
let mut chiara = yvv.other();
chiara.login("Chiara", false).await;
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
// (ADR 0013): one « for » adopts.
let doc = the_doc(&mut yvv, "essai").await;
let e3 = version(&doc, "E3", "0");
let v = yvv.vote(&e3, "for").await;
assert_eq!(v["entered"][0]["code"], "E3");
let d = decisions(&mut yvv, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
// A new article by Chiara: one more module to vote.
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (s, v) = chiara
.call(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({
"title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true,
})),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let added = v["id"].as_str().expect("id").to_owned();
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41);
yvv.vote(&added, "against").await;
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40);
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
let (s, v) = chiara
.call(
"PATCH",
&format!("/api/proposals/{added}"),
Some(json!({
"title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours.",
})),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "substantive");
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
}
#[tokio::test]
async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
let mut yvv = Client::new().await;
yvv.login("Yvv", true).await;
let doc = the_doc(&mut yvv, "essai").await;
// Two articles of a single open version each: one of them is held (ADR 0010).
let (i2, k1) = (version(&doc, "I2", "0"), version(&doc, "K1", "0"));
let (s, v) = yvv
.call(
"POST",
&format!("/api/proposals/{i2}/objections"),
Some(json!({ "otherId": k1, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
// Nobody can vote on it while it is held: one module fewer to vote, for everyone.
let d = decisions(&mut yvv, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
// The list of the group still counts it among the proposals in progress: it is open.
let g = yvv.get("/api/groups/essai").await;
let open = doc["units"]
.as_array()
.expect("units")
.iter()
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|p| p["status"] == "open").count() as u64);
assert_eq!(g["docs"][0]["open"].as_u64(), Some(open.sum::<u64>()));
}
#[tokio::test]
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
let mut admin = Client::new().await;
admin.login("Yvv", true).await;
let mut guest = admin.other();
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
let d = decisions(&mut guest, "monnaie-libre").await;
assert_eq!(d["canVote"], false);
assert_eq!(d["docs"][0]["toVote"], 41);
assert!(d["docs"][0]["withoutMyVote"].is_null());
// In the test group, the same person votes.
let d = decisions(&mut guest, "essai").await;
assert_eq!(d["canVote"], true);
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
}