Fusion du lot F : objections redit / contredit, coquilles validées par leur auteur, messagerie, délai de 3 jours

Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ;
proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24
(« faire une objection », le panier par son adresse), C28–C33 et les deux passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 05:57:21 +01:00
co-authored by Claude Opus 5.5
55 changed files with 5435 additions and 456 deletions
+32 -21
View File
@@ -186,20 +186,22 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
#[tokio::test]
async fn editing_is_not_voting() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
// An administrator of trustinWallet answers for the starting texts (no author).
a.login("Yvv", true).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e1 = unit(&doc, "E1")["inForce"].clone();
let pid = e1["id"].as_str().expect("id").to_owned();
// Anyone may give the text in force its typographic apostrophes, without a vote.
// Anyone may give the text in force its typographic apostrophes, without a vote:
// since 2026-10-31 it waits for validation (tests/typos.rs).
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
assert!(fixed.contains("m\u{2019}engage"));
let (s, v) =
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "typo");
assert_eq!(v["edit"], "typo_pending");
// A change of meaning in force is refused: it goes through a proposal.
let (s, v) = b
.call(
@@ -259,13 +261,14 @@ async fn editing_is_not_voting() {
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
// Typo fixes are measured against the voted text: slips never add up.
// Typo fixes are measured against the voted text: slips never add up (applied at
// once here: the administrator answers for a starting text).
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
let (_, v) = a
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
.await;
assert_eq!(v["edit"], "typo", "one slip is a typo");
let (s, v) = b
let (s, v) = a
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
.await;
assert_eq!(
@@ -273,7 +276,7 @@ async fn editing_is_not_voting() {
(StatusCode::CONFLICT, Some("substantive_in_force")),
"a second slip on top is not"
);
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
let (_, v) = a.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
assert_eq!(v["edit"], "typo", "back to the voted text");
// An address is compared exactly.
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
@@ -335,8 +338,11 @@ async fn editing_is_not_voting() {
assert_eq!(s, StatusCode::CONFLICT);
}
/// Replaces « a contradiction signal blocks entering into force » (2026-10-21):
/// since 2026-10-31 an objection makes the latest arrival non-votable (ADR 0010;
/// the full cycle: tests/objections.rs).
#[tokio::test]
async fn a_contradiction_signal_blocks_entering_into_force() {
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
@@ -345,22 +351,25 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
let (s, v) = b
.call(
"POST",
&format!("/api/proposals/{k1}/signals"),
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
&format!("/api/proposals/{k1}/objections"),
Some(json!({ "otherId": e2, "kind": "contredit", "reason": "Ces règles contredisent la réciprocité en vigueur." })),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let sid = v["id"].as_str().expect("sid").to_owned();
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
// Only its author lifts it; then the pass lets the text in.
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
assert_eq!(v["heldId"], k1.as_str());
let oid = v["id"].as_str().expect("id").to_owned();
let (s, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")), "not votable while it stands");
// Only its author lifts it (or an administrator); then the second voice lets the text in.
let (s, _) = a.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
let (_, v) = b.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two");
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(v["entered"][0]["code"], "K1");
}
@@ -1063,7 +1072,8 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
.expect("count");
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
// From now on a typo is measured against the new text (written by a member, it
// waits for an administrator), and the Préambule is a change of meaning.
let body = i1["body"].as_str().expect("body");
let (s, v) = c
.call(
@@ -1072,7 +1082,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
)
.await;
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo_pending")), "{v}");
let (s, v) = c
.call(
"PATCH",
@@ -1113,6 +1123,7 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
Touch::Vote => {
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
}
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
Touch::Typo => {
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
@@ -1133,8 +1144,8 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
c.call(
"POST",
&format!("/api/proposals/{k1}/signals"),
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
&format!("/api/proposals/{k1}/objections"),
Some(json!({ "otherId": pid, "kind": "contredit", "reason": "Ces règles contredisent le préambule." })),
)
.await
}
+26 -4
View File
@@ -711,17 +711,15 @@ async fn a_session_sees_its_own_ballots_only() {
}
#[tokio::test]
async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let e4 = version(&doc, "E4", "0");
let e5 = version(&doc, "E5", "0");
// A contradiction signalled on E5 against the text in force E1: E5 cannot enter.
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
let e1 = version(&doc, "E1", "inForce");
c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await;
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
@@ -732,6 +730,27 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
if n == 49 {
// The last ballot is signed; before the chain confirms it, an objection holds
// E5 back against the text in force E1 (ADR 0010): no new ballot may hold it.
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let id = doc["doc"]["id"].as_str().expect("id");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{id}/ballots"),
Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")));
}
last = b;
if n % 10 == 9 {
// A session holds at most 20 pending ballots: the background task confirms on the way.
@@ -750,8 +769,11 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
// A ballot sealed before the objection counts, as a vote already cast: kept, but
// the version held back cannot enter into force.
assert_eq!(tally(&d, &e5), (50, 0));
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
assert_eq!(find(&d, &e5)["votable"], false);
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
}
// ---------------------------------------------------------------------------
+662
View File
@@ -0,0 +1,662 @@
//! Objections « redit » / « contredit » and the messages of trustinWallet
//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The
//! three days of a deadline pass on the server's clock, never with a sleep.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, 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
}
/// The status and the error code of a call expected to fail.
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, body: Value) -> String {
let id = doc["doc"]["id"].as_str().expect("id");
self.ok("POST", &format!("/api/docs/{id}/proposals"), Some(body)).await["id"].as_str().expect("id").to_owned()
}
async fn object(&mut self, from: &str, other: &str, kind: &str, reason: &str) -> Value {
self.ok(
"POST",
&format!("/api/proposals/{from}/objections"),
Some(json!({ "otherId": other, "kind": kind, "reason": reason })),
)
.await
}
async fn messages(&mut self) -> Value {
self.ok("GET", "/api/messages", None).await
}
async fn detail(&mut self, id: &str) -> Value {
self.ok("GET", &format!("/api/proposals/{id}"), 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 find<'a>(doc: &'a Value, id: &str) -> &'a Value {
doc["units"]
.as_array()
.expect("units")
.iter()
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.find(|p| p["id"] == id)
.expect("version")
}
fn gestures(m: &Value) -> Vec<&str> {
m["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
}
const REPEATS: &str = "Elle répète l'unicité du compte, déjà en vigueur.";
const CONTRADICTS: &str = "Elle dit le contraire de la réciprocité en vigueur.";
/// C28, by the API: a « redit », the proposal no longer votable, its author changes
/// the article it repeats, the objector lifts.
#[tokio::test]
async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector_lifts() {
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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1");
let (e1_unit, e1_id) =
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
let b = chiara
.propose(
&doc,
json!({ "title": "Un seul compte", "body": "Je n'ai qu'un seul compte membre, et je m'y tiens.", "submit": true }),
)
.await;
let o = yvv.object(&b, &e1_id, "redit", REPEATS).await;
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, never the text in force");
let oid = o["id"].as_str().expect("id").to_owned();
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
assert_eq!(find(&d, &e1_id)["objections"], 1);
// Nobody votes on it any more: neither one vote nor a grouped one.
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
let many = yvv
.err(
"POST",
&format!("/api/docs/{doc_id}/votes"),
Some(json!({ "lines": [{ "proposalId": b, "choice": "for" }] })),
)
.await;
assert_eq!(many, (StatusCode::CONFLICT, "objection".into()));
// Its author is told, in her messages, what to do; the objector has nothing yet.
let m = chiara.messages().await;
assert_eq!(m["unread"], 1);
let raised = &m["messages"][0];
assert_eq!((raised["kind"].as_str(), raised["topic"].as_str()), (Some("objection_raised"), Some("objection")));
assert_eq!(gestures(raised), ["respond"]);
assert_eq!(raised["objection"]["kind"], "redit");
assert_eq!(raised["objection"]["heldVersion"]["id"], b.as_str());
assert_eq!(raised["objection"]["other"]["id"], e1_id.as_str());
assert_eq!(raised["objection"]["objector"], "Yvv");
assert_eq!(raised["doc"]["groupSlug"], "essai");
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
let det = chiara.detail(&b).await;
let ob = &det["objections"][0];
assert_eq!(
(ob["held"].as_bool(), ob["iAnswer"].as_bool(), ob["phase"].as_str()),
(Some(true), Some(true), Some("author"))
);
// Before an answer there is nothing to reiterate; only the objector (or an administrator) lifts.
let r = yvv.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
assert_eq!(r, (StatusCode::CONFLICT, "nothing_to_reiterate".into()));
assert_eq!(chiara.err("POST", &format!("/api/objections/{oid}/lift"), None).await.0, StatusCode::FORBIDDEN);
// A linked proposal must go on the article repeated (or her own): never elsewhere, never by someone else.
let e2_unit = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
let elsewhere =
json!({ "unitId": e2_unit, "objectionId": oid, "title": "Réciprocité", "body": "Autre.", "submit": true });
let (s, _) = chiara.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(elsewhere)).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let by_yvv = json!({ "unitId": e1_unit, "objectionId": oid, "title": "Unicité", "body": "Autre.", "submit": true });
assert_eq!(
yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(by_yvv)).await.0,
StatusCode::BAD_REQUEST
);
// Chiara changes the article it repeats: a proposal on E1, linked to the objection.
let linked = chiara
.propose(
&doc,
json!({
"unitId": e1_unit, "objectionId": oid, "title": "Unicité du compte", "submit": true,
"body": "Je m'engage sur l'honneur à n'avoir qu'un seul compte membre, et à m'y tenir.",
}),
)
.await;
let det = yvv.detail(&b).await;
let ob = &det["objections"][0];
assert_eq!((ob["phase"].as_str(), ob["answer"].as_str()), (Some("objector"), Some("proposal")));
assert_eq!(ob["answerId"], linked.as_str());
assert_eq!((ob["canLift"].as_bool(), ob["canReiterate"].as_bool()), (Some(true), Some(true)));
// The objector is asked: lift or reiterate, by three days; her message is dealt with.
let m = yvv.messages().await;
assert_eq!(m["unread"], 1);
let asked = &m["messages"][0];
assert_eq!(asked["kind"], "objection_answered");
assert_eq!(gestures(asked), ["lift", "reiterate"]);
assert!(asked["dueAt"].as_str().is_some_and(|d| d > asked["createdAt"].as_str().expect("at")), "{asked}");
let m = chiara.messages().await;
assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new());
assert_eq!(m["messages"][0]["outcome"], "answered");
assert_eq!(m["unread"], 0, "answering reads one's message");
// Yvv lifts from his message: the proposal is votable again, the message offers nothing more.
yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
let d = yvv.doc("essai").await;
assert_eq!(find(&d, &b)["votable"], true);
let m = yvv.messages().await;
assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it");
assert_eq!(m["messages"][0]["outcome"], "lifted");
yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
// Lifted is lifted.
let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(again, (StatusCode::CONFLICT, "lifted".into()));
}
/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time;
/// a reiteration tells the author again; without an answer, lifted at the deadline.
#[tokio::test]
async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() {
let (mut chiara, state) = 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 e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let title = "Certifier largement";
let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements.";
let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await;
let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
let phase =
|d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64());
// A typo fix answers nothing.
let typo = chiara
.ok(
"PATCH",
&format!("/api/proposals/{b}"),
Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })),
)
.await;
assert_eq!(typo["edit"], "typo");
assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1)));
// Rewording answers: the objector is asked.
let reworded =
"Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées.";
let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await;
assert_eq!(r["edit"], "substantive");
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message");
let det = yvv.detail(&b).await;
assert_eq!(phase(&det), (Some("objector".into()), Some(1)));
assert_eq!(det["objections"][0]["answer"], "revision");
let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned();
// One cycle at a time: another rewording changes neither the messages nor the deadline.
state.clock.advance(3600);
let again = format!("{reworded} Et à les revoir.");
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await;
assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str());
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
// Reiterated: the objection stands, the author is told again, a second cycle opens.
yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2)));
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false);
let m = chiara.messages().await;
let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect();
assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first");
assert_eq!(gestures(&m["messages"][0]), ["respond"]);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0));
// A new answer, then nobody: three days later it is lifted by itself.
let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.";
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await;
assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2)));
state.clock.advance(3 * 86_400 - 60);
deadlines::sweep(&state).await.expect("sweep");
assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet");
state.clock.advance(120);
let swept = deadlines::sweep(&state).await.expect("sweep");
assert_eq!(swept.lifted, 1);
let det = yvv.detail(&b).await;
assert_eq!(
(det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()),
(Some("lifted"), Some("deadline"))
);
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
}
#[tokio::test]
async fn a_rewording_of_the_other_side_asks_the_objector_too() {
let (mut luna, _) = Client::new().await;
luna.login("Luna", false).await;
let mut chiara = luna.other();
chiara.login("Chiara", false).await;
let mut yvv = luna.other();
yvv.login("Yvv", false).await;
let doc = luna.doc("essai").await;
let a = luna
.propose(
&doc,
json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }),
)
.await;
let b = chiara
.propose(
&doc,
json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }),
)
.await;
let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await;
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from");
// Luna rewords the earlier one: the pair changed, the objector judges again.
luna.ok(
"PATCH",
&format!("/api/proposals/{a}"),
Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })),
)
.await;
let det = yvv.detail(&b).await;
assert_eq!(
(det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()),
(Some("objector"), Some("other"))
);
assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered");
}
#[tokio::test]
async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut admin = chiara.other();
admin.login("Admin", true).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
chiara
.ok(
"PATCH",
&format!("/api/proposals/{b}"),
Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })),
)
.await;
// An administrator never speaks for the objector...
assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN);
// ...but may lift.
admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
let det = admin.detail(&b).await;
assert_eq!(det["objections"][0]["lift"], "admin");
assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin");
// Withdrawing either version ends a standing objection.
let c = chiara
.propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true }))
.await;
let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await;
let lifted: (String, Option<String>) = {
let det = yvv.detail(&c).await;
let ob = det["objections"].as_array().expect("o").iter().find(|o| o["id"] == oid2.as_str()).expect("o").clone();
(ob["phase"].as_str().expect("p").to_owned(), ob["lift"].as_str().map(str::to_owned))
};
assert_eq!(lifted, ("lifted".into(), Some("withdrawn".into())));
}
#[tokio::test]
async fn what_an_objection_may_be_about() {
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 e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let e3 = unit(&doc, "E3");
let (v1, v2) = (e3["proposals"][0]["id"].as_str().expect("v1"), e3["proposals"][1]["id"].as_str().expect("v2"));
let post = |from: &str, other: &str, kind: &str, reason: &str| {
(format!("/api/proposals/{from}/objections"), json!({ "otherId": other, "kind": kind, "reason": reason }))
};
let cases = [
(post(&e1, &e2, "contredit", CONTRADICTS), "both_in_force"),
(post(v1, v2, "redit", REPEATS), "same_unit"),
(post(&e1, &e1, "redit", REPEATS), "other"),
(post(v1, &e1, "signal", REPEATS), "kind"),
(post(v1, &e1, "redit", "court"), "reason"),
];
for ((uri, body), code) in cases {
let (s, e) = yvv.err("POST", &uri, Some(body)).await;
assert_eq!((s, e.as_str()), (StatusCode::BAD_REQUEST, code));
}
// A draft is nobody's business yet.
let draft = chiara
.propose(&doc, json!({ "title": "Brouillon", "body": "Pas encore soumis au vote.", "submit": false }))
.await;
let (s, _) = yvv
.call(
"POST",
&format!("/api/proposals/{v1}/objections"),
Some(json!({ "otherId": draft, "kind": "redit", "reason": REPEATS })),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// One standing objection per person and pair.
yvv.object(v1, &e1, "redit", REPEATS).await;
let twice = yvv
.err(
"POST",
&format!("/api/proposals/{e1}/objections"),
Some(json!({ "otherId": v1, "kind": "contredit", "reason": CONTRADICTS })),
)
.await;
assert_eq!(twice, (StatusCode::CONFLICT, "objection_exists".into()));
// Someone else may object to the same pair.
chiara.object(v1, &e1, "contredit", CONTRADICTS).await;
}
/// Votes cast before the objection stay counted; the version cannot enter into
/// force while it is held, and enters once the objection is lifted.
#[tokio::test]
async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
let (mut chiara, state) = 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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
// Every member of the group for it (written as they would have been before the objection).
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
.fetch_all(&state.db)
.await
.expect("members");
for m in &members {
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
.bind(&b)
.bind(m)
.execute(&state.db)
.await
.expect("vote");
}
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
let d = chiara.doc("essai").await;
assert_eq!(find(&d, &b)["tally"]["for"].as_u64(), Some(members.len() as u64), "the votes stay");
assert!(find(&d, &b)["evaluation"]["reached"].as_bool().expect("reached"));
let lifted = yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
}
#[tokio::test]
async fn a_message_is_read_by_its_recipient_only() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
yvv.object(&b, &e1, "redit", REPEATS).await;
let m = chiara.messages().await;
let mid = m["messages"][0]["id"].as_str().expect("id").to_owned();
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
// Not even an administrator reads or marks someone else's message.
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
assert_eq!(yvv.call("POST", &format!("/api/messages/{mid}/read"), None).await.0, StatusCode::NOT_FOUND);
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
let mut nobody = chiara.other();
assert_eq!(nobody.call("GET", "/api/messages", None).await.0, StatusCode::UNAUTHORIZED);
// Marked as read, it stays in the box.
assert_eq!(chiara.ok("POST", &format!("/api/messages/{mid}/read"), None).await["unread"], 0);
let m = chiara.messages().await;
assert_eq!((m["messages"][0]["read"].as_bool(), gestures(&m["messages"][0])), (Some(true), vec!["respond"]));
// All at once.
let c = chiara
.propose(&doc, json!({ "title": "Deux", "body": "Deux comptes membres par personne.", "submit": true }))
.await;
yvv.object(&c, &e1, "contredit", CONTRADICTS).await;
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
assert_eq!(chiara.ok("POST", "/api/messages/read", None).await["unread"], 0);
}
/// A deadline passed while the server was stopped is dealt with at the next start.
#[tokio::test]
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite://{}", path.display());
let (b, oid) = {
let db = open_db(&url).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut chiara = Client { router: app(state.clone()), cookie: None };
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(
&doc,
json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }),
)
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
let reworded = "Un seul compte membre par être humain pour la certifcation.";
chiara
.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": reworded })))
.await;
// And a typo fix of Yvv's on it, waiting for Chiara.
let fixed = reworded.replace("certifcation", "certification");
let fix =
yvv.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": fixed }))).await;
assert_eq!(fix["edit"], "typo_pending");
state.db.close().await;
(b, oid)
};
// Four days later, the server starts again.
let db = open_db(&url).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
state.clock.advance(4 * 86_400);
let swept = deadlines::sweep(&state).await.expect("start");
assert_eq!((swept.lifted, swept.applied), (1, 1));
let (phase, lift): (String, Option<String>) = sqlx::query_as("SELECT phase, lift FROM signals WHERE id = ?")
.bind(&oid)
.fetch_one(&state.db)
.await
.expect("o");
assert_eq!((phase.as_str(), lift.as_deref()), ("lifted", Some("deadline")));
let body: String =
sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
assert_eq!(body, "Un seul compte membre par être humain pour la certification.");
state.db.close().await;
let _ = std::fs::remove_file(&path);
}
/// The contradiction signals of before become objections « contredit », without
/// loss, on a base migrated up to the version before (0001 to 0007).
#[tokio::test]
async fn contradiction_signals_of_before_become_contredits() {
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
let mut before = sqlx::migrate!("./migrations");
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
before.run(&pool).await.expect("up to 0007");
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
// The documents as the image of before wrote them (its amendments read tables of now).
seed::documents(&state).await.expect("seed");
let id = |code: &'static str, which: &'static str| {
let pool = pool.clone();
async move {
let sql = if which == "in_force" {
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'in_force'"
} else {
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'open' ORDER BY p.rowid LIMIT 1"
};
sqlx::query_scalar::<_, String>(sql).bind(code).fetch_one(&pool).await.expect("id")
}
};
let (e2, k1, e4, e5) =
(id("E2", "in_force").await, id("K1", "open").await, id("E4", "open").await, id("E5", "open").await);
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-30T00:00:00Z')")
.execute(&pool)
.await
.expect("identity");
let signal = |sid: &'static str, from: String, to: String, lifted: Option<&'static str>| {
let pool = pool.clone();
async move {
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by)
VALUES (?, ?, ?, 'Ces deux textes se contredisent.', 'dev:chiara', '2026-10-30T00:00:00Z', ?, ?)",
)
.bind(sid)
.bind(from)
.bind(to)
.bind(lifted.map(|_| "2026-10-30T01:00:00Z"))
.bind(lifted)
.execute(&pool)
.await
.expect("signal");
}
};
signal("s1", k1.clone(), e2.clone(), None).await;
signal("s2", e4.clone(), e5.clone(), None).await;
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
// The image of now starts on it.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("0008");
type Row = (String, String, String, Option<String>, Option<String>);
let rows: Vec<Row> = sqlx::query_as("SELECT id, kind, phase, held_id, lift FROM signals ORDER BY id")
.fetch_all(&pool)
.await
.expect("rows");
assert_eq!(
rows,
[
("s1".into(), "contredit".into(), "author".into(), Some(k1.clone()), None),
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
],
"K1 against the text in force; E5 written after E4 in the same second"
);
// Nothing lost: reasons, authors and dates as they were.
let kept: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM signals WHERE reason = 'Ces deux textes se contredisent.' AND author_id = 'dev:chiara'
AND created_at = '2026-10-30T00:00:00Z'",
)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(kept, 4);
// And the server reads them as objections: K1 and E5 are no longer votable.
let st = ld_server::docs::load_state(
&state,
&sqlx::query_scalar::<_, String>("SELECT id FROM documents WHERE group_slug = 'essai'")
.fetch_one(&pool)
.await
.expect("doc"),
)
.await
.expect("state");
assert!(
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
);
}
+384
View File
@@ -0,0 +1,384 @@
//! 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");
}