forked from yvv/decision
Fusion de chantier/acte-certification (lots H et S) dans le lot V : le tampon et ses états, avec le quorum et les Propositions seed
- conflits résolus des deux côtés : kept_params garde le quorum (S) et la part simulée, la version déplacée, le cran d'avant (V) ; proposals.rs garde seedMinimal / seedProposal (S) et tallyPending / tallySimulated / myVoteState (V) ; - simulation::reexamine juge sur le quorum gardé par l'adoption (params.quorum, à défaut celui de l'article) ; - l'acte répété n'examine plus une adoption qui tient (params.reexamined) ; - tests V repris sans texte en vigueur au départ (I1, E1 : la Proposition seed) et sur un quorum 2 (small_web) ; le test overview d'une personne invitée dans Monnaie libre suit la simulation (elle vote, chaîne fermée). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,17 @@ use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// The seed production runs on 2026-11-02 (3f10a0e): texts in force, seeds a minima.
|
||||
fn production_seed_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
|
||||
}
|
||||
|
||||
fn production_config() -> Config {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = production_seed_dir();
|
||||
cfg
|
||||
}
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
@@ -16,8 +27,19 @@ struct Client {
|
||||
|
||||
impl Client {
|
||||
async fn new() -> (Self, AppState) {
|
||||
Self::seeded(Config::for_tests()).await
|
||||
}
|
||||
|
||||
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
|
||||
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
|
||||
/// For the scenarios that need texts in force; the seed of today has none.
|
||||
async fn production() -> (Self, AppState) {
|
||||
Self::seeded(production_config()).await
|
||||
}
|
||||
|
||||
async fn seeded(cfg: Config) -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
@@ -61,6 +83,22 @@ async fn doc_id(c: &mut Client, group: &str) -> String {
|
||||
v["docs"][0]["id"].as_str().expect("doc").to_owned()
|
||||
}
|
||||
|
||||
/// The quorum of 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, group: &str, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)",
|
||||
)
|
||||
.bind(code)
|
||||
.bind(group)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{group} {code}");
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
@@ -88,7 +126,7 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_seed_a_minima_and_the_open_proposals() {
|
||||
async fn the_seed_proposals_and_the_open_proposals() {
|
||||
let (mut c, _) = Client::new().await;
|
||||
c.login("Chiara", false).await;
|
||||
let (_, groups) = c.call("GET", "/api/groups", None).await;
|
||||
@@ -99,16 +137,10 @@ async fn the_seed_a_minima_and_the_open_proposals() {
|
||||
let id = doc_id(&mut c, "monnaie-libre").await;
|
||||
let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
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_eq!(in_force, ["I1", "E1", "E2"]);
|
||||
let e1 = &unit(&doc, "E1")["inForce"];
|
||||
assert_eq!(e1["seedMinimal"], true);
|
||||
// No article in force: I1, E1 and E2 are seed proposals (ADR 0011), open, put forward.
|
||||
assert!(doc["units"].as_array().expect("units").iter().all(|u| u["inForce"].is_null()));
|
||||
let e1 = &unit(&doc, "E1")["proposals"][0];
|
||||
assert_eq!((e1["seedProposal"].as_bool(), e1["status"].as_str()), (Some(true), Some("open")));
|
||||
assert_eq!(e1["tally"]["for"], 0);
|
||||
assert_eq!(doc["doc"]["voteFloor"], 50.0);
|
||||
assert_eq!(doc["doc"]["electorate"], 5745);
|
||||
@@ -119,8 +151,11 @@ async fn the_seed_a_minima_and_the_open_proposals() {
|
||||
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
|
||||
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
|
||||
assert_eq!(e3["inertia"], 3);
|
||||
// The two structural commitments start one level up (Yvv, 2026-10-30).
|
||||
assert_eq!((unit(&doc, "E1")["inertia"].as_u64(), unit(&doc, "E2")["inertia"].as_u64()), (Some(4), Some(4)));
|
||||
// The seed proposals start low (Yvv, 2026-11-02).
|
||||
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
|
||||
for code in ["I1", "E1", "E2"] {
|
||||
assert_eq!(unit(&doc, code)["inertia"].as_u64(), Some(low), "{code}");
|
||||
}
|
||||
// Translations seeded.
|
||||
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
|
||||
}
|
||||
@@ -131,15 +166,15 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published(
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
// Dev identities are members of every group: W(essai) = 2, C = 1.
|
||||
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
|
||||
// (ADR 0013): one voice passes in the test group.
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert_eq!(e3["proposals"][0]["evaluation"]["quorum"], 1);
|
||||
let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||
let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough");
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["code"], "E3");
|
||||
assert_eq!(v["entered"][0]["code"], "E3", "one voice in the test group");
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert_eq!(e3["inForce"]["id"], p.as_str());
|
||||
@@ -155,7 +190,7 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published(
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
let (mut a, _) = Client::production().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
@@ -172,9 +207,11 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let rival = v["id"].as_str().expect("id").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
// The quorum 1 of the test group (ADR 0013): its *for* against nothing replaces the seed.
|
||||
let (_, v) = a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["kind"], "text");
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "already in force");
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e1 = unit(&doc, "E1");
|
||||
assert_eq!(e1["inForce"]["id"], rival.as_str());
|
||||
@@ -185,7 +222,7 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editing_is_not_voting() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
let (mut a, state) = Client::production().await;
|
||||
// An administrator of trustinWallet answers for the starting texts (no author).
|
||||
a.login("Yvv", true).await;
|
||||
let mut b = a.with(None);
|
||||
@@ -296,6 +333,8 @@ async fn editing_is_not_voting() {
|
||||
let essai = doc_id(&mut a, "essai").await;
|
||||
let (_, essai_doc) = a.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||
let uid = unit(&essai_doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
// Votes stay on it while it is edited: one voice of two does not make it pass.
|
||||
two_voices(&state, "essai", "E2").await;
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
|
||||
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
|
||||
@@ -343,10 +382,11 @@ async fn editing_is_not_voting() {
|
||||
/// the full cycle: tests/objections.rs).
|
||||
#[tokio::test]
|
||||
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
let (mut a, state) = Client::production().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
two_voices(&state, "essai", "K1").await;
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
@@ -389,7 +429,11 @@ async fn inertia_change_is_voted_at_the_level_of_the_article() {
|
||||
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await;
|
||||
assert_eq!(again["id"], p.as_str());
|
||||
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(4)));
|
||||
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
|
||||
assert_eq!(
|
||||
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
|
||||
(Some(5), Some(low))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -400,7 +444,7 @@ async fn previews_and_assistant() {
|
||||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||||
assert_eq!(v["units"].as_array().expect("u").len(), 33);
|
||||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
|
||||
assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes");
|
||||
assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes");
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
|
||||
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
|
||||
@@ -745,7 +789,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let remote = dir.join("textes.git");
|
||||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||
let mut cfg = Config::for_tests();
|
||||
let mut cfg = production_config();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
@@ -753,6 +797,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
without_articles: false,
|
||||
});
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
@@ -818,7 +863,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
||||
path
|
||||
};
|
||||
let config = |remote: &std::path::Path| {
|
||||
let mut cfg = Config::for_tests();
|
||||
let mut cfg = production_config();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
@@ -826,6 +871,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
without_articles: false,
|
||||
});
|
||||
cfg
|
||||
};
|
||||
@@ -908,12 +954,18 @@ fn today_seed() -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||||
}
|
||||
|
||||
/// The seed production runs, I1 in force: the amendment of 30/10 is replayed on it.
|
||||
fn production_seed() -> Value {
|
||||
let path = production_seed_dir().join("acte-engagement-certification.json");
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||||
}
|
||||
|
||||
/// The seed as it was before 2026-10-30: the Préambule in force.
|
||||
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let mut seed = today_seed();
|
||||
let mut seed = production_seed();
|
||||
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);
|
||||
@@ -933,8 +985,10 @@ async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppStat
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// The same base, started again with today's seed (and its amendments).
|
||||
async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
|
||||
/// The same base, started again with the seed of production (and its amendments): the
|
||||
/// seed of today then puts I1 forward (tests/seed_proposals.rs).
|
||||
async fn restart(before: &AppState, mut cfg: Config) -> (Client, AppState) {
|
||||
cfg.seed_dir = production_seed_dir();
|
||||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
@@ -955,10 +1009,12 @@ fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &st
|
||||
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
|
||||
}
|
||||
|
||||
/// I1 as it reads: its text in force, else its seed proposal (ADR 0011).
|
||||
async fn i1_of(c: &mut Client, group: &str) -> Value {
|
||||
let id = doc_id(c, group).await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
unit(&doc, "I1")["inForce"].clone()
|
||||
let u = unit(&doc, "I1");
|
||||
if u["inForce"].is_null() { u["proposals"][0].clone() } else { u["inForce"].clone() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -969,13 +1025,13 @@ async fn a_new_base_starts_with_the_raison_d_etre() {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(1), Some(true)));
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedProposal"].as_bool()), (Some(1), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
|
||||
}
|
||||
}
|
||||
// Born with the text: nothing to replace, and it is said once.
|
||||
// Born with the text, put forward: nothing to replace, and it is said once.
|
||||
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
|
||||
}
|
||||
|
||||
@@ -988,7 +1044,8 @@ async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
|
||||
let seed = today_seed();
|
||||
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
|
||||
let v = &i1["versions"][0];
|
||||
assert_eq!(v["status"], "in_force");
|
||||
// Today the starting text is a seed proposal (ADR 0011); the text is the same.
|
||||
assert_eq!((v["status"].as_str(), v["seedProposal"].as_bool()), (Some("open"), Some(true)));
|
||||
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
|
||||
}
|
||||
@@ -1001,7 +1058,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let remote = dir.join("textes.git");
|
||||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||
let mut cfg = Config::for_tests();
|
||||
let mut cfg = production_config();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
@@ -1009,6 +1066,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
without_articles: false,
|
||||
});
|
||||
let git = |args: &[&str]| {
|
||||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||||
@@ -1124,7 +1182,10 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = match touch {
|
||||
// A vote that does not confirm it (the quorum 2: one voice is not enough), so
|
||||
// the refusal says the vote itself, not a confirmation.
|
||||
Touch::Vote => {
|
||||
two_voices(&before, "essai", "I1").await;
|
||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||
}
|
||||
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
|
||||
|
||||
Reference in New Issue
Block a user