Files
decision/crates/ld-server/tests/modules.rs
T
YvvandClaude Opus 5.5 aaa653e1a7 Lot S : tests serveur repris sans Propositions seed en vigueur (modules, overview), rustfmt
modules.rs : 0 article en vigueur et 35 propositions en cours dans une base
neuve (I1, E1 et E2 sont des Propositions seed) ; l'ajout des modules à une
base existante se rejoue sur le seed de production du 02/11 (textes en
vigueur), comme inertia.rs. overview.rs : le vote porte sur la Proposition
seed I1 ; 41 modules à voter, inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:12:20 +01:00

642 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Modules (ADR 0009): the title of the document (level 4) and the order of the
//! articles of each section (level 2), voted like articles; the guarded
//! addition that gives them to a document born without them.
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, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
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::of(&state), 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 login(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).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 vote(&mut self, proposal: &str, choice: &str) -> Value {
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).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 id_of(doc: &Value, code: &str) -> String {
unit(doc, code)["id"].as_str().expect("id").to_owned()
}
fn order_of<'a>(doc: &'a Value, section: &str) -> &'a Value {
doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section).expect("order")
}
/// The codes of the articles of a section one can see (a text in force or an open
/// version), in the order the document reads them.
fn read_order(doc: &Value, section: &str) -> Vec<String> {
let seen = |u: &Value| {
!u["inForce"].is_null()
|| u["proposals"]
.as_array()
.is_some_and(|p| p.iter().any(|p| p["status"] == "open" && p["kind"] != "inertia"))
};
let mut units: Vec<&Value> =
doc["units"].as_array().expect("units").iter().filter(|u| u["section"] == section && seen(u)).collect();
units.sort_by(|a, b| a["position"].as_f64().unwrap_or(0.0).total_cmp(&b["position"].as_f64().unwrap_or(0.0)));
units.iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect()
}
fn codes(doc: &Value, ids: &Value) -> Vec<String> {
ids.as_array()
.expect("ids")
.iter()
.map(|id| {
doc["units"]
.as_array()
.expect("units")
.iter()
.find(|u| u["id"] == *id)
.map(|u| u["code"].as_str().expect("code").to_owned())
.unwrap_or_else(|| format!("?{id}"))
})
.collect()
}
/// Two people of the test group (W = 2, C = 1): two « for » adopt.
async fn two() -> (Client, Client, AppState) {
let (mut a, state) = Client::new().await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
(a, b, state)
}
#[tokio::test]
async fn a_new_base_has_its_title_and_the_order_of_each_section() {
let (mut c, state) = Client::new().await;
c.login("Chiara").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
// The articles stay the articles: 33 of them, none of the modules.
assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}");
let title = &doc["modules"]["title"];
assert_eq!(
(title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()),
(Some("title"), Some("titre"), Some(4))
);
let v = &title["inForce"];
assert_eq!(
(v["title"].as_str(), v["seedMinimal"].as_bool()),
(Some("Acte d'engagement Certification"), Some(true))
);
assert_eq!(v["translations"]["en"]["title"], "Certification Commitment Act");
assert_eq!(v["translations"]["es"]["stale"], false);
// One order per section, in the order of the document, each at level 2.
let orders = doc["modules"]["orders"].as_array().expect("orders");
let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect();
assert_eq!(
sections,
[
"introduction",
"fondamental",
"technique",
"conclusion",
"annexe",
"formule",
"inertie",
"ordonnancement"
]
);
assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true));
// Its version in force is the order the section reads in now.
let fondamental = order_of(&doc, "fondamental");
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]);
assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]);
assert_eq!(fondamental["moot"], false);
// « Ordonnancement » holds one article: nothing to decide.
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true);
}
// Born with them: the addition says so, once per document.
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, m.outcome FROM module_amendments m JOIN documents d ON d.id = m.doc_id ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions");
assert_eq!(rows, [("essai".into(), "present".into()), ("monnaie-libre".into(), "present".into())]);
// The previews show articles only.
let doc = c.doc("monnaie-libre").await;
let id = doc["doc"]["id"].as_str().expect("id");
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(all["units"].as_array().expect("units").len(), 33);
// The list of the group: the articles in force — none, I1, E1 and E2 are seed proposals
// (ADR 0011), three more open versions —; no module has an open proposal yet.
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)));
}
#[tokio::test]
async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() {
let (mut yvv, mut chiara, _) = two().await;
let doc = yvv.doc("essai").await;
let conclusion = order_of(&doc, "conclusion").clone();
let module = conclusion["id"].as_str().expect("id").to_owned();
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
let uri = format!("/api/units/{module}/order");
// Every article of the section, each once, in another order.
for (wrong, code) in [
(json!([k1]), "order"),
(json!([k2, k1, k1]), "order"),
(json!([k2, id_of(&doc, "E1")]), "order"),
(json!([k1, k2]), "same_order"),
] {
let (s, v) = yvv.call("POST", &uri, Some(json!({ "order": wrong }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong}");
}
let first = yvv.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
assert_eq!(first["existing"], false);
// The same order again: the open one is given back.
let again = chiara.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
assert_eq!((again["id"].as_str(), again["existing"].as_bool()), (first["id"].as_str(), Some(true)));
let p = first["id"].as_str().expect("id").to_owned();
let doc = yvv.doc("essai").await;
let proposal = &order_of(&doc, "conclusion")["proposals"][0];
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("order"), Some(2)));
assert_eq!(codes(&doc, &proposal["order"]["ids"]), ["K2", "K1"]);
assert_eq!(proposal["order"]["same"], false);
// Voted like an article: one voice of two is not enough, the second brings it into force.
assert!(yvv.vote(&p, "for").await["entered"].as_array().expect("e").is_empty());
let v = chiara.vote(&p, "for").await;
assert_eq!(
(v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()),
(Some("ordre.conclusion"), Some("order"))
);
let doc = yvv.doc("essai").await;
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
let now = order_of(&doc, "conclusion");
assert_eq!(now["inForce"]["id"], p.as_str());
assert_eq!(codes(&doc, &now["inForce"]["order"]["ids"]), ["K2", "K1"]);
// The order of before goes back among the proposals, with its votes, and could return.
let back = &now["proposals"][0];
assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed")));
assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]);
// Nothing else moved: the other sections read as before, the texts are the same.
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]);
assert_eq!(read_order(&doc, "technique")[0], "T2");
}
#[tokio::test]
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
let (mut yvv, mut chiara, _) = two().await;
let doc = yvv.doc("essai").await;
let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned();
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1"));
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"]
.as_str()
.expect("id")
.to_owned();
yvv.vote(&p, "for").await;
// The list of the group counts it among the proposals in progress, never among the articles
// (none in force: I1, E1 and E2 are seed proposals, ADR 0011).
let g = yvv.ok("GET", "/api/groups/essai", None).await;
let before = doc["units"]
.as_array()
.expect("u")
.iter()
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|x| x["status"] == "open").count() as u64);
assert_eq!(
(g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()),
(Some(0), Some(before.sum::<u64>() + 1))
);
// A new article enters the section: at its end, and at the end of the order under vote.
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })),
)
.await;
let doc = yvv.doc("essai").await;
let fresh = unit(&doc, "N1")["code"]
.as_str()
.map(|_| ())
.and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned());
let fresh = fresh.expect("the new article");
let new_code = fresh["code"].as_str().expect("code").to_owned();
assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end");
let module_now = order_of(&doc, "inertie");
let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]);
assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]);
// Its vote stays: completing an order never asks the voters again.
assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for")));
// The new article leaves again (withdrawn by its author): dropped, as if never there.
let added = created["id"].as_str().expect("id");
chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await;
let doc = yvv.doc("essai").await;
let open = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == p.as_str())
.cloned()
.expect("p");
assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]);
assert!(open["order"]["added"].as_array().expect("a").is_empty());
// An order proposed with three articles, one of which then leaves: dropped from it.
let created = chiara
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
)
.await;
let other = created["unitId"].as_str().expect("unit").to_owned();
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await
["id"]
.as_str()
.expect("id")
.to_owned();
chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await;
let doc = yvv.doc("essai").await;
let o = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == three.as_str())
.cloned()
.expect("o");
assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]);
assert_eq!(o["order"]["dropped"], json!([other]));
// Completed, it reads like the first one: the second voice brings that order in.
let v = chiara.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "order");
let doc = yvv.doc("essai").await;
assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]);
// Now the order in force: the other one changes nothing more and cannot enter.
let o = order_of(&doc, "inertie")["proposals"]
.as_array()
.expect("p")
.iter()
.find(|x| x["id"] == three.as_str())
.cloned()
.expect("o");
assert_eq!(o["order"]["same"], true);
yvv.vote(&three, "for").await;
assert!(chiara.vote(&three, "for").await["entered"].as_array().expect("e").is_empty());
}
#[tokio::test]
async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() {
let (mut yvv, _, _) = two().await;
let doc = yvv.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let conclusion = order_of(&doc, "conclusion");
let module = conclusion["id"].as_str().expect("id");
let seed_order = conclusion["inForce"]["id"].as_str().expect("id");
let (s, v) = yvv
.call(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "unitId": module, "title": "Texte", "body": "Un texte sur un module.", "submit": true })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("module")));
let (s, v) = yvv
.call(
"PATCH",
&format!("/api/proposals/{seed_order}"),
Some(json!({ "title": "ordre.conclusion", "body": "x\ny" })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
let (s, v) = yvv
.call(
"PUT",
&format!("/api/proposals/{seed_order}/translations/en"),
Some(json!({ "title": "order", "body": "" })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
// A title and an order each have their own door, and only that one.
let title = doc["modules"]["title"]["id"].as_str().expect("title");
let (s, v) = yvv.call("POST", &format!("/api/units/{title}/order"), Some(json!({ "order": [] }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
// An objection (« redit » / « contredit », ADR 0010) is between two articles: never a module.
let i2 = unit(&doc, "I2")["proposals"][0]["id"].as_str().expect("i2").to_owned();
let title_v = doc["modules"]["title"]["inForce"]["id"].as_str().expect("title").to_owned();
for (from, other) in [(seed_order, i2.as_str()), (i2.as_str(), seed_order), (i2.as_str(), title_v.as_str())] {
let (s, v) = yvv
.call(
"POST",
&format!("/api/proposals/{from}/objections"),
Some(json!({ "otherId": other, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("other")), "{from} → {other}");
}
// A module's inertia is voted like an article's.
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
assert_eq!(i["existing"], false);
}
#[tokio::test]
async fn another_title_enters_into_force_and_its_translations_are_to_review() {
let (mut yvv, mut chiara, state) = two().await;
let doc = yvv.doc("essai").await;
let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned();
let uri = format!("/api/units/{module}/title");
for (wrong, code) in [
(" ", "title"),
("Deux\nlignes", "title"),
("Acte d'engagement Certification", "same_title"),
("Acte\u{200b}d'engagement", "hidden_characters"),
] {
let (s, v) = yvv.call("POST", &uri, Some(json!({ "title": wrong }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong:?}");
}
let new_title = "Acte d'engagement de la certification";
let p = yvv.ok("POST", &uri, Some(json!({ "title": new_title }))).await["id"].as_str().expect("id").to_owned();
let doc = yvv.doc("essai").await;
let proposal = &doc["modules"]["title"]["proposals"][0];
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("title"), Some(4)));
yvv.vote(&p, "for").await;
let v = chiara.vote(&p, "for").await;
assert_eq!((v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), (Some("titre"), Some("title")));
// The document changes title in French; English and Spanish stay, « à revoir ».
let doc = yvv.doc("essai").await;
assert_eq!(doc["doc"]["title"]["fr"], new_title);
assert_eq!(doc["doc"]["title"]["en"], "Certification Commitment Act");
let tr = &doc["modules"]["title"]["inForce"]["translations"];
assert_eq!((tr["en"]["stale"].as_bool(), tr["es"]["stale"].as_bool()), (Some(true), Some(true)));
let g = yvv.ok("GET", "/api/groups/essai", None).await;
assert_eq!(g["docs"][0]["title"]["fr"], new_title);
let d = yvv.ok("GET", "/api/groups/essai/decisions", None).await;
assert_eq!(d["docs"][0]["title"]["fr"], new_title);
// A member translates it again: the document's English title follows, up to date.
yvv.ok(
"PUT",
&format!("/api/proposals/{p}/translations/en"),
Some(json!({ "title": "Commitment Act of Certification", "body": "" })),
)
.await;
let doc = yvv.doc("essai").await;
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
// A typo of the title in force is corrected without a vote, by the path of any typo
// (ADR 0010): someone else's waits for the title's author, then the document follows.
let fixed = "Acte d’engagement de la certification";
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
assert_eq!((r["edit"].as_str(), r["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], new_title, "nothing written yet");
let tid = r["typo"]["id"].as_str().expect("typo").to_owned();
assert_eq!(yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
// An order is never corrected: neither by its author nor by anyone else.
let doc = yvv.doc("essai").await;
let order = order_of(&doc, "conclusion")["inForce"]["id"].as_str().expect("order").to_owned();
let (s, v) =
chiara.call("PATCH", &format!("/api/proposals/{order}"), Some(json!({ "title": "", "body": "x" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
// The test group never publishes; the title of the publication is covered by `render`.
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
)
.fetch_one(&state.db)
.await
.expect("outbox");
assert_eq!(n, 0);
}
#[tokio::test]
async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
let (mut yvv, _, _) = two().await;
let doc = yvv.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let new = |section: Option<&str>, after: Option<String>| json!({ "section": section, "afterUnitId": after, "title": "Nouveau", "body": "Un nouvel article.", "submit": true });
let a = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("fondamental"), None))).await;
// An older client names an article: the end of its section, not right after it.
let b = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(None, Some(id_of(&doc, "I1"))))).await;
let (s, v) = yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("nulle-part"), None))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("section")));
let doc = yvv.doc("essai").await;
let code = |created: &Value| {
doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).expect("unit")["code"]
.as_str()
.expect("code")
.to_owned()
};
let (ca, cb) = (code(&a), code(&b));
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]);
assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]);
// It joins the order of its section, at its end.
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]);
}
// ---------------------------------------------------------------------------
// A base born without modules, as production holds it, then the new version
// ---------------------------------------------------------------------------
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a
/// minima. Since that day the seed puts them forward as seed proposals (ADR 0011,
/// tests/seed_proposals.rs); the addition of the modules is replayed on the seed of then.
fn production_config() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
cfg
}
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
let today = production_config().seed_dir.join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// What must not move: every unit of the base (place, text in force, section,
/// inertia), every version (status, text, revision) and every vote.
async fn snapshot(
state: &AppState,
) -> (Vec<(String, f64, Option<String>, Option<String>, String)>, Vec<(String, String, String, i64)>, i64) {
let units = sqlx::query_as(
"SELECT id, position, in_force_id, section, inertia FROM units WHERE kind = 'article' ORDER BY id",
)
.fetch_all(&state.db)
.await
.expect("units");
let proposals = sqlx::query_as(
"SELECT p.id, p.status, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.kind = 'article' ORDER BY p.id",
)
.fetch_all(&state.db)
.await
.expect("proposals");
let votes = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
(units, proposals, votes)
}
#[tokio::test]
async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
// Production before this version: the documents, no module.
let mut cfg = Config::for_tests();
cfg.seed_dir = seed_without_modules("applied");
let db = open_db("sqlite::memory:").await.expect("db");
let before = AppState::new(cfg, db).await.expect("state");
seed::documents(&before).await.expect("documents");
let mut c = Client::of(&before);
c.login("Yvv").await;
let mut chiara = c.other();
chiara.login("Chiara").await;
let doc = c.doc("essai").await;
assert!(doc["modules"]["title"].is_null());
// Life goes on there: votes, an article adopted, another one proposed in « conclusion ».
let e3 = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("e3").to_owned();
c.vote(&e3, "for").await;
chiara.vote(&e3, "for").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
c.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel final, proposé avant les modules.", "submit": true })),
)
.await;
let kept = snapshot(&before).await;
let reads = read_order(&c.doc("essai").await, "conclusion");
// The new version starts: each document receives its modules, from what it holds.
let state = AppState::new(production_config(), before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
let rows: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id
ORDER BY d.group_slug",
)
.fetch_all(&state.db)
.await
.expect("decisions");
let created = r#"["titre","ordre.introduction","ordre.fondamental","ordre.technique","ordre.conclusion","ordre.annexe","ordre.formule","ordre.inertie","ordre.ordonnancement"]"#;
assert_eq!(
rows,
[
("essai".into(), "applied".into(), None, Some(created.into())),
("monnaie-libre".into(), "applied".into(), None, Some(created.into())),
]
);
// Nothing else moved: articles, versions, votes.
assert_eq!(snapshot(&state).await, kept);
let mut c = Client::of(&state);
c.login("Yvv").await;
let doc = c.doc("essai").await;
assert_eq!(doc["modules"]["title"]["inForce"]["title"], "Acte d'engagement Certification");
assert_eq!(
doc["modules"]["title"]["inForce"]["translations"]["es"]["title"],
"Acta de compromiso de Certificación"
);
// The order of each section in force is the one it read in, the new article included.
let conclusion = order_of(&doc, "conclusion");
assert_eq!(codes(&doc, &conclusion["inForce"]["order"]["ids"]), reads);
assert_eq!(reads.len(), 3);
assert_eq!(conclusion["inForce"]["seedMinimal"], true);
// « Décisions » counts them: 41 + the new article in the test group, E3 adopted there.
let d = c.ok("GET", "/api/groups/essai/decisions", None).await;
assert_eq!(d["docs"][0]["toVote"], 41);
// Republished where the document publishes, never from the test group.
let queued: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'modules%'",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(
queued,
[(
"monnaie-libre".to_owned(),
"modules ajoutés : le titre du document, l'ordre des articles de chaque section".to_owned()
)]
);
// What is published: the same text, and the modules in document.json.
let ml = c.doc("monnaie-libre").await;
let st = ld_server::docs::load_state(&state, ml["doc"]["id"].as_str().expect("id")).await.expect("state");
let files = ld_server::publish::render(&state, &st).await.expect("render");
let file = |name: &str| files.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).expect("file");
assert!(!file("fr.md").contains("ordre.") && file("fr.md").contains("# Acte d'engagement Certification"));
let json: Value = serde_json::from_str(&file("document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3, "the articles in force, no module");
assert_eq!(
(json["modules"]["title"]["inertia"].as_u64(), json["modules"]["title"]["status"].as_str()),
(Some(4), Some("seed-minimal"))
);
assert_eq!(json["modules"]["orders"].as_array().expect("orders").len(), 8);
assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"]));
// Once only: the next start adds nothing.
let again = AppState::new(production_config(), state.db.clone()).await.expect("state");
seed::ensure(&again).await.expect("seed");
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n");
assert_eq!(n, 18, "9 modules in each of the two documents");
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments").fetch_one(&again.db).await.expect("n");
assert_eq!(decided, 2);
}