forked from yvv/decision
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>
385 lines
18 KiB
Rust
385 lines
18 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 {
|
|
async fn new() -> (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 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;
|
|
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");
|
|
}
|