v3 : modules d'ordre et de titre — moteur, données, serveur (L11)

L'ordre des articles de chaque section (cran 2) et le titre du document
(cran 4) deviennent des modules : des unités à part, dont les versions
sont des propositions votées comme les articles (dépliées, panier, chaîne).

- moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé,
  complété à la fin quand un article entre ou sort, places redistribuées,
  nouvel article en fin de section) ; pending : un ordre de moins de deux
  articles n'est jamais à voter ;
- données : migration 0007 (units.kind, module_amendments) ; le seed
  déclare ses modules ; module_amend.rs les ajoute une fois, gardé et
  journalisé, aux documents nés sans eux, sans toucher articles ni votes ;
- serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer,
  entrée en vigueur : les articles changent de place, le document change
  de titre, en et es passent « à revoir ») ; publication des modules ;
- reste mineur : la médiane compte les statuts open, in_force, applied,
  et eux seuls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 02:08:44 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 3831ddb1bf
21 changed files with 2249 additions and 115 deletions
+596
View File
@@ -0,0 +1,596 @@
//! 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 counts articles only.
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(3), Some(32)));
}
#[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;
// 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")));
// 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, and 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"], "typo");
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], 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
// ---------------------------------------------------------------------------
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
let today = Config::for_tests().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(Config::for_tests(), 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(Config::for_tests(), 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);
}