//! 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, } 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) -> (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 { 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) -> (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; 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"); }