forked from yvv/decision
1129 lines
51 KiB
Rust
1129 lines
51 KiB
Rust
//! Lot T4 (Yvv, 2026-11-03, « Modif des textes Acte engagement »): the texts and the
|
|
//! structure of the certification act.
|
|
//!
|
|
//! - I1 « Raison d'être », E1 « Unicité du compte » and E2 « Réciprocité », the three seed
|
|
//! proposals, take Yvv's texts; a new seed proposal, E12 « Responsabilité civique », comes
|
|
//! between E1 and E2, at the level of seed proposals.
|
|
//! - The three sections at the bottom — `formule` (F1, F1.1, F1.2, F1.3), `inertie` (N1,
|
|
//! N1.1), `ordonnancement` (O1) — and the order of each leave the document: they are
|
|
//! information on how the vote works, not texts to vote (« Les votes se font à leur place,
|
|
//! et les infos doivent trouver leur place dans l'ux », ADR 0014). Nothing is erased: an
|
|
//! existing base marks them retired (`units.retired_at`), only where no person left a trace
|
|
//! on them; an image that ignores the column shows them again as they were.
|
|
//!
|
|
//! Each change reaches production by a guarded amendment, decided once per document, played
|
|
//! here on a base made as production is after M6c and after M8.
|
|
|
|
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 std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::{Path, PathBuf};
|
|
use tower::ServiceExt;
|
|
|
|
const GROUPS: [&str; 2] = ["essai", "monnaie-libre"];
|
|
/// The three seed proposals whose texts change.
|
|
const AMORCE: [&str; 3] = ["I1", "E1", "E2"];
|
|
/// The new article, its amendment, and the retirement.
|
|
const NEW: &str = "E12";
|
|
const ADD: &str = "2026-11-03-e12-responsabilite-civique";
|
|
const RETIRE: &str = "2026-11-03-fonctionnement-hors-vote";
|
|
/// What leaves the document: seven articles, the order of their three sections.
|
|
const OUT: [&str; 7] = ["F1", "F1.1", "F1.2", "F1.3", "N1", "N1.1", "O1"];
|
|
const OUT_ORDERS: [&str; 3] = ["ordre.formule", "ordre.inertie", "ordre.ordonnancement"];
|
|
const OUT_SECTIONS: [&str; 3] = ["formule", "inertie", "ordonnancement"];
|
|
/// What stays.
|
|
const SECTIONS: [&str; 5] = ["introduction", "fondamental", "technique", "conclusion", "annexe"];
|
|
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
|
|
|
|
/// Yvv's words (03/11), the French that is voted — « il y a une et une seule clé » where
|
|
/// he wrote « il n'y a une et une seule » (read as a slip, the parallel of the first half);
|
|
/// the quotation keeps the non-breaking spaces of the one it replaces.
|
|
const I1_FR: &str = "*«\u{a0}Derrière une clé il y a un et un seul être humain vivant\u{a0}; derrière un être humain, il y a une et une seule clé certifiante.\u{a0}»*\n\nC'est l'unique enjeu de la certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé par cette clé.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle, qui fonde l'usage de la première monnaie libre.";
|
|
const E1_FR: &str =
|
|
"Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant.";
|
|
const E12_TITLE: [(&str, &str); 3] =
|
|
[("fr", "Responsabilité civique"), ("en", "Civic responsibility"), ("es", "Responsabilidad cívica")];
|
|
const E12_FR: &str = "Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la responsabilité civique, pour certifier à mon tour.";
|
|
const E2_FR: &str = "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui s'engagent elles-mêmes, à scrupuleusement respecter ces trois principes fondamentaux.";
|
|
|
|
fn new_text(code: &str) -> &'static str {
|
|
match code {
|
|
"I1" => I1_FR,
|
|
"E1" => E1_FR,
|
|
"E2" => E2_FR,
|
|
_ => E12_FR,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct Client {
|
|
router: axum::Router,
|
|
cookie: Option<String>,
|
|
}
|
|
|
|
impl Client {
|
|
fn of(state: &AppState) -> Self {
|
|
Self { router: app(state.clone()), cookie: None }
|
|
}
|
|
|
|
async fn as_(state: &AppState, name: &str, admin: bool) -> Self {
|
|
let mut c = Self::of(state);
|
|
let (s, v) = c.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
c
|
|
}
|
|
|
|
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 get(&mut self, uri: &str) -> Value {
|
|
self.ok("GET", uri, None).await
|
|
}
|
|
|
|
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) -> Option<&'a Value> {
|
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code)
|
|
}
|
|
|
|
fn codes(doc: &Value) -> Vec<String> {
|
|
doc["units"].as_array().expect("units").iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect()
|
|
}
|
|
|
|
fn order_sections(doc: &Value) -> Vec<String> {
|
|
doc["modules"]["orders"]
|
|
.as_array()
|
|
.expect("orders")
|
|
.iter()
|
|
.map(|o| o["section"].as_str().expect("section").to_owned())
|
|
.collect()
|
|
}
|
|
|
|
/// The codes of a section in the order it reads in now.
|
|
fn read_order(doc: &Value, section: &str) -> Vec<String> {
|
|
let o = doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section);
|
|
let members = o.expect("order")["members"].as_array().expect("members").clone();
|
|
members
|
|
.iter()
|
|
.map(|id| {
|
|
doc["units"]
|
|
.as_array()
|
|
.expect("units")
|
|
.iter()
|
|
.find(|u| u["id"] == *id)
|
|
.and_then(|u| u["code"].as_str())
|
|
.expect("member")
|
|
.to_owned()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The seeds, and a base made as production is
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn read(path: PathBuf) -> Value {
|
|
serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json")
|
|
}
|
|
|
|
fn fixtures() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
|
}
|
|
|
|
/// The seed of today (lot T4).
|
|
fn today_seed() -> Value {
|
|
read(Config::for_tests().seed_dir.join("acte-engagement-certification.json"))
|
|
}
|
|
|
|
/// The seed production runs since M8 (de1b7eb), the last with the articles of operation.
|
|
fn m8_seed() -> Value {
|
|
read(fixtures().join("seed-m8/acte-engagement-certification.json"))
|
|
}
|
|
|
|
/// The seed production ran from M5 (3f10a0e, 02/11) to M6.
|
|
fn production_seed() -> Value {
|
|
read(fixtures().join("seed-production-2026-11-02/acte-engagement-certification.json"))
|
|
}
|
|
|
|
fn seed_unit<'a>(seed: &'a Value, code: &str) -> &'a Value {
|
|
seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
|
}
|
|
|
|
/// A seed with the first version of `codes` taken from `from` (a seed, or a map code → text).
|
|
fn with_texts(base: &Value, from: &Value, codes: &[&str]) -> Value {
|
|
let mut seed = base.clone();
|
|
for u in seed["units"].as_array_mut().expect("units") {
|
|
let code = u["code"].as_str().expect("code").to_owned();
|
|
if codes.contains(&code.as_str()) {
|
|
let v = from["units"].get(&code).unwrap_or_else(|| &seed_unit(from, &code)["versions"][0]);
|
|
u["versions"][0]["title"] = v["title"].clone();
|
|
u["versions"][0]["body"] = v["body"].clone();
|
|
}
|
|
}
|
|
seed
|
|
}
|
|
|
|
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.",
|
|
),
|
|
];
|
|
|
|
const RULES: [&str; 3] = ["F1.1", "F1.2", "N1.1"];
|
|
|
|
fn seed_dir(tag: &str, seed: &Value) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("ld-amorce-{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
|
|
}
|
|
|
|
fn config(dir: PathBuf) -> Config {
|
|
let mut cfg = Config::for_tests();
|
|
cfg.seed_dir = dir;
|
|
cfg
|
|
}
|
|
|
|
async fn start(db: &sqlx::SqlitePool, cfg: Config) -> AppState {
|
|
let state = AppState::new(cfg, db.clone()).await.expect("state");
|
|
seed::ensure(&state).await.expect("seed");
|
|
state
|
|
}
|
|
|
|
/// Production's history: born on 21/10 (the Préambule, the rule articles of before),
|
|
/// started by M5 (its seed: I1 becomes the Raison d'être, the rule articles rewritten), by
|
|
/// M6 (the seed proposals, the quorums by level), by M6b (lot T) and by M6c (lot T2).
|
|
async fn production_after_m6c(tag: &str) -> AppState {
|
|
let prod = production_seed();
|
|
let mut born = with_texts(&prod, &read(fixtures().join("seed-regles-avant.json")), &["F1.2", "N1", "N1.1", "O1"]);
|
|
let i1 = born["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);
|
|
}
|
|
let db = open_db("sqlite::memory:").await.expect("db");
|
|
let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state");
|
|
seed::documents(&first).await.expect("born");
|
|
start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await;
|
|
let m8 = m8_seed();
|
|
start(&db, config(seed_dir(&format!("{tag}-m6"), &with_texts(&m8, &prod, &RULES)))).await;
|
|
let m6b = read(fixtures().join("seed-quorum-m6b.json"));
|
|
start(&db, config(seed_dir(&format!("{tag}-m6b"), &with_texts(&m8, &m6b, &RULES)))).await;
|
|
let t2 = read(fixtures().join("seed-quorum-t2.json"));
|
|
start(&db, config(seed_dir(&format!("{tag}-m6c"), &with_texts(&m8, &t2, &RULES)))).await
|
|
}
|
|
|
|
/// … then by M8 (lots T3, T3b, T3c: F1.2 and N1.1 at their revision 5).
|
|
async fn production_after_m8(tag: &str) -> AppState {
|
|
let base = production_after_m6c(tag).await;
|
|
start(&base.db, config(seed_dir(&format!("{tag}-m8"), &m8_seed()))).await
|
|
}
|
|
|
|
/// The start of lot T4: the seed of today.
|
|
async fn t4(base: &AppState) -> AppState {
|
|
start(&base.db, Config::for_tests()).await
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// What the database holds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn count(state: &AppState, sql: &str) -> i64 {
|
|
sqlx::query_scalar(sql).fetch_one(&state.db).await.expect(sql)
|
|
}
|
|
|
|
async fn doc_id(state: &AppState, group: &str) -> String {
|
|
sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = ?")
|
|
.bind(group)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("doc")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
/// The first version of the seed of an article: (id, status, revision, title, body, seed_featured).
|
|
async fn seed_version(state: &AppState, group: &str, code: &str) -> (String, String, i64, String, String, bool) {
|
|
sqlx::query_as(
|
|
"SELECT p.id, p.status, p.revision, p.title, p.body, p.seed_featured 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' ORDER BY p.rowid LIMIT 1",
|
|
)
|
|
.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")
|
|
}
|
|
|
|
async fn translation(state: &AppState, id: &str, lang: &str) -> (String, String, String, String, Option<String>) {
|
|
sqlx::query_as(
|
|
"SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?",
|
|
)
|
|
.bind(id)
|
|
.bind(lang)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("translation")
|
|
}
|
|
|
|
/// Every table, and its number of rows.
|
|
async fn tables(state: &AppState) -> BTreeMap<String, i64> {
|
|
let names: Vec<String> = sqlx::query_scalar(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("tables");
|
|
let mut out = BTreeMap::new();
|
|
for t in names {
|
|
out.insert(t.clone(), count(state, &format!("SELECT COUNT(*) FROM \"{t}\"")).await);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every row of every table, by rowid: what a deletion would take away.
|
|
async fn rowids(state: &AppState) -> BTreeMap<String, BTreeSet<i64>> {
|
|
let mut out = BTreeMap::new();
|
|
for t in tables(state).await.into_keys() {
|
|
let ids: Vec<i64> =
|
|
sqlx::query_scalar(&format!("SELECT rowid FROM \"{t}\"")).fetch_all(&state.db).await.expect("rowids");
|
|
out.insert(t, ids.into_iter().collect());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every row of a table as text, all its columns but `skip`, through `filter` (a WHERE).
|
|
async fn rows(state: &AppState, table: &str, skip: &[&str], filter: &str) -> Vec<String> {
|
|
let columns: Vec<String> = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')"))
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("columns");
|
|
let cols: Vec<String> = columns.iter().filter(|c| !skip.contains(&c.as_str())).map(|c| format!("quote(\"{c}\")")).collect();
|
|
let sql = format!("SELECT {} FROM \"{table}\" WHERE {filter} ORDER BY rowid", cols.join(" || '|' || "));
|
|
sqlx::query_scalar(&sql).fetch_all(&state.db).await.expect("rows")
|
|
}
|
|
|
|
/// The units that leave, by section (their articles and their orders).
|
|
const OF_THE_OUT: &str = "section IN ('formule', 'inertie', 'ordonnancement')";
|
|
const OF_THEIR_VERSIONS: &str =
|
|
"proposal_id IN (SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.section IN ('formule', 'inertie', 'ordonnancement'))";
|
|
|
|
/// What the articles of operation hold: units (without the columns of the retirement),
|
|
/// their versions, revisions, translations.
|
|
async fn the_rules_of_operation(state: &AppState) -> [Vec<String>; 4] {
|
|
[
|
|
rows(state, "units", &["retired_at", "retired_reason"], OF_THE_OUT).await,
|
|
rows(state, "proposals", &[], "unit_id IN (SELECT id FROM units WHERE section IN ('formule', 'inertie', 'ordonnancement'))").await,
|
|
rows(state, "revisions", &[], OF_THEIR_VERSIONS).await,
|
|
rows(state, "translations", &[], OF_THEIR_VERSIONS).await,
|
|
]
|
|
}
|
|
|
|
/// Each decision of the retirement: (group, outcome, reason, unit).
|
|
async fn retirements(state: &AppState) -> Vec<(String, String, Option<String>, Option<String>)> {
|
|
sqlx::query_as(
|
|
"SELECT d.group_slug, r.outcome, r.reason, r.unit_code FROM retirement_amendments r
|
|
JOIN documents d ON d.id = r.doc_id WHERE r.amendment_id = ? ORDER BY d.group_slug",
|
|
)
|
|
.bind(RETIRE)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("retirement_amendments")
|
|
}
|
|
|
|
/// Each decision of the new article: (group, outcome, reason, placement, placement reason).
|
|
async fn additions(state: &AppState) -> Vec<(String, String, Option<String>, Option<String>, Option<String>)> {
|
|
sqlx::query_as(
|
|
"SELECT d.group_slug, a.outcome, a.reason, a.placement, a.placement_reason FROM article_amendments a
|
|
JOIN documents d ON d.id = a.doc_id WHERE a.amendment_id = ? ORDER BY d.group_slug",
|
|
)
|
|
.bind(ADD)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("article_amendments")
|
|
}
|
|
|
|
/// Each decision of the new texts: (group, unit, outcome, reason).
|
|
async fn rewrites(state: &AppState) -> Vec<(String, String, String, Option<String>)> {
|
|
sqlx::query_as(
|
|
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s
|
|
JOIN documents d ON d.id = s.doc_id WHERE s.amendment_id LIKE '2026-11-03-%-amorce'
|
|
ORDER BY d.group_slug, s.unit_code",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("seed_amendments")
|
|
}
|
|
|
|
async fn retired(state: &AppState, group: &str) -> Vec<String> {
|
|
sqlx::query_scalar(
|
|
"SELECT u.code FROM units u JOIN documents d ON d.id = u.doc_id
|
|
WHERE d.group_slug = ? AND u.retired_at IS NOT NULL ORDER BY u.code",
|
|
)
|
|
.bind(group)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("retired")
|
|
}
|
|
|
|
fn all_out() -> Vec<String> {
|
|
let mut v: Vec<String> = OUT.iter().chain(OUT_ORDERS.iter()).map(|c| (*c).to_owned()).collect();
|
|
v.sort();
|
|
v
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A new base
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_new_base_is_born_with_the_texts_of_t4_and_without_the_rules_of_operation() {
|
|
let db = open_db("sqlite::memory:").await.expect("db");
|
|
let state = start(&db, Config::for_tests()).await;
|
|
let today = today_seed();
|
|
let mut c = Client::as_(&state, "Chiara", false).await;
|
|
for group in GROUPS {
|
|
let doc = c.doc(group).await;
|
|
let codes = codes(&doc);
|
|
assert_eq!(codes.len(), 27, "{group}: {codes:?}");
|
|
for code in OUT {
|
|
assert!(!codes.contains(&code.to_owned()), "{group}: {code} is no article to vote");
|
|
}
|
|
assert_eq!(order_sections(&doc), SECTIONS, "{group}");
|
|
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", NEW, "E2"], "{group}");
|
|
for code in [AMORCE.as_slice(), &[NEW]].concat() {
|
|
let u = unit(&doc, code).expect(code);
|
|
let p = &u["proposals"][0];
|
|
assert_eq!(p["body"], new_text(code), "{group} {code}");
|
|
assert_eq!((p["status"].as_str(), p["seedProposal"].as_bool()), (Some("open"), Some(true)), "{group} {code}");
|
|
assert_eq!(p["revision"], 1, "{group} {code}");
|
|
assert_eq!(u["inertia"], LOW, "{group} {code}");
|
|
assert!(u["inForce"].is_null(), "{group} {code}");
|
|
let v = &seed_unit(&today, code)["versions"][0];
|
|
for lang in ["en", "es"] {
|
|
assert_eq!(p["translations"][lang]["title"], v["title"][lang], "{group} {code} {lang}");
|
|
assert_eq!(p["translations"][lang]["body"], v["body"][lang], "{group} {code} {lang}");
|
|
assert_eq!(p["translations"][lang]["stale"], false, "{group} {code} {lang}");
|
|
}
|
|
}
|
|
let e12 = &unit(&doc, NEW).expect("E12")["proposals"][0];
|
|
for (lang, title) in E12_TITLE {
|
|
let shown = if lang == "fr" { &e12["title"] } else { &e12["translations"][lang]["title"] };
|
|
assert_eq!(shown, title, "{group} {lang}");
|
|
}
|
|
// 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).
|
|
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}");
|
|
}
|
|
assert_eq!(count(&state, &format!("SELECT COUNT(*) FROM units WHERE {OF_THE_OUT}")).await, 0, "never born");
|
|
// Born with all of it: each amendment says so, once per document.
|
|
let present = |g: &str| (g.to_owned(), "present".to_owned(), None, None);
|
|
assert_eq!(retirements(&state).await, [present("essai"), present("monnaie-libre")]);
|
|
let added = additions(&state).await;
|
|
assert_eq!(added.iter().map(|a| (a.0.as_str(), a.1.as_str())).collect::<Vec<_>>(), [("essai", "present"), ("monnaie-libre", "present")]);
|
|
let rw = rewrites(&state).await;
|
|
assert_eq!(rw.len(), 6, "{rw:?}");
|
|
assert!(rw.iter().all(|(_, _, o, _)| o == "present"), "{rw:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Production, after M6c and after M8
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What lot T4 does to a base made as production is, and the proof that nothing is erased.
|
|
/// `rules_revision`: F1.2 and N1.1 at 4 (after M6c) or 5 (after M8).
|
|
async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) {
|
|
// The base as production holds it.
|
|
for group in GROUPS {
|
|
for code in AMORCE {
|
|
let (id, status, revision, _, _, featured) = seed_version(&base, group, code).await;
|
|
assert_eq!((status.as_str(), featured), ("open", true), "{group} {code}");
|
|
let expected: &[&str] = if code == "I1" {
|
|
&["initial", "amendment", "seed_proposal"]
|
|
} else {
|
|
&["initial", "seed_proposal"]
|
|
};
|
|
assert_eq!(kinds(&base, &id).await, expected, "{group} {code}");
|
|
assert_eq!(revision, if code == "I1" { 2 } else { 1 }, "{group} {code}");
|
|
}
|
|
for code in ["F1.2", "N1.1"] {
|
|
assert_eq!(seed_version(&base, group, code).await.2, rules_revision, "{group} {code}");
|
|
}
|
|
assert_eq!(seed_version(&base, group, "F1.1").await.2, 3, "{group} F1.1");
|
|
}
|
|
let before = tables(&base).await;
|
|
let before_rows = rowids(&base).await;
|
|
let kept = the_rules_of_operation(&base).await;
|
|
let untouched_versions = rows(&base, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2'))").await;
|
|
|
|
let state = t4(&base).await;
|
|
|
|
// I1, E1 and E2: Yvv's texts, written over the seed proposal, one revision more.
|
|
let today = today_seed();
|
|
for group in GROUPS {
|
|
for code in AMORCE {
|
|
let (id, status, revision, title, body, featured) = seed_version(&state, group, code).await;
|
|
assert_eq!(body, new_text(code), "{group} {code}");
|
|
assert_eq!((status.as_str(), featured), ("open", true), "{group} {code}");
|
|
assert_eq!(revision, if code == "I1" { 3 } else { 2 }, "{group} {code}");
|
|
let expected: &[&str] = if code == "I1" {
|
|
&["initial", "amendment", "seed_proposal", "amendment"]
|
|
} else {
|
|
&["initial", "seed_proposal", "amendment"]
|
|
};
|
|
assert_eq!(kinds(&state, &id).await, expected, "{group} {code}");
|
|
let v = &seed_unit(&today, code)["versions"][0];
|
|
for lang in ["en", "es"] {
|
|
let (t, b, hash, origin, by) = translation(&state, &id, lang).await;
|
|
assert_eq!((json!(t), json!(b)), (v["title"][lang].clone(), v["body"][lang].clone()), "{group} {code} {lang}");
|
|
assert_eq!(hash, ld_server::seed_amend::fingerprint(&title, &body), "{group} {code} {lang}");
|
|
assert_eq!((origin.as_str(), by), ("seed", None));
|
|
}
|
|
}
|
|
}
|
|
let rw = rewrites(&state).await;
|
|
assert_eq!(rw.len(), 6, "{rw:?}");
|
|
assert!(rw.iter().all(|(_, _, o, r)| o == "applied" && r.is_none()), "{rw:?}");
|
|
|
|
// E12: a seed proposal at the level of the others, between E1 and E2.
|
|
let between = |g: &str| (g.to_owned(), "applied".to_owned(), None, Some("between".to_owned()), None);
|
|
assert_eq!(additions(&state).await, [between("essai"), between("monnaie-libre")]);
|
|
for group in GROUPS {
|
|
let (id, status, revision, title, body, featured) = seed_version(&state, group, NEW).await;
|
|
assert_eq!((title.as_str(), body.as_str()), ("Responsabilité civique", E12_FR), "{group}");
|
|
assert_eq!((status.as_str(), revision, featured), ("open", 1, true), "{group}");
|
|
assert_eq!(kinds(&state, &id).await, ["initial"], "{group}");
|
|
let (section, position, inertia, in_force): (String, f64, String, Option<String>) = sqlx::query_as(
|
|
"SELECT section, position, inertia, in_force_id FROM units WHERE id = ?",
|
|
)
|
|
.bind(unit_id(&state, group, NEW).await)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("unit");
|
|
assert_eq!((section.as_str(), position, inertia, in_force), ("fondamental", 4.5, LOW.to_string(), None), "{group}");
|
|
for (lang, t) in E12_TITLE.iter().skip(1) {
|
|
let (title_tr, _, hash, origin, _) = translation(&state, &id, lang).await;
|
|
assert_eq!((title_tr.as_str(), origin.as_str()), (*t, "seed"), "{group} {lang}");
|
|
assert_eq!(hash, ld_server::seed_amend::fingerprint(&title, &body), "{group} {lang}");
|
|
}
|
|
}
|
|
|
|
// The rules of operation: retired in both groups, nothing else written on them.
|
|
let mark = |g: &str| (g.to_owned(), "applied".to_owned(), None, None);
|
|
assert_eq!(retirements(&state).await, [mark("essai"), mark("monnaie-libre")]);
|
|
for group in GROUPS {
|
|
assert_eq!(retired(&state, group).await, all_out(), "{group}");
|
|
}
|
|
let reasons: Vec<String> = sqlx::query_scalar(&format!("SELECT DISTINCT retired_reason FROM units WHERE {OF_THE_OUT}"))
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("reasons");
|
|
assert_eq!(reasons, [RETIRE]);
|
|
assert_eq!(the_rules_of_operation(&state).await, kept, "their rows, as they were");
|
|
|
|
// Through the API: neither listed nor counted.
|
|
let mut c = Client::as_(&state, "Chiara", false).await;
|
|
for group in GROUPS {
|
|
let doc = c.doc(group).await;
|
|
let codes = codes(&doc);
|
|
assert_eq!(codes.len(), 27, "{group}: {codes:?}");
|
|
assert!(OUT.iter().all(|o| !codes.contains(&(*o).to_owned())), "{group}: {codes:?}");
|
|
assert_eq!(order_sections(&doc), SECTIONS, "{group}");
|
|
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", NEW, "E2"], "{group}");
|
|
let all = c.get(&format!("/api/docs/{}/preview?mode=all", doc["doc"]["id"].as_str().expect("id"))).await;
|
|
assert_eq!(all["units"].as_array().expect("units").len(), 27, "{group}");
|
|
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}");
|
|
}
|
|
|
|
// Nothing erased, anywhere, the publication queue included: every row of before is
|
|
// there, and each table holds exactly what lot T4 adds.
|
|
let after = tables(&state).await;
|
|
let after_rows = rowids(&state).await;
|
|
for (t, ids) in &before_rows {
|
|
let missing: Vec<&i64> = ids.difference(&after_rows[t]).collect();
|
|
assert!(missing.is_empty(), "{t}: rows erased {missing:?}");
|
|
}
|
|
let added: BTreeMap<&str, i64> = [
|
|
// E12, in each document.
|
|
("units", 2),
|
|
("proposals", 2),
|
|
// E12's first revision, and the new texts of I1, E1, E2, in each document.
|
|
("revisions", 8),
|
|
// E12 in en and es; I1, E1, E2 replace their own.
|
|
("translations", 4),
|
|
("seed_amendments", 6),
|
|
("article_amendments", 2),
|
|
("retirement_amendments", 2),
|
|
// Monnaie libre: I1, E1, E2, E12, the retirement.
|
|
("outbox", 5),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
for (t, n) in &after {
|
|
let was = before.get(t).copied().unwrap_or(0);
|
|
assert_eq!(n - was, added.get(t.as_str()).copied().unwrap_or(0), "{t}: {was} → {n}");
|
|
}
|
|
eprintln!("lot T4, rows by table (before → after): {}", after.iter().map(|(t, n)| format!("{t} {} → {n}", before.get(t).copied().unwrap_or(0))).collect::<Vec<_>>().join(", "));
|
|
// Every other version untouched.
|
|
let others = rows(&state, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2', 'E12'))").await;
|
|
assert_eq!(others, untouched_versions);
|
|
// The queue of Monnaie libre: a row for each change, waiting for the first text.
|
|
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 ORDER BY o.id DESC LIMIT 5",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("outbox");
|
|
assert!(queued.iter().all(|(g, _)| g == "monnaie-libre"), "{queued:?}");
|
|
assert!(queued.iter().any(|(_, r)| r.contains("formule")), "{queued:?}");
|
|
assert!(queued.iter().any(|(_, r)| r.contains(NEW)), "{queued:?}");
|
|
|
|
// Decided once: a second start replays nothing.
|
|
let again = t4(&state).await;
|
|
assert_eq!(tables(&again).await, after);
|
|
assert_eq!(retirements(&again).await, [mark("essai"), mark("monnaie-libre")]);
|
|
assert_eq!(rewrites(&again).await, rw);
|
|
assert_eq!(additions(&again).await, [between("essai"), between("monnaie-libre")]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn production_after_m8_gets_lot_t4_once_and_nothing_is_erased() {
|
|
production_gets_lot_t4_once(production_after_m8("m8").await, 5).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn production_after_m6c_gets_lot_t4_once_and_nothing_is_erased() {
|
|
production_gets_lot_t4_once(production_after_m6c("m6c").await, 4).await;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// After the retirement, the API refuses everything on them
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn nothing_is_voted_proposed_corrected_or_set_on_the_rules_of_operation() {
|
|
let state = t4(&production_after_m8("refusals").await).await;
|
|
let mut yvv = Client::as_(&state, "Yvv", true).await;
|
|
let mut chiara = Client::as_(&state, "Chiara", false).await;
|
|
for group in GROUPS {
|
|
let doc = doc_id(&state, group).await;
|
|
let unit = unit_id(&state, group, "F1.2").await;
|
|
let (f12, _, _, title, body, _) = seed_version(&state, group, "F1.2").await;
|
|
let (e3, ..) = seed_version(&state, group, "E3").await;
|
|
let order = unit_id(&state, group, "ordre.formule").await;
|
|
let mut ids = Vec::new();
|
|
for code in ["F1.3", "F1.2", "F1.1", "F1"] {
|
|
ids.push(unit_id(&state, group, code).await);
|
|
}
|
|
let cases: Vec<(&str, String, Option<Value>, StatusCode)> = vec![
|
|
("POST", format!("/api/proposals/{f12}/vote"), Some(json!({ "choice": "for" })), StatusCode::NOT_FOUND),
|
|
(
|
|
"POST",
|
|
format!("/api/docs/{doc}/votes"),
|
|
Some(json!({ "lines": [{ "proposalId": f12, "choice": "for" }] })),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
("GET", format!("/api/proposals/{f12}"), None, StatusCode::NOT_FOUND),
|
|
(
|
|
"PATCH",
|
|
format!("/api/proposals/{f12}"),
|
|
Some(json!({ "title": title, "body": format!("{body}.") })),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
(
|
|
"PUT",
|
|
format!("/api/proposals/{f12}/translations/en"),
|
|
Some(json!({ "title": "Default settings", "body": "A member's translation." })),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/api/proposals/{f12}/objections"),
|
|
Some(json!({ "otherId": e3, "kind": "redit", "reason": "Ces deux textes disent la même chose." })),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/api/proposals/{e3}/objections"),
|
|
Some(json!({ "otherId": f12, "kind": "redit", "reason": "Ces deux textes disent la même chose." })),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/api/docs/{doc}/proposals"),
|
|
Some(json!({ "unitId": unit, "title": "Paramètres", "body": "Un autre texte.", "submit": true })),
|
|
StatusCode::NOT_FOUND,
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/api/docs/{doc}/proposals"),
|
|
Some(json!({ "section": "formule", "title": "Formule", "body": "Un nouvel article.", "submit": true })),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
("POST", format!("/api/units/{unit}/inertia"), Some(json!({ "target": 4 })), StatusCode::NOT_FOUND),
|
|
("PUT", format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": 2 })), StatusCode::NOT_FOUND),
|
|
("POST", format!("/api/units/{order}/order"), Some(json!({ "order": ids })), StatusCode::NOT_FOUND),
|
|
];
|
|
for (method, uri, body, expected) in cases {
|
|
for who in [&mut yvv, &mut chiara] {
|
|
let (s, v) = who.call(method, &uri, body.clone()).await;
|
|
assert_eq!(s, expected, "{group} {method} {uri}: {v}");
|
|
}
|
|
}
|
|
}
|
|
// Nothing was written by any of them.
|
|
for (table, sql) in [
|
|
("votes", "SELECT COUNT(*) FROM votes"),
|
|
("ballot_lines", "SELECT COUNT(*) FROM ballot_lines"),
|
|
("typos", "SELECT COUNT(*) FROM typos"),
|
|
("signals", "SELECT COUNT(*) FROM signals"),
|
|
("quorum_changes", "SELECT COUNT(*) FROM quorum_changes"),
|
|
("member versions", "SELECT COUNT(*) FROM proposals WHERE origin <> 'seed'"),
|
|
("member translations", "SELECT COUNT(*) FROM translations WHERE origin <> 'seed'"),
|
|
] {
|
|
assert_eq!(count(&state, sql).await, 0, "{table}");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Any trace of a person keeps them in the document
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum Trace {
|
|
/// A vote of the server (the test group).
|
|
Vote,
|
|
/// A simulated vote (Monnaie libre): a vote, and the line of its ballot.
|
|
Simulated,
|
|
/// A simulated vote withdrawn: the line of its ballot stays.
|
|
Withdrawn,
|
|
/// A typo fix someone else wrote, waiting for an administrator.
|
|
Typo,
|
|
/// A member's translation.
|
|
Translation,
|
|
/// An objection naming it.
|
|
Objection,
|
|
/// A member's article in one of the sections.
|
|
Article,
|
|
/// A member's change of inertia.
|
|
Inertia,
|
|
/// A member's order of one of the sections.
|
|
Order,
|
|
/// A quorum set by an administrator.
|
|
Quorum,
|
|
}
|
|
|
|
impl Trace {
|
|
fn group(self) -> &'static str {
|
|
match self {
|
|
Trace::Simulated | Trace::Withdrawn => "monnaie-libre",
|
|
_ => "essai",
|
|
}
|
|
}
|
|
|
|
/// The reason and the unit the refusal names.
|
|
fn expected(self) -> (&'static str, &'static str) {
|
|
match self {
|
|
Trace::Vote => ("votes", "N1"),
|
|
Trace::Simulated => ("votes", "F1.2"),
|
|
Trace::Withdrawn => ("ballots", "N1.1"),
|
|
Trace::Typo => ("corrected", "F1.1"),
|
|
Trace::Translation => ("translation_edited", "O1"),
|
|
Trace::Objection => ("signalled", "F1.3"),
|
|
Trace::Article => ("member_proposal", "N3"),
|
|
Trace::Inertia => ("member_proposal", "F1"),
|
|
Trace::Order => ("member_proposal", "ordre.formule"),
|
|
Trace::Quorum => ("quorum_set", "N1.1"),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn leave(state: &AppState, trace: Trace) {
|
|
let group = trace.group();
|
|
let mut vera = Client::as_(state, "Vera", false).await;
|
|
let mut yvv = Client::as_(state, "Yvv", true).await;
|
|
let doc = doc_id(state, group).await;
|
|
let id = |code: &'static str| async move { seed_version(state, group, code).await };
|
|
match trace {
|
|
Trace::Vote => {
|
|
let (p, ..) = id("N1").await;
|
|
vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "against" }))).await;
|
|
}
|
|
Trace::Simulated => {
|
|
let (p, ..) = id("F1.2").await;
|
|
let v = vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
assert_eq!(v["simulated"], true);
|
|
}
|
|
Trace::Withdrawn => {
|
|
let (p, ..) = id("N1.1").await;
|
|
vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": null }))).await;
|
|
let votes = count(state, &format!("SELECT COUNT(*) FROM votes WHERE proposal_id = '{p}'")).await;
|
|
assert_eq!(votes, 0, "withdrawn");
|
|
}
|
|
Trace::Typo => {
|
|
let (p, _, _, title, body, _) = id("F1.1").await;
|
|
let fixed = body.replacen('\'', "\u{2019}", 1);
|
|
let v = vera.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": title, "body": fixed }))).await;
|
|
assert_eq!(v["edit"], "typo_pending", "{v}");
|
|
}
|
|
Trace::Translation => {
|
|
let (p, ..) = id("O1").await;
|
|
vera.ok(
|
|
"PUT",
|
|
&format!("/api/proposals/{p}/translations/en"),
|
|
Some(json!({ "title": "Ordering", "body": "A member's translation." })),
|
|
)
|
|
.await;
|
|
}
|
|
Trace::Objection => {
|
|
let (p, ..) = id("F1.3").await;
|
|
let (e3, ..) = id("E3").await;
|
|
vera.ok(
|
|
"POST",
|
|
&format!("/api/proposals/{p}/objections"),
|
|
Some(json!({ "otherId": e3, "kind": "redit", "reason": "Ces deux textes disent la même chose." })),
|
|
)
|
|
.await;
|
|
}
|
|
Trace::Article => {
|
|
vera.ok(
|
|
"POST",
|
|
&format!("/api/docs/{doc}/proposals"),
|
|
Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre est à 4.", "submit": true })),
|
|
)
|
|
.await;
|
|
}
|
|
Trace::Inertia => {
|
|
let u = unit_id(state, group, "F1").await;
|
|
vera.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 4 }))).await;
|
|
}
|
|
Trace::Order => {
|
|
let u = unit_id(state, group, "ordre.formule").await;
|
|
let mut ids = Vec::new();
|
|
for code in ["F1.3", "F1.2", "F1.1", "F1"] {
|
|
ids.push(unit_id(state, group, code).await);
|
|
}
|
|
vera.ok("POST", &format!("/api/units/{u}/order"), Some(json!({ "order": ids }))).await;
|
|
}
|
|
Trace::Quorum => {
|
|
let u = unit_id(state, group, "N1.1").await;
|
|
yvv.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 1 }))).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn any_trace_of_a_person_keeps_the_rules_of_operation_in_the_document() {
|
|
for trace in [
|
|
Trace::Vote,
|
|
Trace::Simulated,
|
|
Trace::Withdrawn,
|
|
Trace::Typo,
|
|
Trace::Translation,
|
|
Trace::Objection,
|
|
Trace::Article,
|
|
Trace::Inertia,
|
|
Trace::Order,
|
|
Trace::Quorum,
|
|
] {
|
|
let base = production_after_m8(&format!("trace-{trace:?}")).await;
|
|
leave(&base, trace).await;
|
|
let kept = the_rules_of_operation(&base).await;
|
|
let state = t4(&base).await;
|
|
let (reason, code) = trace.expected();
|
|
let touched = trace.group();
|
|
let decided: Vec<(String, String, Option<String>, Option<String>)> = GROUPS
|
|
.iter()
|
|
.map(|g| {
|
|
if *g == touched {
|
|
((*g).to_owned(), "refused".to_owned(), Some(reason.to_owned()), Some(code.to_owned()))
|
|
} else {
|
|
((*g).to_owned(), "applied".to_owned(), None, None)
|
|
}
|
|
})
|
|
.collect();
|
|
assert_eq!(retirements(&state).await, decided, "{trace:?}");
|
|
// Refused: nothing moves there; they stay listed and votable.
|
|
assert!(retired(&state, touched).await.is_empty(), "{trace:?}");
|
|
assert_eq!(the_rules_of_operation(&state).await, kept, "{trace:?}");
|
|
let mut c = Client::as_(&state, "Chiara", false).await;
|
|
let doc = c.doc(touched).await;
|
|
let listed = codes(&doc);
|
|
assert!(OUT.iter().all(|o| listed.contains(&(*o).to_owned())), "{trace:?}: {listed:?}");
|
|
assert!(order_sections(&doc).contains(&"formule".to_owned()), "{trace:?}");
|
|
let other = GROUPS.iter().find(|g| **g != touched).expect("other");
|
|
assert_eq!(retired(&state, other).await, all_out(), "{trace:?}");
|
|
// Decided once: still refused at the next start.
|
|
let again = t4(&state).await;
|
|
assert_eq!(retirements(&again).await, decided, "{trace:?}");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The texts of I1, E1, E2 and the place of E12, guarded
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_vote_on_a_seed_proposal_keeps_its_text_and_a_voted_order_puts_e12_at_the_end() {
|
|
let base = production_after_m8("guards").await;
|
|
let mut vera = Client::as_(&base, "Vera", false).await;
|
|
// In the test group: a vote on E1, and a vote on the order of « fondamental » in force.
|
|
let (e1, _, _, _, e1_body, _) = seed_version(&base, "essai", "E1").await;
|
|
vera.ok("POST", &format!("/api/proposals/{e1}/vote"), Some(json!({ "choice": "against" }))).await;
|
|
let order: String = sqlx::query_scalar(
|
|
"SELECT u.in_force_id FROM units u JOIN documents d ON d.id = u.doc_id
|
|
WHERE d.group_slug = 'essai' AND u.code = 'ordre.fondamental'",
|
|
)
|
|
.fetch_one(&base.db)
|
|
.await
|
|
.expect("order in force");
|
|
vera.ok("POST", &format!("/api/proposals/{order}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
|
|
let state = t4(&base).await;
|
|
let rw = rewrites(&state).await;
|
|
let expected: Vec<(String, String, String, Option<String>)> = [
|
|
("essai", "E1", "refused", Some("votes")),
|
|
("essai", "E2", "applied", None),
|
|
("essai", "I1", "applied", None),
|
|
("monnaie-libre", "E1", "applied", None),
|
|
("monnaie-libre", "E2", "applied", None),
|
|
("monnaie-libre", "I1", "applied", None),
|
|
]
|
|
.iter()
|
|
.map(|(g, u, o, r)| ((*g).to_owned(), (*u).to_owned(), (*o).to_owned(), r.map(str::to_owned)))
|
|
.collect();
|
|
assert_eq!(rw, expected);
|
|
let (_, _, revision, _, body, _) = seed_version(&state, "essai", "E1").await;
|
|
assert_eq!((revision, body), (1, e1_body), "the text voted on stays");
|
|
assert_eq!(seed_version(&state, "monnaie-libre", "E1").await.4, E1_FR);
|
|
// E12: at the end of « fondamental » where its order was voted on, between E1 and E2 elsewhere.
|
|
let added = additions(&state).await;
|
|
assert_eq!(
|
|
added,
|
|
[
|
|
("essai".to_owned(), "applied".to_owned(), None, Some("end".to_owned()), Some("order_voted".to_owned())),
|
|
("monnaie-libre".to_owned(), "applied".to_owned(), None, Some("between".to_owned()), None),
|
|
]
|
|
);
|
|
let mut c = Client::as_(&state, "Chiara", false).await;
|
|
assert_eq!(read_order(&c.doc("essai").await, "fondamental"), ["T1", "E1", "E2", NEW]);
|
|
assert_eq!(read_order(&c.doc("monnaie-libre").await, "fondamental"), ["T1", "E1", NEW, "E2"]);
|
|
// The retirement does not look at « fondamental »: applied in both.
|
|
let mark = |g: &str| (g.to_owned(), "applied".to_owned(), None, None);
|
|
assert_eq!(retirements(&state).await, [mark("essai"), mark("monnaie-libre")]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rollback: an image that ignores the column shows them as they were
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn an_image_that_ignores_the_column_shows_the_rules_of_operation_as_they_were() {
|
|
let base = production_after_m8("rollback").await;
|
|
let mut c = Client::as_(&base, "Chiara", false).await;
|
|
let view = |doc: &Value| -> Vec<Value> {
|
|
let units = doc["units"].as_array().expect("units").iter().filter(|u| OUT.contains(&u["code"].as_str().unwrap_or("")));
|
|
let orders = doc["modules"]["orders"]
|
|
.as_array()
|
|
.expect("orders")
|
|
.iter()
|
|
.filter(|o| OUT_SECTIONS.contains(&o["section"].as_str().unwrap_or("")));
|
|
units.chain(orders).cloned().collect()
|
|
};
|
|
let mut before = Vec::new();
|
|
for group in GROUPS {
|
|
let shown = view(&c.doc(group).await);
|
|
assert_eq!(shown.len(), 10, "{group}");
|
|
before.push(shown);
|
|
}
|
|
let state = t4(&base).await;
|
|
for group in GROUPS {
|
|
assert!(view(&c.doc(group).await).is_empty(), "{group}: retired");
|
|
}
|
|
// The image of before (de1b7eb) reads `units` without the column: as here with it empty.
|
|
sqlx::query("UPDATE units SET retired_at = NULL").execute(&state.db).await.expect("as the image of before reads it");
|
|
let mut c = Client::as_(&state, "Chiara", false).await;
|
|
for (i, group) in GROUPS.iter().enumerate() {
|
|
assert_eq!(view(&c.doc(group).await), before[i], "{group}: the same units, versions, texts, translations");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Publication: never in the texts published
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct Remote {
|
|
dir: PathBuf,
|
|
bare: PathBuf,
|
|
}
|
|
|
|
impl Remote {
|
|
fn new(tag: &str) -> Self {
|
|
let dir = std::env::temp_dir().join(format!("ld-amorce-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) -> 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: false,
|
|
simulated: false,
|
|
});
|
|
cfg
|
|
}
|
|
|
|
fn git(&self, args: &[&str]) -> Option<String> {
|
|
let out = std::process::Command::new("git").arg("-C").arg(&self.bare).args(args).output().expect("git");
|
|
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
|
|
}
|
|
|
|
fn show(&self, file: &str) -> Option<String> {
|
|
self.git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")])
|
|
}
|
|
}
|
|
|
|
impl Drop for Remote {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_rules_of_operation_are_never_published() {
|
|
let remote = Remote::new("t4");
|
|
let base = production_after_m8("publish").await;
|
|
let queue_before = count(&base, "SELECT COUNT(*) FROM outbox").await;
|
|
let state = start(&base.db, remote.config()).await;
|
|
ld_server::publish::pass(&state).await.expect("pass");
|
|
assert_eq!(remote.show("fr.md"), None, "no article in force: the queue waits");
|
|
// The first text enters by proven votes: the document goes out, with its queue.
|
|
let doc = doc_id(&state, "monnaie-libre").await;
|
|
let (e1, ..) = seed_version(&state, "monnaie-libre", "E1").await;
|
|
let revision = seed_version(&state, "monnaie-libre", "E1").await.2;
|
|
for i in 0..60 {
|
|
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES (?, 'g1', ?, 1, '2026-11-03T00: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', ?, '2026-11-03T00:00:00Z', 'chain')")
|
|
.bind(&e1)
|
|
.bind(format!("idty:{}", 9000 + i))
|
|
.bind(revision)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("vote");
|
|
}
|
|
let entered = ld_server::adoption::run(&state, &doc).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.contains(E1_FR), "{fr}");
|
|
for word in ["Formule", "Inertie", "Ordonnancement", "Paramètres par défaut", "Niveaux d'inertie"] {
|
|
assert!(!fr.contains(word), "{word}: {fr}");
|
|
}
|
|
let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json");
|
|
let units: Vec<&str> = json["units"].as_array().expect("units").iter().filter_map(|u| u["code"].as_str()).collect();
|
|
assert_eq!(units, ["E1"]);
|
|
let sections: Vec<&str> =
|
|
json["modules"]["orders"].as_array().expect("orders").iter().filter_map(|o| o["section"].as_str()).collect();
|
|
assert_eq!(sections, SECTIONS);
|
|
// The queue: every row kept, gone out together; the commit says each reason, those of the
|
|
// texts of operation written before included.
|
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
|
"SELECT o.reason, o.done_at FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'monnaie-libre' ORDER BY o.id",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("outbox");
|
|
assert!(rows.iter().all(|(_, done)| done.is_some()), "{rows:?}");
|
|
assert!(count(&state, "SELECT COUNT(*) FROM outbox").await > queue_before + 5);
|
|
let message = remote.git(&["log", "-1", "--format=%B", "main"]).expect("log");
|
|
assert!(message.contains("F1.2") && message.contains("formule"), "{message}");
|
|
}
|