forked from yvv/decision
- Le revirement de l'auteur sur une coquille (reprendre une correction appliquée, valider une correction refusée) ne s'écrit que sur une version encore ouverte : Settle porte while_open ; et la garde de statut de write_text marche enfin (?9 restait NULL, le statut est le 8e paramètre). - 0008 lève les signalements debout dont un côté est déjà retiré. - Une seule objection debout par personne et par paire (index unique partiel) ; envoyée plusieurs fois à la fois, les autres reçoivent objection_exists. - Un retrait relance l'adoption : la version que l'objection retenait entre aussitôt. Tests vus rouges d'abord. Boucle : 230 tests Rust, svelte-check 0, 55 vitest, e2e 35 / 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
773 lines
36 KiB
Rust
773 lines
36 KiB
Rust
//! 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");
|
|
}
|
|
|
|
/// Withdrawing the other side frees the held version at once: with its votes, it
|
|
/// enters then, not at the next pass of the sweeper (relecture of cd7e683).
|
|
#[tokio::test]
|
|
async fn withdrawing_the_other_side_lets_the_held_version_enter_at_once() {
|
|
let (mut chiara, state) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let mut zoe = chiara.other();
|
|
zoe.login("Zoé", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let a = yvv
|
|
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
|
.await;
|
|
let b = chiara
|
|
.propose(
|
|
&doc,
|
|
json!({ "title": "Un seul compte", "body": "Une personne, un seul compte membre.", "submit": true }),
|
|
)
|
|
.await;
|
|
let held = zoe.object(&b, &a, "redit", "Elle répète la proposition d'Yvv.").await;
|
|
assert_eq!(held["heldId"], b.as_str(), "the latest arrival");
|
|
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");
|
|
}
|
|
yvv.ok("POST", &format!("/api/proposals/{a}/withdraw"), None).await;
|
|
let status: String =
|
|
sqlx::query_scalar("SELECT status FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
|
|
assert_eq!(status, "in_force");
|
|
}
|
|
|
|
#[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.
|
|
/// One person, one objection on a pair: sent several times at once, one stands
|
|
/// (relecture of cd7e683: four simultaneous requests made four).
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn the_same_objection_sent_at_once_stands_once() {
|
|
let path = std::env::temp_dir().join(format!("ld-lot-f-twice-{}.db", std::process::id()));
|
|
let _ = std::fs::remove_file(&path);
|
|
let db = open_db(&format!("sqlite://{}", path.display())).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 sends = (0..8).map(|_| {
|
|
let mut c = Client { router: yvv.router.clone(), cookie: yvv.cookie.clone() };
|
|
let (b, e1) = (b.clone(), e1.clone());
|
|
tokio::spawn(async move {
|
|
c.call(
|
|
"POST",
|
|
&format!("/api/proposals/{b}/objections"),
|
|
Some(json!({ "otherId": e1, "kind": "redit", "reason": REPEATS })),
|
|
)
|
|
.await
|
|
})
|
|
});
|
|
let mut answers = Vec::new();
|
|
for s in sends.collect::<Vec<_>>() {
|
|
let (status, v) = s.await.expect("join");
|
|
answers.push((status, v["error"].as_str().unwrap_or_default().to_owned()));
|
|
}
|
|
assert_eq!(answers.iter().filter(|(s, _)| *s == StatusCode::OK).count(), 1, "{answers:?}");
|
|
assert!(
|
|
answers.iter().all(|(s, e)| *s == StatusCode::OK || (*s == StatusCode::CONFLICT && e == "objection_exists")),
|
|
"{answers:?}"
|
|
);
|
|
let standing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE lifted_at IS NULL")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("count");
|
|
assert_eq!(standing, 1);
|
|
state.db.close().await;
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[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;
|
|
// Standing against a version withdrawn since: the rule of before lifted nothing
|
|
// on a withdrawal; now a withdrawal ends the objection (relecture of cd7e683).
|
|
let spare: Vec<String> = sqlx::query_scalar(
|
|
"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 p.status = 'open' AND p.id NOT IN (?, ?, ?) ORDER BY p.rowid LIMIT 2",
|
|
)
|
|
.bind(&k1)
|
|
.bind(&e4)
|
|
.bind(&e5)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("two more");
|
|
let (live, gone) = (spare[0].clone(), spare[1].clone());
|
|
sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?")
|
|
.bind(&gone)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("withdraw");
|
|
signal("s5", live.clone(), gone.clone(), None).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())),
|
|
("s5".into(), "contredit".into(), "lifted".into(), None, Some("withdrawn".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, 5);
|
|
// 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)
|
|
);
|
|
assert!(!st.blocked.contains(&live), "nothing holds a version back against one withdrawn");
|
|
}
|