Files
decision/crates/ld-server/tests/modules.rs
T
YvvandClaude Opus 5.5 785580c78f v3 : modules d'ordre et de titre — l'interface, restes mineurs (L11, C25–C27)
Chaque section du lecteur finit par l'ordre de ses articles (inertie 2) : ses
articles numérotés, son vote déplié, « Proposer un autre ordre » — une liste où
l'on clique un article puis sa place, ses flèches, ou le clavier. Le titre du
document (inertie 4) se vote et se propose sous le titre, « traduction à
revoir » quand il a changé. Les deux sont dans la liste des propositions, le
panier et la chaîne Ğ1 ; un nouvel article se propose dans une section, à sa fin.
La liste du groupe compte les propositions ouvertes des modules.

Restes mineurs : la participation ramenée se dit en deux temps (la mesure, puis
ce que le groupe demande), les lignes « habituelle » des réglages resserrées, le
sélecteur d'inertie se parcourt aux flèches (le cran actuel atteignable, jamais
choisi), la marque « seed a minima » porte le vrai nombre de votes.

Contrôles : C25 (ordre, dont la chaîne et le téléphone), C26 (titre), C27
(participation ramenée) ; C3, C17 (41 modules), C20 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:35:02 +01:00

608 lines
29 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; 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::<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")));
// 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);
}