forked from yvv/decision
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
661 lines
32 KiB
Rust
661 lines
32 KiB
Rust
//! 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.
|
||
/// The quorum of a module or an article set to 2, as its author — an administrator, for a
|
||
/// text of the seed — sets it before the first vote (ADR 0013). In the test group, where
|
||
/// the quorum of every level is 1 and one voice passes, one voice of two then no longer does.
|
||
async fn two_voices(state: &AppState, code: &str) {
|
||
let n = sqlx::query(
|
||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
|
||
)
|
||
.bind(code)
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("quorum")
|
||
.rows_affected();
|
||
assert_eq!(n, 1, "{code}");
|
||
}
|
||
|
||
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, state) = two().await;
|
||
two_voices(&state, "ordre.conclusion").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, state) = two().await;
|
||
// The order stays under vote with one voice.
|
||
two_voices(&state, "ordre.inertie").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;
|
||
two_voices(&state, "titre").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);
|
||
}
|