forked from yvv/decision
Objections « redit » / « contredit » à la place des signalements : la dernière arrivée des deux versions n'est plus votable (repli, panier, chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article visé (proposition liée) ou en reformulant ; la personne qui objecte lève ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection est levée. Un administrateur peut lever. Coquilles : la sienne passe tout de suite ; celle d'un autre attend l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur garde la main tant que le texte n'est pas en vigueur. En vigueur, seule une coquille validée corrige (auteur de la version, administrateur pour un texte semé). Deux coquilles en attente : la seconde se mesure au texte voté ; à l'application, celle qui ne colle plus est périmée. Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque personne ne lit que les siens. Échéances par le balayage existant, avec rattrapage au démarrage et horloge injectable (route de dev). Migration 0008 additive : les signalements deviennent des « contredit », sans perte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
661 lines
31 KiB
Rust
661 lines
31 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");
|
|
|
|
// 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)
|
|
);
|
|
}
|