forked from yvv/decision
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -625,17 +625,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() {
|
||||
@@ -646,6 +644,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.
|
||||
@@ -664,8 +683,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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
//! 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");
|
||||
|
||||
// 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");
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user