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

395 lines
19 KiB
Rust

//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010), end to end on an
//! in-memory database. What a typo is does not change (`ld_engine::edit`); who
//! applies it, and when, does.
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, deadlines, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
/// These scenarios need texts in force; the seed of today has none.
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
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");
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 err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
let (s, v) = self.call(method, uri, body).await;
(s, v["error"].as_str().unwrap_or_default().to_owned())
}
async fn login(&mut self, name: &str, admin: bool) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).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
}
async fn propose(&mut self, doc: &Value, title: &str, body: &str) -> String {
let id = doc["doc"]["id"].as_str().expect("id");
let v = self
.ok(
"POST",
&format!("/api/docs/{id}/proposals"),
Some(json!({ "title": title, "body": body, "submit": true })),
)
.await;
v["id"].as_str().expect("id").to_owned()
}
async fn fix(&mut self, id: &str, title: &str, body: &str) -> Value {
self.ok("PATCH", &format!("/api/proposals/{id}"), Some(json!({ "title": title, "body": body }))).await
}
async fn detail(&mut self, id: &str) -> Value {
self.ok("GET", &format!("/api/proposals/{id}"), None).await
}
async fn messages(&mut self) -> Value {
self.ok("GET", "/api/messages", None).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 gestures(v: &Value) -> Vec<&str> {
v["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
}
fn last(history: &Value) -> &Value {
history.as_array().expect("history").last().expect("entry")
}
const TITLE: &str = "Réciprocité";
const VOTED: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagemnts.";
const FIX_1: &str = "Je m'engage à ne certifier que des personnes qui respectent ces engagemnts.";
const FIX_2: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagements.";
/// C30, by the API: someone else's typo on a proposal waits for its author, who
/// validates it from her messages — and keeps the hand while it is not in force.
#[tokio::test]
async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
// Its author sets the quorum 2 before the first vote (ADR 0013): in the test group, one
// voice of two then no longer makes it pass, and it stays hers to answer for.
let u = chiara.detail(&p).await["unit"]["id"].as_str().expect("unit").to_owned();
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
let w = yvv.fix(&p, TITLE, FIX_1).await;
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
assert!(w["typo"]["dueAt"].is_string());
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
// Nothing written yet: the text stays, and the pending fix shows with its diff.
let d = yvv.detail(&p).await;
assert_eq!(d["proposal"]["body"], VOTED);
let t = &d["typos"][0];
assert_eq!((t["status"].as_str(), t["writer"].as_str()), (Some("pending"), Some("Yvv")));
assert!(t["diff"].as_array().is_some_and(|x| !x.is_empty()));
assert_eq!(gestures(t), Vec::<&str>::new(), "the writer does not answer");
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), ["validate", "refuse"]);
assert_eq!(yvv.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
// Its author is told, by a message with the gestures.
let m = chiara.messages().await;
let msg = &m["messages"][0];
assert_eq!((msg["kind"].as_str(), msg["topic"].as_str()), (Some("typo_pending"), Some("typo")));
assert_eq!(gestures(msg), ["validate", "refuse"]);
assert_eq!(msg["typo"]["writer"], "Yvv");
assert_eq!(msg["typo"]["version"]["id"], p.as_str());
// Validated: the fix is in the text, the vote stays.
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["body"], FIX_1);
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
let h = last(&d["history"]);
assert_eq!(
(h["kind"].as_str(), h["author"].as_str(), h["typo"]["answeredBy"].as_str()),
(Some("typo"), Some("Yvv"), Some("Chiara"))
);
let m = chiara.messages().await;
assert_eq!(
(gestures(&m["messages"][0]).len(), m["messages"][0]["outcome"].as_str(), m["unread"].as_i64()),
(0, Some("applied"), Some(0))
);
// The author keeps the hand while it is not in force: she takes it back, then validates it again.
assert_eq!(gestures(&d["typos"][0]), ["refuse"]);
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["body"], VOTED);
assert_eq!(last(&d["history"])["kind"], "revert");
assert_eq!(gestures(&d["typos"][0]), ["validate"]);
chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
// Her own fixes apply at once, as before.
let own = chiara.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await;
assert_eq!(own["edit"], "typo");
// A change of meaning stays refused to anyone else.
let s = yvv
.err("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": TITLE, "body": "Tout autre chose." })))
.await;
assert_eq!(s, (StatusCode::CONFLICT, "substantive_not_author".into()));
}
/// C31, by the API: on a text in force, only a validated fix corrects it — by its
/// author, or an administrator for a starting text; else by itself after three days.
#[tokio::test]
async fn on_a_text_in_force_only_a_validated_typo_corrects_it() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let doc = chiara.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let corrections = || {
let db = state.db.clone();
let doc_id = doc_id.clone();
async move {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM outbox WHERE doc_id = ? AND reason = 'correction typographique'",
)
.bind(doc_id)
.fetch_one(&db)
.await
.expect("outbox")
}
};
let e1 = unit(&doc, "E1")["inForce"].clone();
let e1_id = e1["id"].as_str().expect("id").to_owned();
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
let w = chiara.fix(&e1_id, e1["title"].as_str().expect("t"), &fixed).await;
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("admin")));
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
assert_ne!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str(), "not in force before it is validated");
assert_eq!(corrections().await, 0, "nothing republished");
// A starting text has no author: an administrator answers, from his messages.
assert_eq!(gestures(&yvv.messages().await["messages"][0]), ["validate", "refuse"]);
assert_eq!(chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str());
assert_eq!(corrections().await, 1, "republished once validated");
// In force, an answer is final.
assert_eq!(
yvv.err("POST", &format!("/api/typos/{tid}/refuse"), None).await,
(StatusCode::CONFLICT, "settled".into())
);
// Nobody answers: three days later it applies by itself.
let e2 = unit(&doc, "E2")["inForce"].clone();
let e2_id = e2["id"].as_str().expect("id").to_owned();
let e2_fixed = e2["body"].as_str().expect("body").replace('\'', "\u{2019}");
chiara.fix(&e2_id, e2["title"].as_str().expect("t"), &e2_fixed).await;
state.clock.advance(3 * 86_400 - 60);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
state.clock.advance(120);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 1);
let d = chiara.detail(&e2_id).await;
assert_eq!(d["proposal"]["body"], e2_fixed.as_str());
assert_eq!(last(&d["history"])["typo"]["answeredBy"], Value::Null);
assert_eq!(last(&d["history"])["typo"]["deadline"], true);
assert_eq!(corrections().await, 2);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
// An administrator's own fix on a starting text applies at once.
let i1 = unit(&doc, "I1")["inForce"].clone();
let i1_id = i1["id"].as_str().expect("id");
let body = i1["body"].as_str().expect("body").replace('\'', "\u{2019}");
assert_eq!(yvv.fix(i1_id, i1["title"].as_str().expect("t"), &body).await["edit"], "typo");
}
#[tokio::test]
async fn on_a_text_in_force_by_an_author_she_answers_and_a_refusal_is_final() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
// W = 2 in the test group: two voices for make it pass.
for c in [&mut chiara, &mut yvv] {
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
}
assert_eq!(chiara.detail(&p).await["proposal"]["status"], "in_force");
let tid = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
assert_eq!(chiara.detail(&p).await["proposal"]["body"], VOTED);
// In force: no going back on the answer.
let back = chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(back, (StatusCode::CONFLICT, "settled".into()));
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), Vec::<&str>::new());
}
/// Two fixes waiting on one text: each is judged against the voted text; once one
/// applies, the other is written over a text that is gone — stale, « recharge ».
#[tokio::test]
async fn two_pending_typos_on_one_text_the_second_goes_stale() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut luna = chiara.other();
luna.login("Luna", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
let t1 = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
let w2 = luna.fix(&p, TITLE, FIX_2).await;
assert_eq!(w2["edit"], "typo_pending", "each one slip from the voted text");
let t2 = w2["typo"]["id"].as_str().expect("id").to_owned();
chiara.ok("POST", &format!("/api/typos/{t1}/validate"), None).await;
let d = chiara.detail(&p).await;
let second = d["typos"].as_array().expect("t").iter().find(|t| t["id"] == t2.as_str()).expect("t2").clone();
assert_eq!((second["status"].as_str(), gestures(&second).len()), (Some("stale"), 0));
let late = chiara.err("POST", &format!("/api/typos/{t2}/validate"), None).await;
assert_eq!(late, (StatusCode::CONFLICT, "changed".into()));
let m = chiara.messages().await;
let msg =
m["messages"].as_array().expect("m").iter().find(|x| x["typo"]["id"] == t2.as_str()).expect("msg").clone();
assert_eq!((msg["outcome"].as_str(), gestures(&msg).len()), (Some("stale"), 0));
// The deadline never applies it.
state.clock.advance(4 * 86_400);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
// Written again over the text of now, it waits as any other.
assert_eq!(luna.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await["edit"], "typo_pending");
}
#[tokio::test]
async fn a_change_of_substance_or_a_withdrawal_leaves_pending_typos_stale() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
let t = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
chiara
.fix(&p, TITLE, "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.")
.await;
assert_eq!(chiara.detail(&p).await["typos"][0]["status"], "stale");
assert_eq!(
chiara.err("POST", &format!("/api/typos/{t}/validate"), None).await,
(StatusCode::CONFLICT, "changed".into())
);
let q = chiara.propose(&doc, "Autre", VOTED).await;
yvv.fix(&q, "Autre", FIX_1).await;
chiara.ok("POST", &format!("/api/proposals/{q}/withdraw"), None).await;
let stale: i64 = {
let d = chiara.detail(&q).await;
d["typos"].as_array().expect("t").iter().filter(|t| t["status"] == "stale").count() as i64
};
assert_eq!(stale, 1);
}
#[tokio::test]
async fn translations_stay_editorial() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
yvv.ok(
"PUT",
&format!("/api/proposals/{p}/translations/en"),
Some(json!({ "title": "Reciprocity", "body": "I only certify people who keep these commitments." })),
)
.await;
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["translations"]["en"]["title"], "Reciprocity", "without any validation");
assert_eq!(d["typos"].as_array().expect("t").len(), 0);
assert_eq!(chiara.messages().await["messages"].as_array().expect("m").len(), 0);
}
/// A text without author: only the administrators who open its group are told,
/// and a message shows only while its recipient opens the group.
#[tokio::test]
async fn only_administrators_who_open_the_group_are_told() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let mut luna = chiara.other();
luna.login("Luna", true).await;
let leave = |who: &'static str| {
let db = state.db.clone();
async move {
sqlx::query("DELETE FROM memberships WHERE identity_id = ? AND group_slug = 'essai'")
.bind(who)
.execute(&db)
.await
.expect("leave");
}
};
leave("dev:luna").await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"].clone();
let body = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
let w = chiara.fix(e1["id"].as_str().expect("id"), e1["title"].as_str().expect("t"), &body).await;
assert_eq!(w["typo"]["answerer"], "admin");
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
assert_eq!(luna.messages().await["messages"].as_array().expect("m").len(), 0, "Luna does not open the test group");
leave("dev:yvv").await;
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0, "no longer shown");
}