forked from yvv/decision
- 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>
281 lines
12 KiB
Rust
281 lines
12 KiB
Rust
//! A group at a glance: what is coming in each tool, and what is left to vote.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use http_body_util::BodyExt;
|
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
struct Client {
|
|
router: axum::Router,
|
|
cookie: Option<String>,
|
|
}
|
|
|
|
impl Client {
|
|
async fn new() -> Self {
|
|
Self::with_state().await.0
|
|
}
|
|
|
|
async fn with_state() -> (Self, AppState) {
|
|
let db = open_db("sqlite::memory:").await.expect("db");
|
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
|
seed::ensure(&state).await.expect("seed");
|
|
(Self { router: app(state.clone()), cookie: None }, state)
|
|
}
|
|
|
|
fn other(&self) -> Self {
|
|
Self { router: self.router.clone(), cookie: None }
|
|
}
|
|
|
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
|
if let Some(c) = &self.cookie {
|
|
req = req.header(header::COOKIE, c);
|
|
}
|
|
let req = match body {
|
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
|
None => req.body(Body::empty()),
|
|
}
|
|
.expect("request");
|
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
|
let status = res.status();
|
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
|
if raw.starts_with("ld_session=") {
|
|
self.cookie = Some(raw);
|
|
}
|
|
}
|
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
|
}
|
|
|
|
async fn 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}");
|
|
}
|
|
|
|
/// An invitation identity that opens only `groups`.
|
|
async fn invited(&mut self, admin: &mut Client, groups: &[&str]) {
|
|
let (s, v) =
|
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": groups }))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
|
let (s, v) = self.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).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
|
|
}
|
|
}
|
|
|
|
fn titles(items: &Value, lang: &str) -> Vec<String> {
|
|
items.as_array().expect("items").iter().map(|i| i["title"][lang].as_str().expect("title").to_owned()).collect()
|
|
}
|
|
|
|
async fn the_doc(c: &mut Client, group: &str) -> Value {
|
|
let v = c.get(&format!("/api/groups/{group}")).await;
|
|
let id = v["docs"][0]["id"].as_str().expect("doc").to_owned();
|
|
c.get(&format!("/api/docs/{id}")).await
|
|
}
|
|
|
|
fn version(doc: &Value, code: &str, which: &str) -> String {
|
|
let unit = doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit");
|
|
let v = if which == "inForce" { &unit["inForce"] } else { &unit["proposals"][which.parse::<usize>().expect("n")] };
|
|
v["id"].as_str().expect("id").to_owned()
|
|
}
|
|
|
|
/// The quorum of an article of the test group set to 2, as its author — an administrator,
|
|
/// for a text of the seed — sets it before the first vote (ADR 0013): where the quorum of
|
|
/// every level is 1 and one voice passes, one voice of two then no longer does.
|
|
async fn two_voices(state: &AppState, code: &str) {
|
|
let n = sqlx::query(
|
|
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
|
|
)
|
|
.bind(code)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("quorum")
|
|
.rows_affected();
|
|
assert_eq!(n, 1, "{code}");
|
|
}
|
|
|
|
async fn decisions(c: &mut Client, group: &str) -> Value {
|
|
c.get(&format!("/api/groups/{group}/decisions")).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn what_is_coming_is_served_with_the_group_in_three_languages() {
|
|
let mut c = Client::new().await;
|
|
c.login("Chiara", false).await;
|
|
let g = c.get("/api/groups/monnaie-libre").await;
|
|
let up = &g["upcoming"];
|
|
assert_eq!(titles(&up["docs"], "fr"), ["Acte d'engagement forgerons"]);
|
|
assert_eq!(titles(&up["mandats"], "fr"), ["Administration forum Duniter", "Administration forum Monnaie libre"]);
|
|
assert_eq!(titles(&up["protocoles"], "fr"), ["Runtime upgrade — décision on-chain"]);
|
|
for tool in ["docs", "mandats", "protocoles"] {
|
|
for lang in ["en", "es"] {
|
|
assert!(titles(&up[tool], lang).iter().all(|t| !t.is_empty()), "{tool} {lang}");
|
|
}
|
|
}
|
|
// The docs in progress are still there, and only Monnaie libre has something coming.
|
|
assert_eq!(g["docs"].as_array().expect("docs").len(), 1);
|
|
let essai = c.get("/api/groups/essai").await;
|
|
for tool in ["docs", "mandats", "protocoles"] {
|
|
assert_eq!(essai["upcoming"][tool], json!([]), "{tool}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() {
|
|
let mut admin = Client::new().await;
|
|
admin.login("Yvv", true).await;
|
|
let mut guest = admin.other();
|
|
guest.invited(&mut admin, &["essai"]).await;
|
|
let g = guest.get("/api/groups/axiom").await;
|
|
assert_eq!(g["group"]["open"], false);
|
|
assert_eq!(g["docs"], json!([]));
|
|
for tool in ["docs", "mandats", "protocoles"] {
|
|
assert_eq!(g["upcoming"][tool], json!([]), "{tool}");
|
|
}
|
|
let (s, _) = guest.call("GET", "/api/groups/axiom/decisions", None).await;
|
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
|
let (s, _) = guest.other().call("GET", "/api/groups/monnaie-libre/decisions", None).await;
|
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
|
|
// In the test group, which votes on the server (Monnaie libre votes on the chain:
|
|
// tests/ballots.rs counts « sans ton vote » there).
|
|
let (mut c, state) = Client::with_state().await;
|
|
c.login("Chiara", false).await;
|
|
// Someone else, who will not vote: W(essai) = 2.
|
|
let mut yvv = c.other();
|
|
yvv.login("Yvv", false).await;
|
|
let d = decisions(&mut c, "essai").await;
|
|
assert_eq!(d["canVote"], true);
|
|
let docs = d["docs"].as_array().expect("docs");
|
|
assert_eq!(docs.len(), 1);
|
|
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
|
|
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
|
|
// 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title
|
|
// of the document and the order of 7 sections (« ordonnancement » holds one article: nothing
|
|
// to decide).
|
|
assert_eq!(docs[0]["toVote"], 41);
|
|
assert_eq!(docs[0]["withoutMyVote"], 41);
|
|
|
|
let doc = the_doc(&mut c, "essai").await;
|
|
// Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two.
|
|
two_voices(&state, "E3").await;
|
|
two_voices(&state, "I1").await;
|
|
c.vote(&version(&doc, "E3", "0"), "for").await;
|
|
c.vote(&version(&doc, "E3", "1"), "against").await;
|
|
c.vote(&version(&doc, "I1", "0"), "for").await;
|
|
let d = decisions(&mut c, "essai").await;
|
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
|
|
// Someone else has not voted yet.
|
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_count() {
|
|
let mut yvv = Client::new().await;
|
|
yvv.login("Yvv", false).await;
|
|
let mut chiara = yvv.other();
|
|
chiara.login("Chiara", false).await;
|
|
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
|
|
// (ADR 0013): one « for » adopts.
|
|
let doc = the_doc(&mut yvv, "essai").await;
|
|
let e3 = version(&doc, "E3", "0");
|
|
let v = yvv.vote(&e3, "for").await;
|
|
assert_eq!(v["entered"][0]["code"], "E3");
|
|
let d = decisions(&mut yvv, "essai").await;
|
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
|
|
|
// A new article by Chiara: one more module to vote.
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let (s, v) = chiara
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/proposals"),
|
|
Some(json!({
|
|
"title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true,
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
let added = v["id"].as_str().expect("id").to_owned();
|
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41);
|
|
yvv.vote(&added, "against").await;
|
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40);
|
|
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
|
|
let (s, v) = chiara
|
|
.call(
|
|
"PATCH",
|
|
&format!("/api/proposals/{added}"),
|
|
Some(json!({
|
|
"title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours.",
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v["edit"], "substantive");
|
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
|
|
let mut yvv = Client::new().await;
|
|
yvv.login("Yvv", true).await;
|
|
let doc = the_doc(&mut yvv, "essai").await;
|
|
// Two articles of a single open version each: one of them is held (ADR 0010).
|
|
let (i2, k1) = (version(&doc, "I2", "0"), version(&doc, "K1", "0"));
|
|
let (s, v) = yvv
|
|
.call(
|
|
"POST",
|
|
&format!("/api/proposals/{i2}/objections"),
|
|
Some(json!({ "otherId": k1, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
// Nobody can vote on it while it is held: one module fewer to vote, for everyone.
|
|
let d = decisions(&mut yvv, "essai").await;
|
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
|
// The list of the group still counts it among the proposals in progress: it is open.
|
|
let g = yvv.get("/api/groups/essai").await;
|
|
let open = doc["units"]
|
|
.as_array()
|
|
.expect("units")
|
|
.iter()
|
|
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|p| p["status"] == "open").count() as u64);
|
|
assert_eq!(g["docs"][0]["open"].as_u64(), Some(open.sum::<u64>()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_of_ones_own() {
|
|
let mut admin = Client::new().await;
|
|
admin.login("Yvv", true).await;
|
|
let mut guest = admin.other();
|
|
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
|
|
// No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012).
|
|
let d = decisions(&mut guest, "monnaie-libre").await;
|
|
assert_eq!(d["canVote"], true);
|
|
assert_eq!(d["docs"][0]["toVote"], 41);
|
|
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
|
|
// In the test group, the same person votes.
|
|
let d = decisions(&mut guest, "essai").await;
|
|
assert_eq!(d["canVote"], true);
|
|
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
|
|
}
|