Files
decision/crates/ld-server/tests/seed_proposals.rs
T

775 lines
35 KiB
Rust

//! Propositions seed (Yvv, 2026-11-02, ADR 0011): the three first modules of the
//! certification act — I1 « Raison d'être », E1 Unicité du compte, E2 Réciprocité —
//! keep a status of their own to start the vote, but they are not in force. A new
//! base is born with them open; an existing base gets them through a guarded
//! amendment, only where nobody has touched the text. Monnaie libre publishes no
//! document without any article in force: the queue waits for the first text.
//!
//! Lot T4 (2026-11-03): a fourth, E12 « Responsabilité civique », and Yvv's texts for the three
//! (`tests/amorce.rs`). A new base is born with the four; the bases made as production are
//! started here with the seed of M8, the last that gave the three their texts of before.
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;
const CODES: [&str; 3] = ["I1", "E1", "E2"];
/// The level seed proposals start at (Yvv, 2026-11-02: « une inertie basse »).
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
const GROUPS: [&str; 2] = ["essai", "monnaie-libre"];
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
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 get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str, admin: bool) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
async fn doc(&mut self, group: &str) -> Value {
let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned();
self.get(&format!("/api/docs/{id}")).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")
}
/// Every version shown of the document: in force, open, of articles and modules.
fn versions(doc: &Value) -> Vec<&Value> {
let m = &doc["modules"];
doc["units"]
.as_array()
.expect("units")
.iter()
.chain(std::iter::once(&m["title"]))
.chain(m["orders"].as_array().expect("orders").iter())
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.filter(|v| !v.is_null())
.collect()
}
async fn fresh() -> AppState {
fresh_with(Config::for_tests()).await
}
async fn fresh_with(cfg: Config) -> AppState {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
#[tokio::test]
async fn a_new_base_is_born_with_four_seed_proposals_and_no_article_in_force() {
let state = fresh().await;
let mut c = Client::of(&state);
c.login("Chiara", false).await;
for group in GROUPS {
let doc = c.doc(group).await;
let in_force: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| !u["inForce"].is_null())
.filter_map(|u| u["code"].as_str())
.collect();
assert!(in_force.is_empty(), "{group}: no article in force, {in_force:?}");
for code in CODES.iter().chain(&["E12"]) {
let code = *code;
let u = unit(&doc, code);
let p = &u["proposals"][0];
assert_eq!(
(p["status"].as_str(), p["origin"].as_str(), p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()),
(Some("open"), Some("seed"), Some(true), Some(false)),
"{group} {code}"
);
assert_eq!((p["revision"].as_i64(), p["tally"]["for"].as_u64()), (Some(1), Some(0)), "{group} {code}");
assert_eq!(p["votable"], true, "{group} {code}");
assert_eq!(u["inertia"], LOW, "{group} {code}: a low level to start");
assert!(p["translations"]["en"]["title"].is_string(), "{group} {code}: translated");
}
assert_eq!(unit(&doc, "I1")["proposals"][0]["title"], "Raison d'être");
// Only those four; the title and the orders stay seeds a minima, in force.
let marked: Vec<&Value> = versions(&doc).into_iter().filter(|v| v["seedProposal"] == true).collect();
assert_eq!(marked.len(), 4, "{group}");
let title = &doc["modules"]["title"]["inForce"];
assert_eq!((title["seedMinimal"].as_bool(), title["seedProposal"].as_bool()), (Some(true), Some(false)));
assert!(
doc["modules"]["orders"].as_array().expect("orders").iter().all(|o| o["inForce"]["seedMinimal"] == true)
);
// The counts: no article in force, 29 open versions (27 articles, E3 and E7 two each);
// 33 modules to vote (27 articles, the title, the order of 5 sections; lot T4).
let g = c.get(&format!("/api/groups/{group}")).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29)), "{group}");
let d = c.get(&format!("/api/groups/{group}/decisions")).await;
assert_eq!(d["docs"][0]["toVote"], 33, "{group}");
// The preview of the text in force is empty; « if everything passes » shows them.
let id = doc["doc"]["id"].as_str().expect("id");
let p = c.get(&format!("/api/docs/{id}/preview")).await;
assert_eq!(p["units"], json!([]), "{group}");
let all = c.get(&format!("/api/docs/{id}/preview?mode=all")).await;
assert!(all["units"].as_array().expect("units").iter().any(|u| u["title"] == "Raison d'être"));
}
// Born with them: the seed proposal amendments, superseded since lot T4 (the seed puts
// forward Yvv's texts, not theirs), decide nothing; the texts of lot T4 are said present.
let decided: Vec<(String, String, String)> = sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome FROM seed_proposal_amendments s
JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code",
)
.fetch_all(&state.db)
.await
.expect("decisions");
assert!(decided.is_empty(), "{decided:?}");
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("changes");
assert_eq!(changes, 0, "born at the low level: nothing changed");
// The Raison d'être of a new base is the text its own amendment of lot T4 names.
let i1: Vec<String> = sqlx::query_scalar(
"SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-11-03-i1-amorce' ORDER BY doc_id",
)
.fetch_all(&state.db)
.await
.expect("i1");
assert_eq!(i1, ["present", "present"]);
}
// ---------------------------------------------------------------------------
// A base made like production (02/11): I1 at revision 2 (the Préambule, then the
// « Raison d'être » amendment of 30/10), E1 and E2 at revision 1, all three in
// force as seeds a minima, in both groups.
// ---------------------------------------------------------------------------
const PREAMBULE: [(&str, &str, &str); 3] = [
(
"fr",
"Préambule",
"Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
),
(
"en",
"Preamble",
"This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
),
(
"es",
"Preámbulo",
"La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.",
),
];
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a
/// minima, I1 and E1 at level 3 and 4.
fn production_seed() -> Value {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json");
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed of production, with I1 the Préambule as before 2026-10-30.
fn seed_dir(tag: &str, preambule: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ld-seed-proposals-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let mut seed = production_seed();
if preambule {
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
for (lang, title, body) in PREAMBULE {
i1["versions"][0]["title"][lang] = json!(title);
i1["versions"][0]["body"][lang] = json!(body);
}
}
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// Production as of 2026-11-02: born with the Préambule, then started with the seed it
/// runs (its amendments: I1 becomes the Raison d'être).
async fn production(tag: &str, cfg: Config) -> AppState {
let mut first = cfg.clone();
first.seed_dir = seed_dir(&format!("{tag}-first"), true);
let db = open_db("sqlite::memory:").await.expect("db");
let born = AppState::new(first, db).await.expect("state");
seed::documents(&born).await.expect("first seed");
let mut then = cfg;
then.seed_dir = seed_dir(&format!("{tag}-then"), false);
let state = AppState::new(then, born.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed of production");
state
}
/// The seed of M6 for these three texts, unchanged until M8 (de1b7eb): since lot T4 (03/11) the
/// seed of today gives them Yvv's texts, and their seed proposal amendments are superseded
/// (`tests/amorce.rs`).
fn m8() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8");
cfg
}
/// The same base, started again with a later seed (and its amendments).
async fn restart(before: &AppState, cfg: Config) -> AppState {
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
#[derive(Debug, PartialEq, sqlx::FromRow)]
struct Version {
id: String,
status: String,
revision: i64,
title: String,
body: String,
in_force: bool,
}
async fn version(state: &AppState, group: &str, code: &str) -> Version {
sqlx::query_as(
"SELECT p.id, p.status, p.revision, p.title, p.body, IFNULL(u.in_force_id = p.id, 0) AS in_force
FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND u.code = ? AND p.origin = 'seed' AND p.kind = 'text'",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("version")
}
async fn kinds(state: &AppState, id: &str) -> Vec<String> {
sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
.bind(id)
.fetch_all(&state.db)
.await
.expect("revisions")
}
/// Each decision on the seed proposals: (group, unit, outcome, reason, level outcome,
/// level reason, at).
type Decision = (String, String, String, Option<String>, String, Option<String>, String);
async fn decisions(state: &AppState) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason, s.level_outcome, s.level_reason, s.at
FROM seed_proposal_amendments s JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code",
)
.fetch_all(&state.db)
.await
.expect("decisions")
}
type Outcome<'a> = (&'a str, &'a str, &'a str, Option<&'a str>, &'a str, Option<&'a str>);
fn outcomes(rows: &[Decision]) -> Vec<Outcome<'_>> {
rows.iter()
.map(|(g, u, o, r, lo, lr, _)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref(), lo.as_str(), lr.as_deref()))
.collect()
}
async fn unit_id(state: &AppState, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("unit")
}
async fn level_of(state: &AppState, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.inertia FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("level")
}
/// Production on 2026-10-30 (orch): open changes of inertia at zero votes, I1 → 5 and
/// E1 → 5. Written here in Monnaie libre only, so both answers of the rule are seen.
async fn open_inertia_proposals(state: &AppState) {
let mut c = Client::of(state);
c.login("Oscar", false).await;
for code in ["I1", "E1"] {
let u = unit_id(state, "monnaie-libre", code).await;
let (s, v) = c.call("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
}
#[tokio::test]
async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once() {
let before = production("applied", Config::for_tests()).await;
open_inertia_proposals(&before).await;
// As production holds them.
let mut kept = Vec::new();
for group in GROUPS {
for code in CODES {
let v = version(&before, group, code).await;
assert_eq!((v.status.as_str(), v.in_force), ("in_force", true), "{group} {code}");
let expected: &[&str] = if code == "I1" { &["initial", "amendment"] } else { &["initial"] };
assert_eq!(kinds(&before, &v.id).await, expected, "{group} {code}");
assert_eq!(v.revision, if code == "I1" { 2 } else { 1 }, "{group} {code}");
assert_eq!(level_of(&before, group, code).await, if code == "I1" { "3" } else { "4" });
kept.push(v);
}
}
// The articles of the quorum (F1.1, F1.2, N1.1) are amended at the same start, by the
// seed amendments of lot T (`tests/seed_quorum_texts.rs`): left out here.
let translations = |state: AppState| async move {
sqlx::query_as::<_, (String, String, String, String, String)>(
"SELECT t.proposal_id, t.lang, t.title, t.body, t.source_hash FROM translations t
JOIN proposals p ON p.id = t.proposal_id JOIN units u ON u.id = p.unit_id
WHERE u.code NOT IN ('F1.1', 'F1.2', 'N1.1') ORDER BY t.proposal_id, t.lang",
)
.fetch_all(&state.db)
.await
.expect("translations")
};
let translated = translations(before.clone()).await;
let state = restart(&before, m8()).await;
let first = decisions(&state).await;
// Every text becomes a seed proposal; the level goes low where nobody touched it:
// an open change of inertia by a member keeps it (I1 and E1 in Monnaie libre).
assert_eq!(
outcomes(&first),
[
("essai", "E1", "applied", None, "applied", None),
("essai", "E2", "applied", None, "applied", None),
("essai", "I1", "applied", None, "applied", None),
("monnaie-libre", "E1", "applied", None, "refused", Some("inertia_proposal")),
("monnaie-libre", "E2", "applied", None, "applied", None),
("monnaie-libre", "I1", "applied", None, "refused", Some("inertia_proposal")),
]
);
for (i, group) in GROUPS.iter().enumerate() {
for (j, code) in CODES.iter().enumerate() {
let was = &kept[i * CODES.len() + j];
let v = version(&state, group, code).await;
// The same version, its text and revision kept: open now, and not in force.
assert_eq!(v.id, was.id, "{group} {code}");
assert_eq!((v.status.as_str(), v.in_force), ("open", false), "{group} {code}");
assert_eq!((&v.title, &v.body, v.revision), (&was.title, &was.body, was.revision), "{group} {code}");
let featured: (i64, Option<String>, Option<String>) =
sqlx::query_as("SELECT seed_featured, entered_at, confirmed_at FROM proposals WHERE id = ?")
.bind(&v.id)
.fetch_one(&state.db)
.await
.expect("mark");
assert_eq!(featured, (1, None, None), "{group} {code}");
// Its history says the change of status, after what it had.
let mut expected: Vec<&str> = if *code == "I1" { vec!["initial", "amendment"] } else { vec!["initial"] };
expected.push("seed_proposal");
assert_eq!(kinds(&state, &v.id).await, expected, "{group} {code}");
let in_force: Option<String> = sqlx::query_scalar("SELECT in_force_id FROM units WHERE id = ?")
.bind(unit_id(&state, group, code).await)
.fetch_one(&state.db)
.await
.expect("unit");
assert_eq!(in_force, None, "{group} {code}");
let held = *group == "monnaie-libre" && *code != "E2";
let level = match (held, *code) {
(true, "I1") => "3".to_owned(),
(true, _) => "4".to_owned(),
_ => LOW.to_string(),
};
assert_eq!(level_of(&state, group, code).await, level, "{group} {code}");
}
}
// Translations untouched.
assert_eq!(translations(state.clone()).await, translated);
// The article says its new level, as a starting setting of the text.
let changes: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT d.group_slug, u.code, c.from_level, c.to_level, c.amendment_id FROM inertia_changes c
JOIN units u ON u.id = c.unit_id JOIN documents d ON d.id = c.doc_id
WHERE c.cause = 'amendment' ORDER BY d.group_slug, u.code",
)
.fetch_all(&state.db)
.await
.expect("changes");
let line = |g: &str, c: &str, from: &str| {
let id = format!("2026-11-02-{}-proposition-seed", c.to_lowercase());
(g.to_owned(), c.to_owned(), from.to_owned(), LOW.to_string(), Some(id))
};
assert_eq!(
changes,
[
line("essai", "E1", "4"),
line("essai", "E2", "4"),
line("essai", "I1", "3"),
line("monnaie-libre", "E2", "4")
]
);
let mut c = Client::of(&state);
c.login("Chiara", false).await;
for group in GROUPS {
let doc = c.doc(group).await;
for code in CODES {
let p = &unit(&doc, code)["proposals"][0];
assert_eq!(
(p["seedProposal"].as_bool(), p["status"].as_str()),
(Some(true), Some("open")),
"{group} {code}"
);
assert!(unit(&doc, code)["inForce"].is_null());
let (_, d) = c.call("GET", &format!("/api/proposals/{}", p["id"].as_str().expect("id")), None).await;
let last = d["history"].as_array().expect("history").last().cloned().expect("a line");
assert_eq!((last["kind"].as_str(), last["author"].as_str()), (Some("seed_proposal"), None), "{d}");
assert!(last["diff"].is_null(), "a change of status changes no text");
}
let e2 = unit(&doc, "E2")["inertiaChanges"].as_array().expect("changes").clone();
assert_eq!(
(e2[0]["from"].as_u64(), e2[0]["to"].as_u64(), e2[0]["cause"].as_str()),
(Some(4), Some(u64::from(LOW)), Some("amendment"))
);
}
// Monnaie libre republishes, once per article; the test group never.
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 'Proposition seed%' ORDER BY o.id",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(
queued,
CODES
.iter()
.map(|c| ("monnaie-libre".to_owned(), format!("Proposition seed : {c} sort de la vigueur")))
.collect::<Vec<_>>()
);
// Decided once: a second start changes nothing.
let again = restart(&state, m8()).await;
assert_eq!(decisions(&again).await, first);
for group in GROUPS {
for code in CODES {
let v = version(&again, group, code).await;
assert_eq!(kinds(&again, &v.id).await.iter().filter(|k| *k == "seed_proposal").count(), 1);
}
}
let n: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM outbox WHERE reason LIKE 'Proposition seed%'),
(SELECT COUNT(*) FROM inertia_changes WHERE cause = 'amendment')",
)
.fetch_one(&again.db)
.await
.expect("counts");
assert_eq!(n, (3, 4));
}
#[tokio::test]
async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() {
let before = production("refused", Config::for_tests()).await;
// In the test group someone voted against E1: there it stays in force, at its level.
let mut c = Client::of(&before);
c.login("Vera", false).await;
let e1 = version(&before, "essai", "E1").await;
let v = c.vote(&e1.id, "against").await;
assert!(v["entered"].as_array().expect("entered").is_empty());
let state = restart(&before, m8()).await;
let first = decisions(&state).await;
assert_eq!(
outcomes(&first),
[
("essai", "E1", "refused", Some("votes"), "refused", Some("not_a_seed_proposal")),
("essai", "E2", "applied", None, "applied", None),
("essai", "I1", "applied", None, "applied", None),
("monnaie-libre", "E1", "applied", None, "applied", None),
("monnaie-libre", "E2", "applied", None, "applied", None),
("monnaie-libre", "I1", "applied", None, "applied", None),
]
);
let kept = version(&state, "essai", "E1").await;
assert_eq!((kept.status.as_str(), kept.in_force, kept.revision), ("in_force", true, 1));
assert_eq!(kinds(&state, &kept.id).await, ["initial"], "no line: nothing changed");
assert_eq!(level_of(&state, "essai", "E1").await, "4");
let mut c = Client::of(&state);
c.login("Vera", false).await;
let doc = c.doc("essai").await;
let e1 = &unit(&doc, "E1")["inForce"];
assert_eq!((e1["seedMinimal"].as_bool(), e1["seedProposal"].as_bool()), (Some(true), Some(false)));
// Decided once: the vote withdrawn, a later start does not look again.
let (s, v) = c.call("POST", &format!("/api/proposals/{}/vote", kept.id), Some(json!({ "choice": null }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let again = restart(&state, m8()).await;
assert_eq!(decisions(&again).await, first);
assert!(version(&again, "essai", "E1").await.in_force);
}
// ---------------------------------------------------------------------------
// Publication: no document without any article in force (default kept while Yvv decides)
// ---------------------------------------------------------------------------
struct Remote {
dir: std::path::PathBuf,
bare: std::path::PathBuf,
}
impl Remote {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("ld-seed-proposals-pub-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = dir.join("textes.git");
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
assert!(ok.success());
Self { dir, bare }
}
fn config(&self, without_articles: bool) -> Config {
let mut cfg = Config::for_tests();
cfg.data_dir = self.dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: self.bare.display().to_string(),
branch: "main".into(),
key: self.dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles,
simulated: false,
});
cfg
}
fn show(&self, file: &str) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&self.bare)
.args(["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")])
.output()
.expect("git");
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
}
}
impl Drop for Remote {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
async fn outbox(state: &AppState) -> Vec<(String, i64, Option<String>, Option<String>)> {
sqlx::query_as("SELECT reason, attempts, last_error, done_at FROM outbox ORDER BY id")
.fetch_all(&state.db)
.await
.expect("outbox")
}
#[tokio::test]
async fn a_document_without_any_article_in_force_waits_and_goes_out_with_the_first_text() {
let remote = Remote::new("suspended");
let state = fresh_with(remote.config(false)).await;
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(remote.show("fr.md"), None, "nothing went out");
// The queue stays, not as a failure: no attempt counted, no error.
assert_eq!(outbox(&state).await, [("publication initiale".to_owned(), 0, None, None)]);
let mut c = Client::of(&state);
c.login("Chiara", false).await;
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["doc"]["publication"]["state"], "suspended");
// A restart queues nothing new and sends nothing.
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(remote.show("fr.md"), None);
assert_eq!(outbox(&state).await.len(), 1);
// The first text enters (fifty voices of the Ğ1 web of trust, written here as they
// land after the chain): the document goes out, with it alone.
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
for i in 0..60 {
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES (?, 'g1', ?, 1, '2026-11-02T00:00:00Z')")
.bind(format!("idty:{}", 9000 + i))
.bind(format!("Membre {i}"))
.execute(&state.db)
.await
.expect("identity");
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, ?, 'for', 1, '2026-11-02T00:00:00Z', 'chain')")
.bind(&e1)
.bind(format!("idty:{}", 9000 + i))
.execute(&state.db)
.await
.expect("vote");
}
let entered = ld_server::adoption::run(&state, &doc_id).await.expect("adoption");
assert_eq!(entered.iter().map(|e| e.code.as_str()).collect::<Vec<_>>(), ["E1"]);
ld_server::publish::pass(&state).await.expect("pass");
let fr = remote.show("fr.md").expect("published");
assert!(fr.contains("## Unicité du compte"), "{fr}");
assert!(!fr.contains("Raison d'être") && !fr.contains("## Réciprocité"), "only the text in force: {fr}");
let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 1);
assert_eq!(json["units"][0]["status"], "adopted");
assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some()), "the whole queue went out");
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["doc"]["publication"]["state"], "done");
}
#[tokio::test]
async fn publishing_a_document_without_any_article_in_force_is_one_switch_away() {
let remote = Remote::new("switch");
let state = fresh_with(remote.config(true)).await;
ld_server::publish::pass(&state).await.expect("pass");
let fr = remote.show("fr.md").expect("published anyway");
assert!(fr.contains("# Acte d'engagement Certification") && !fr.contains("## "), "{fr}");
let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json");
assert_eq!(json["units"], json!([]));
assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some()));
}
// ---------------------------------------------------------------------------
// A seed proposal enters like any proposal
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() {
let state = fresh().await;
let mut a = Client::of(&state);
a.login("Yvv", false).await;
let mut b = a.other();
b.login("Chiara", false).await;
// Two members: W = 2, the quorum 1 at every level of the test group (ADR 0013) — one
// voice passes; one against holds a voice for (the share M of two voters).
let doc = a.doc("essai").await;
let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned();
let v = b.vote(&i1, "against").await;
assert!(v["entered"].as_array().expect("entered").is_empty());
let v = a.vote(&i1, "for").await;
assert!(v["entered"].as_array().expect("entered").is_empty(), "one for, one against: not enough");
let v = b.vote(&i1, "for").await;
assert_eq!(v["entered"][0]["code"], "I1");
let doc = a.doc("essai").await;
let p = &unit(&doc, "I1")["inForce"];
assert_eq!(p["id"], i1.as_str());
assert!(p["enteredAt"].is_string());
assert_eq!((p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()), (Some(false), Some(false)));
let g = a.get("/api/groups/essai").await;
assert_eq!(g["docs"][0]["inForce"], 1);
let adopted: (String, i64, i64) =
sqlx::query_as("SELECT kind, votes_for, electorate FROM adoptions WHERE proposal_id = ?")
.bind(&i1)
.fetch_one(&state.db)
.await
.expect("adoption");
assert_eq!(adopted, ("text".to_owned(), 2, 2));
// Whatever the number of members, one voice against none passes in the test group: at
// 16, as at 2 (the quorum 1; before 2026-11-03, C = 1 + B^W held it under 16).
for i in 0..14 {
let mut m = a.other();
m.login(&format!("Membre {i}"), false).await;
}
let doc = a.doc("essai").await;
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
let v = a.vote(&e1, "for").await;
assert_eq!(v["entered"][0]["code"], "E1", "16 members: one voice");
let electorate: i64 = sqlx::query_scalar("SELECT electorate FROM adoptions WHERE proposal_id = ?")
.bind(&e1)
.fetch_one(&state.db)
.await
.expect("adoption");
assert_eq!(electorate, 16);
}
/// Lot U3 (Yvv, 2026-11-03: « un petit chapitre qui explicite les réglages initiaux de l'auteur,
/// notamment Quorum et Inertie »): the chapter « Info ouverture doc de réf. » says at which level
/// the Propositions d'amorce opened, read in the data of the document. Once one has entered into
/// force it is no longer a seed proposal (`seedProposal`), but the view still says the seed put
/// it forward (`seedFeatured`): its level at the opening stays readable, and it is never counted
/// among the other articles.
#[tokio::test]
async fn the_view_keeps_which_versions_the_seed_put_forward_once_they_enter_into_force() {
let state = fresh().await;
let mut a = Client::of(&state);
a.login("Yvv", false).await;
for group in GROUPS {
let doc = a.doc(group).await;
let featured: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| {
std::iter::once(&u["inForce"])
.chain(u["proposals"].as_array().expect("p").iter())
.any(|v| v["seedFeatured"].as_bool() == Some(true))
})
.filter_map(|u| u["code"].as_str())
.collect();
assert_eq!(featured, ["I1", "E1", "E12", "E2"], "{group}");
// Every version says it, true or false; the modules were never put forward.
for v in versions(&doc) {
let seeded = v["seedFeatured"].as_bool().unwrap_or_else(|| panic!("{group}: {v}"));
assert_eq!(seeded, v["seedProposal"].as_bool() == Some(true), "{group}: {}", v["title"]);
}
}
// Into force in the test group, by one voice (quorum 1): no longer a seed proposal, still
// put forward by the seed, at the level it opened at.
let doc = a.doc("essai").await;
let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned();
assert_eq!(a.vote(&i1, "for").await["entered"][0]["code"], "I1");
let doc = a.doc("essai").await;
let p = &unit(&doc, "I1")["inForce"];
assert_eq!(p["id"], i1.as_str());
assert_eq!((p["seedProposal"].as_bool(), p["seedFeatured"].as_bool()), (Some(false), Some(true)));
assert_eq!(unit(&doc, "I1")["inertia"], LOW);
assert!(unit(&doc, "I1")["inertiaChanges"].as_array().expect("changes").is_empty());
}