forked from yvv/decision
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
18 KiB
Rust
405 lines
18 KiB
Rust
//! The quorum (ADR 0013). Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition
|
|
//! par défaut, variable modifiable par l'auteur initial du texte… c'est plutôt un
|
|
//! quorum » ; 2026-11-03, below the quorum: « Ils comptent comme non ». Each level has
|
|
//! its quorum — « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, 1 everywhere in the test
|
|
//! group; the author of an article's first version sets its own, « avant le 1er vote ».
|
|
//! An existing base gets the quorums by level through a guarded amendment, decided once.
|
|
|
|
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;
|
|
|
|
/// The quorums of Monnaie libre by level (Yvv, 2026-11-02).
|
|
const QUORUMS: [u64; 5] = [12, 12, 50, 50, 50];
|
|
/// W of Monnaie libre in the tests (`Config::for_tests`, no chain read).
|
|
const W: u64 = 5745;
|
|
|
|
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 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 login(&mut self, name: &str, admin: bool) {
|
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
|
}
|
|
|
|
async fn vote(&mut self, proposal: &str, choice: Option<&str>) -> Value {
|
|
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).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
|
|
}
|
|
|
|
/// `PUT /units/{id}/quorum`.
|
|
async fn set(&mut self, unit: &str, quorum: Value) -> (StatusCode, Value) {
|
|
self.call("PUT", &format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": quorum }))).await
|
|
}
|
|
}
|
|
|
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
|
}
|
|
|
|
fn unit_by_id<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
|
doc["units"].as_array().expect("units").iter().find(|u| u["id"] == id).expect("unit")
|
|
}
|
|
|
|
fn numbers(levels: &Value, key: &str) -> Vec<u64> {
|
|
levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect()
|
|
}
|
|
|
|
/// (quorum, quorumDefault, canSetQuorum) of a unit.
|
|
fn quorum_of(u: &Value) -> (Option<u64>, Option<u64>, Option<bool>) {
|
|
(u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool())
|
|
}
|
|
|
|
/// (quorum, cast, required, yesNeeded, noNeeded, missing, reached) of an evaluation.
|
|
fn figures(e: &Value) -> (u64, u64, u64, u64, u64, u64, bool) {
|
|
let n = |k: &str| e[k].as_u64().unwrap_or_else(|| panic!("{k}: {e}"));
|
|
(n("quorum"), n("cast"), n("required"), n("yesNeeded"), n("noNeeded"), n("missing"), e["reached"] == true)
|
|
}
|
|
|
|
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 each_level_has_its_quorum_and_every_evaluation_says_it() {
|
|
let state = fresh().await;
|
|
let mut c = Client::of(&state);
|
|
c.login("Chiara", false).await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!(doc["doc"]["quorums"], json!(QUORUMS));
|
|
assert_eq!(numbers(&doc["levels"], "quorum"), QUORUMS);
|
|
// I1, a seed proposal at level 2: its level's quorum, 12. Nobody voted: judged on 12
|
|
// voters, the 12 missing counted as against — 9 « pour » pass (the control figure).
|
|
let i1 = unit(&doc, "I1");
|
|
assert_eq!(quorum_of(i1), (None, Some(12), Some(false)), "not the author, no administrator");
|
|
assert_eq!(figures(&i1["proposals"][0]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
|
|
// E3 at level 3: the quorum 50, 39 « pour » at no vote.
|
|
let e3 = unit(&doc, "E3");
|
|
assert_eq!(e3["inertia"], 3);
|
|
assert_eq!(quorum_of(e3), (None, Some(50), Some(false)));
|
|
assert_eq!(figures(&e3["proposals"][0]["evaluation"]), (50, 0, 39, 39, 0, 39, false));
|
|
// The detail says the same, the five levels on their quorums.
|
|
let p = i1["proposals"][0]["id"].as_str().expect("i1");
|
|
let d = c.get(&format!("/api/proposals/{p}")).await;
|
|
assert_eq!(figures(&d["proposal"]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
|
|
assert_eq!(numbers(&d["levels"], "quorum"), QUORUMS);
|
|
assert_eq!(numbers(&d["levels"], "required"), [8, 9, 39, 43, 46]);
|
|
|
|
// The test group: 1 everywhere.
|
|
let essai = c.doc("essai").await;
|
|
assert_eq!(essai["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
|
|
assert_eq!(quorum_of(unit(&essai, "E3")), (None, Some(1), Some(false)));
|
|
assert_eq!(unit(&essai, "I1")["proposals"][0]["evaluation"]["quorum"], 1);
|
|
|
|
// What is published carries them (document.json).
|
|
let id = doc["doc"]["id"].as_str().expect("id");
|
|
let st = ld_server::docs::load_state(&state, id).await.expect("state");
|
|
let files = ld_server::publish::render(&state, &st).await.expect("render");
|
|
let json: Value = serde_json::from_str(&files.iter().find(|(n, _)| n == "document.json").expect("document.json").1)
|
|
.expect("json");
|
|
assert_eq!(json["quorums"], json!(QUORUMS));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() {
|
|
let state = fresh().await;
|
|
let mut chiara = Client::of(&state);
|
|
chiara.login("Chiara", false).await;
|
|
let mut luna = chiara.other();
|
|
luna.login("Luna", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let created = chiara
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/proposals"),
|
|
Some(json!({ "section": "fondamental", "title": "Nouveau", "body": "Un nouvel article.", "submit": true })),
|
|
)
|
|
.await;
|
|
let (u, text) =
|
|
(created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned());
|
|
// Its author may, another member may not; the test group: its level's quorum, 1.
|
|
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true)));
|
|
assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false)));
|
|
let (s, v) = luna.set(&u, json!(2)).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::FORBIDDEN, Some("forbidden")));
|
|
// From 1 in the test group to W (2 members).
|
|
for bad in [json!(0), json!(3), json!(-1), json!(1.5)] {
|
|
let (s, v) = chiara.set(&u, bad.clone()).await;
|
|
assert!(s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, "{bad}: {s} {v}");
|
|
if s == StatusCode::BAD_REQUEST {
|
|
assert_eq!(v["error"], "quorum", "{bad}");
|
|
}
|
|
}
|
|
let (s, v) = chiara.set(&u, json!(2)).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v, json!({ "quorum": 2, "quorumDefault": 1, "canSetQuorum": true }));
|
|
let doc = chiara.doc("essai").await;
|
|
let mine = unit_by_id(&doc, &u);
|
|
assert_eq!(quorum_of(mine), (Some(2), Some(1), Some(true)));
|
|
assert_eq!(mine["proposals"][0]["evaluation"]["quorum"], 2);
|
|
// A change of its inertia is judged on it too.
|
|
let inertia = chiara.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await;
|
|
let d = chiara.get(&format!("/api/proposals/{}", inertia["id"].as_str().expect("id"))).await;
|
|
assert_eq!(d["proposal"]["evaluation"]["quorum"], 2);
|
|
assert_eq!(numbers(&d["levels"], "quorum"), [2; 5], "the article's own quorum, at every level");
|
|
// Back to its level's, then 2 again: each change is journaled.
|
|
let (s, v) = chiara.set(&u, Value::Null).await;
|
|
assert_eq!((s, &v), (StatusCode::OK, &json!({ "quorum": null, "quorumDefault": 1, "canSetQuorum": true })));
|
|
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
|
|
let journal: Vec<(Option<i64>, Option<i64>, String)> = sqlx::query_as(
|
|
"SELECT q.from_quorum, q.to_quorum, i.display_name FROM quorum_changes q
|
|
JOIN identities i ON i.id = q.identity_id WHERE q.unit_id = ? ORDER BY q.id",
|
|
)
|
|
.bind(&u)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("journal");
|
|
let chiara_name = "Chiara".to_owned();
|
|
assert_eq!(
|
|
journal,
|
|
[(None, Some(2), chiara_name.clone()), (Some(2), None, chiara_name.clone()), (None, Some(2), chiara_name)]
|
|
);
|
|
|
|
// The first vote: 1 « pour » of 2 voters on a quorum of 2 does not pass, and the
|
|
// quorum is held from then on.
|
|
let v = luna.vote(&text, Some("for")).await;
|
|
assert!(v["entered"].as_array().expect("entered").is_empty(), "{v}");
|
|
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(false)));
|
|
let (s, v) = chiara.set(&u, json!(1)).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
|
|
// A vote on its change of inertia counts as well: it is the same article.
|
|
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
|
}
|
|
|
|
/// A ballot of the chain on a proposal, as the server writes it when a wallet asks.
|
|
async fn ballot(state: &AppState, doc: &str, proposal: &str, status: &str, n: u32) {
|
|
let id = format!("ballot-{status}-{n}");
|
|
sqlx::query(
|
|
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status,
|
|
created_at, expires_at)
|
|
VALUES (?, ?, (SELECT id FROM identities LIMIT 1), 'salt', ?, ?, 'g1vote', 1, ?,
|
|
'2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')",
|
|
)
|
|
.bind(&id)
|
|
.bind(doc)
|
|
.bind(format!("digest-{id}"))
|
|
.bind(format!("remark-{id}"))
|
|
.bind(status)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("ballot");
|
|
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, 1, 'for')")
|
|
.bind(&id)
|
|
.bind(proposal)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("line");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pending() {
|
|
let state = fresh().await;
|
|
let mut admin = Client::of(&state);
|
|
admin.login("Yvv", true).await;
|
|
let mut chiara = admin.other();
|
|
chiara.login("Chiara", false).await;
|
|
let doc = admin.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = unit(&doc, "I1");
|
|
let (u, p) = (i1["id"].as_str().expect("u").to_owned(), i1["proposals"][0]["id"].as_str().expect("p").to_owned());
|
|
// A text of the seed has no author: an administrator answers for it.
|
|
assert_eq!(quorum_of(i1), (None, Some(12), Some(true)));
|
|
let (s, _) = chiara.set(&u, json!(20)).await;
|
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
|
// At least 2: one voice never passes a text alone; at most W.
|
|
for bad in [1, 0, W + 1] {
|
|
let (s, v) = admin.set(&u, json!(bad)).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("quorum")), "{bad}");
|
|
}
|
|
for good in [2, W, 20] {
|
|
let (s, v) = admin.set(&u, json!(good)).await;
|
|
assert_eq!((s, v["quorum"].as_u64()), (StatusCode::OK, Some(good)), "{v}");
|
|
}
|
|
let doc = admin.doc("monnaie-libre").await;
|
|
assert_eq!(unit(&doc, "I1")["proposals"][0]["evaluation"]["quorum"], 20);
|
|
// A refused ballot, an expired one, are no vote.
|
|
ballot(&state, &doc_id, &p, "refused", 1).await;
|
|
ballot(&state, &doc_id, &p, "expired", 2).await;
|
|
assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
|
assert_eq!(admin.set(&u, json!(12)).await.0, StatusCode::OK);
|
|
// A pending one is: the vote is cast, the chain has not said it yet.
|
|
ballot(&state, &doc_id, &p, "pending", 3).await;
|
|
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
|
assert_eq!(quorum_of(unit(&admin.doc("monnaie-libre").await, "I1")), (Some(12), Some(12), Some(false)));
|
|
let (s, v) = admin.set(&u, Value::Null).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
|
|
// A confirmed one, on another article.
|
|
let e1 = unit(&doc, "E1");
|
|
let (u1, p1) = (e1["id"].as_str().expect("u").to_owned(), e1["proposals"][0]["id"].as_str().expect("p").to_owned());
|
|
assert!(!ld_server::quorum::a_vote_exists(&state, &u1).await.expect("query"));
|
|
ballot(&state, &doc_id, &p1, "confirmed", 4).await;
|
|
assert_eq!(admin.set(&u1, json!(30)).await.1["error"], "voted");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// An existing base: the quorum amendment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The seed production runs on 2026-11-02: no quorum anywhere, the C of before.
|
|
fn production_config() -> Config {
|
|
let mut cfg = Config::for_tests();
|
|
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
|
|
cfg
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// (group, from, to, outcome, reason) of each decision.
|
|
type Decision = (String, Option<String>, String, String, Option<String>);
|
|
|
|
async fn decisions(state: &AppState) -> Vec<Decision> {
|
|
sqlx::query_as(
|
|
"SELECT d.group_slug, q.from_quorums, q.to_quorums, q.outcome, q.reason
|
|
FROM quorum_amendments q JOIN documents d ON d.id = q.doc_id ORDER BY d.group_slug",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("decisions")
|
|
}
|
|
|
|
async fn stored(state: &AppState, group: &str) -> Option<String> {
|
|
sqlx::query_scalar("SELECT quorums FROM documents WHERE group_slug = ?")
|
|
.bind(group)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("quorums")
|
|
}
|
|
|
|
fn row(group: &str, from: Option<&str>, to: &str, outcome: &str, reason: Option<&str>) -> Decision {
|
|
(group.into(), from.map(Into::into), to.into(), outcome.into(), reason.map(Into::into))
|
|
}
|
|
|
|
const ML: &str = "[12,12,50,50,50]";
|
|
const ESSAI: &str = "[1,1,1,1,1]";
|
|
|
|
#[tokio::test]
|
|
async fn the_quorum_amendment_writes_the_quorums_by_level_once() {
|
|
// Production as it runs: no quorum written; every level reads the C of before.
|
|
let born = fresh_with(production_config()).await;
|
|
assert_eq!((stored(&born, "monnaie-libre").await, stored(&born, "essai").await), (None, None));
|
|
assert!(decisions(&born).await.is_empty(), "a seed without quorums decides nothing");
|
|
let mut c = Client::of(&born);
|
|
c.login("Chiara", false).await;
|
|
assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50]));
|
|
assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
|
|
|
|
// Started with today's seed: written, where none was.
|
|
let state = restart(&born, Config::for_tests()).await;
|
|
let first = decisions(&state).await;
|
|
assert_eq!(
|
|
first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
|
|
[row("essai", None, ESSAI, "applied", None), row("monnaie-libre", None, ML, "applied", None)]
|
|
);
|
|
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some(ML));
|
|
assert_eq!(stored(&state, "essai").await.as_deref(), Some(ESSAI));
|
|
let mut c = Client::of(&state);
|
|
c.login("Chiara", false).await;
|
|
assert_eq!(unit(&c.doc("monnaie-libre").await, "I1")["proposals"][0]["evaluation"]["quorum"], 12);
|
|
// Republished where the document publishes; never from the test group.
|
|
let queued: Vec<String> = sqlx::query_scalar(
|
|
"SELECT d.group_slug FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'quorums%'",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("outbox");
|
|
assert_eq!(queued, ["monnaie-libre"]);
|
|
// Once only.
|
|
let again = restart(&state, Config::for_tests()).await;
|
|
assert_eq!(decisions(&again).await, first);
|
|
|
|
// A new base is born with them: present.
|
|
let new = fresh().await;
|
|
let present: Vec<String> = decisions(&new).await.into_iter().map(|(_, _, _, o, _)| o).collect();
|
|
assert_eq!(present, ["present", "present"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn quorums_written_otherwise_are_kept_and_the_refusal_is_kept() {
|
|
let born = fresh_with(production_config()).await;
|
|
sqlx::query("UPDATE documents SET quorums = '[3,3,3,3,3]' WHERE group_slug = 'monnaie-libre'")
|
|
.execute(&born.db)
|
|
.await
|
|
.expect("quorums");
|
|
let state = restart(&born, Config::for_tests()).await;
|
|
let got = decisions(&state).await;
|
|
assert_eq!(
|
|
got.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
|
|
[
|
|
row("essai", None, ESSAI, "applied", None),
|
|
row("monnaie-libre", Some("[3,3,3,3,3]"), ML, "refused", Some("quorums_set")),
|
|
]
|
|
);
|
|
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some("[3,3,3,3,3]"));
|
|
}
|