v3 : serveur des objections, des coquilles validées et de la messagerie (lot F)

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>
This commit is contained in:
Yvv
2026-10-31 02:45:48 +01:00
co-authored by Claude Opus 5.5
parent 70dbd712c8
commit 3f1f968f16
17 changed files with 2754 additions and 238 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
}