//! 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, } 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) -> (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 { 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 { 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 { 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; 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(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; // The list of the group counts it among the proposals in progress, never among the articles. 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(3), Some(before.sum::() + 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| 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, Option, 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, Option)> = 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); }