From c640413a700cc51022e01549b9dfdc079af6e242 Mon Sep 17 00:00:00 2001 From: Yvv Date: Fri, 30 Oct 2026 09:07:39 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20D=C3=A9cisions=20et=20=C2=AB=20=C3=80?= =?UTF-8?q?=20venir=20=C2=BB=20c=C3=B4t=C3=A9=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ld-engine/pending : un module (article) reste à voter tant qu'aucune version n'a été adoptée par un vote (seed a minima non confirmé, ou pas de version en vigueur et au moins une version ouverte) ; les variantes comptent pour un ; « sans ton vote » = aucun vote valide sur aucune version. - overview.rs : GET /api/groups/{slug} ajoute « upcoming » (docs, mandats, protocoles), lu au démarrage dans seeds/upcoming/.json, trilingue, servi seulement à qui ouvre le groupe ; GET /api/groups/{slug}/decisions, une ligne par doc avec toVote et withoutMyVote (si la personne vote). - Retour après connexion par défaut : « / » au lieu de « /groupes ». Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/lib.rs | 1 + crates/ld-engine/src/pending.rs | 95 ++++++++++++ crates/ld-server/src/auth.rs | 14 +- crates/ld-server/src/lib.rs | 9 +- crates/ld-server/src/overview.rs | 222 ++++++++++++++++++++++++++++ crates/ld-server/tests/overview.rs | 224 +++++++++++++++++++++++++++++ seeds/upcoming/monnaie-libre.json | 40 ++++++ 7 files changed, 596 insertions(+), 9 deletions(-) create mode 100644 crates/ld-engine/src/pending.rs create mode 100644 crates/ld-server/src/overview.rs create mode 100644 crates/ld-server/tests/overview.rs create mode 100644 seeds/upcoming/monnaie-libre.json diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 9a8b818..616a534 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod assist; pub mod compose; pub mod edit; pub mod names; +pub mod pending; pub mod threshold; pub use threshold::{FormulaParams, InertiaPreset, Tally}; diff --git a/crates/ld-engine/src/pending.rs b/crates/ld-engine/src/pending.rs new file mode 100644 index 0000000..d3c1b72 --- /dev/null +++ b/crates/ld-engine/src/pending.rs @@ -0,0 +1,95 @@ +//! What is left to vote in a document — the line of « Décisions ». +//! +//! A module is an article (a unit). It stays **to vote** as long as no version +//! of it was adopted by a vote: its version in force is a seed a minima not yet +//! confirmed, or it has no version in force and at least one open version. +//! Variants of an article count once. For a person who votes, a module is +//! **without their vote** when they hold no valid vote (one not « to +//! reconfirm ») on any of its versions. + +/// One article, as one person sees it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Module { + /// The version in force, if any: `Some(true)` when a vote adopted or + /// confirmed it, `Some(false)` for a seed a minima. + pub in_force: Option, + /// Open versions of the article (proposals to replace, add or remove it). + pub open: u32, + /// The person holds a valid vote on one of its versions, in force or open. + pub voted: bool, +} + +impl Module { + pub fn to_vote(&self) -> bool { + match self.in_force { + Some(adopted) => !adopted, + None => self.open > 0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Pending { + /// Modules not yet adopted by a vote. + pub to_vote: u32, + /// Among them, those where the person has no valid vote. + pub without_my_vote: u32, +} + +pub fn pending(modules: &[Module]) -> Pending { + modules.iter().filter(|m| m.to_vote()).fold(Pending::default(), |acc, m| Pending { + to_vote: acc.to_vote + 1, + without_my_vote: acc.without_my_vote + u32::from(!m.voted), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED: Option = Some(false); + const ADOPTED: Option = Some(true); + + fn m(in_force: Option, open: u32, voted: bool) -> Module { + Module { in_force, open, voted } + } + + #[test] + fn a_seed_a_minima_stays_to_vote_until_a_vote_confirms_it() { + assert!(m(SEED, 0, false).to_vote()); + assert!(m(SEED, 2, true).to_vote()); + assert!(!m(ADOPTED, 0, false).to_vote()); + } + + #[test] + fn an_adopted_article_is_no_longer_to_vote_even_with_rivals() { + assert!(!m(ADOPTED, 3, false).to_vote()); + } + + #[test] + fn an_empty_slot_is_to_vote_only_with_an_open_version() { + assert!(m(None, 1, false).to_vote()); + assert!(!m(None, 0, false).to_vote()); + } + + #[test] + fn variants_count_once_and_my_vote_on_any_version_counts() { + // Seed of the certification act: 3 seeds a minima, 30 open articles (two with a variant). + let mut modules = vec![m(SEED, 0, false); 3]; + modules.extend(vec![m(None, 1, false); 28]); + modules.extend(vec![m(None, 2, false); 2]); + assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 33 }); + modules[0].voted = true; + modules[31].voted = true; + assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 31 }); + // Once adopted, an article leaves both counts, voted or not. + modules[1].in_force = ADOPTED; + modules[31].in_force = ADOPTED; + assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 }); + } + + #[test] + fn nothing_to_vote_in_an_empty_document() { + assert_eq!(pending(&[]), Pending::default()); + } +} diff --git a/crates/ld-server/src/auth.rs b/crates/ld-server/src/auth.rs index e226564..6019220 100644 --- a/crates/ld-server/src/auth.rs +++ b/crates/ld-server/src/auth.rs @@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String { { p.to_owned() } - _ => "/groupes".to_owned(), + _ => "/".to_owned(), } } @@ -577,12 +577,12 @@ mod tests { #[test] fn return_paths_stay_local() { assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre"); - assert_eq!(safe_return(Some("//evil.example")), "/groupes"); - assert_eq!(safe_return(Some("https://evil.example")), "/groupes"); - assert_eq!(safe_return(Some("/\\evil")), "/groupes"); - assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes"); - assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes"); - assert_eq!(safe_return(None), "/groupes"); + assert_eq!(safe_return(Some("//evil.example")), "/"); + assert_eq!(safe_return(Some("https://evil.example")), "/"); + assert_eq!(safe_return(Some("/\\evil")), "/"); + assert_eq!(safe_return(Some("/\t/evil.example")), "/"); + assert_eq!(safe_return(Some("/\n/evil.example")), "/"); + assert_eq!(safe_return(None), "/"); } #[tokio::test] diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index c828505..bb8f514 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod config; pub mod docs; pub mod error; +pub mod overview; pub mod proposals; pub mod publish; pub mod seed; @@ -41,6 +42,8 @@ pub struct AppState { pub http: reqwest::Client, /// Wakes the publisher as soon as an adoption is recorded. pub publish_wake: Arc, + /// What is coming in the tools of each group (`seeds/upcoming/`), read at start. + pub upcoming: Arc, } impl AppState { @@ -49,7 +52,8 @@ impl AppState { .timeout(Duration::from_secs(15)) .user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION"))) .build()?; - Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) }) + let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?); + Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming }) } } @@ -103,7 +107,8 @@ pub fn api_router() -> Router { .route("/auth/logout", post(auth::logout)) .route("/me", get(auth::me)) .route("/groups", get(docs::groups)) - .route("/groups/{slug}", get(docs::group)) + .route("/groups/{slug}", get(overview::group)) + .route("/groups/{slug}/decisions", get(overview::decisions)) .route("/docs/{id}", get(docs::document)) .route("/docs/{id}/preview", get(docs::preview)) .route("/docs/{id}/assist", post(proposals::assist)) diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs new file mode 100644 index 0000000..8ef0433 --- /dev/null +++ b/crates/ld-server/src/overview.rs @@ -0,0 +1,222 @@ +//! A group at a glance: what is coming in each tool, and what is left to vote. +//! +//! What is coming is data, not code: `seeds/upcoming/.json`, one file +//! per group, read once at start (titles in fr, en and es). It is served with +//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who +//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one +//! line per document, counted by `ld_engine::pending`. + +use crate::AppState; +use crate::auth::User; +use crate::docs; +use crate::error::AppResult; +use crate::util::{self, LangMap}; +use anyhow::{Context, bail}; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::pending::{Module, pending}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashSet}; + +/// Something announced in a tool of a group, not open yet. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct UpcomingItem { + pub key: String, + pub title: LangMap, +} + +/// What is coming in the tools of one group. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GroupUpcoming { + #[serde(default)] + pub docs: Vec, + #[serde(default)] + pub mandats: Vec, + #[serde(default)] + pub protocoles: Vec, +} + +impl GroupUpcoming { + fn check(&self) -> anyhow::Result<()> { + for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] { + let mut keys = HashSet::new(); + for item in items { + if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) { + bail!("{tool}: empty or repeated key « {} »", item.key); + } + for lang in util::LANGS { + if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) { + bail!("{tool}/{}: no title in {lang}", item.key); + } + } + } + } + Ok(()) + } +} + +/// Every group's announcements, by group slug. +#[derive(Clone, Debug, Default)] +pub struct Upcoming(BTreeMap); + +impl Upcoming { + /// Reads `/upcoming/*.json`; no directory, nothing coming. A file + /// that does not read, or lacks a language, stops the start. + pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result { + let dir = seed_dir.join("upcoming"); + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(e).context(dir.display().to_string()), + }; + let mut out = BTreeMap::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_none_or(|x| x != "json") { + continue; + } + let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + let raw = tokio::fs::read_to_string(&path).await?; + let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?; + parsed.check().with_context(|| path.display().to_string())?; + out.insert(group, parsed); + } + Ok(Self(out)) + } + + pub fn of(&self, group: &str) -> GroupUpcoming { + self.0.get(group).cloned().unwrap_or_default() + } +} + +/// The group, its tools and documents (`docs::group`), plus what is coming. +pub async fn group(State(state): State, user: User, Path(slug): Path) -> AppResult> { + let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?; + let open = view["group"]["open"].as_bool().unwrap_or(false); + let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() }; + view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null); + Ok(Json(view)) +} + +/// « Décisions »: one line per document in progress, with its modules left to vote. +pub async fn decisions( + State(state): State, + User(user): User, + Path(slug): Path, +) -> AppResult> { + let group = docs::load_group(&state, &slug).await?; + docs::require_open(&state, &user, &group).await?; + let can_vote = docs::may_vote(&state, &user, &group).await?; + #[derive(sqlx::FromRow)] + struct DocRow { + id: String, + slug: String, + title: String, + } + let rows = + sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at") + .bind(&slug) + .fetch_all(&state.db) + .await?; + let mut out = Vec::with_capacity(rows.len()); + for d in rows { + let p = pending(&modules(&state, &d.id, &user.id).await?); + out.push(json!({ + "id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title), + "toVote": p.to_vote, + "withoutMyVote": can_vote.then_some(p.without_my_vote), + })); + } + Ok(Json(json!({ "canVote": can_vote, "docs": out }))) +} + +/// The articles of a document as `me` sees them: its version in force (adopted +/// by a vote, or a seed a minima), its open versions, and whether `me` holds a +/// valid vote on one of them. A change of inertia is not a version of the text. +async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { + #[derive(sqlx::FromRow)] + struct VersionRow { + unit_id: String, + in_force: bool, + adopted: bool, + voted: bool, + } + let rows = sqlx::query_as::<_, VersionRow>( + "SELECT u.id AS unit_id, + IFNULL(p.id = u.in_force_id, 0) AS in_force, + (p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted, + EXISTS (SELECT 1 FROM votes v + WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted + FROM units u JOIN proposals p ON p.unit_id = u.id + WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open') + ORDER BY u.position, u.id", + ) + .bind(me) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut out: Vec = Vec::new(); + let mut current: Option = None; + for r in rows { + if current.as_deref() != Some(r.unit_id.as_str()) { + out.push(Module::default()); + current = Some(r.unit_id); + } + if let Some(m) = out.last_mut() { + if r.in_force { + m.in_force = Some(r.adopted); + } else { + m.open += 1; + } + m.voted |= r.voted; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Config; + + async fn load(files: &[(&str, &str)]) -> anyhow::Result { + let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id())); + tokio::fs::create_dir_all(dir.join("upcoming")).await?; + for (name, body) in files { + tokio::fs::write(dir.join("upcoming").join(name), body).await?; + } + let out = Upcoming::load(&dir).await; + let _ = tokio::fs::remove_dir_all(&dir).await; + out + } + + const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#; + + #[tokio::test] + async fn one_file_per_group_and_nothing_elsewhere() { + let up = + load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load"); + assert_eq!(up.of("axiom").mandats.len(), 1); + assert!(up.of("axiom").docs.is_empty()); + assert!(up.of("librodrome").mandats.is_empty()); + let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load"); + assert!(none.of("axiom").docs.is_empty()); + } + + #[tokio::test] + async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() { + let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#; + assert!(load(&[("axiom.json", no_es)]).await.is_err()); + assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err()); + assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err()); + } + + #[tokio::test] + async fn the_seeds_of_the_repository_read() { + let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load"); + assert_eq!(up.of("monnaie-libre").mandats.len(), 2); + } +} diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs new file mode 100644 index 0000000..ec23069 --- /dev/null +++ b/crates/ld-server/tests/overview.rs @@ -0,0 +1,224 @@ +//! 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, +} + +impl Client { + async fn new() -> Self { + 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), cookie: None } + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (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 { + 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::().expect("n")] }; + v["id"].as_str().expect("id").to_owned() +} + +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() { + let mut c = Client::new().await; + c.login("Chiara", false).await; + let d = decisions(&mut c, "monnaie-libre").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 seeds a minima + 30 open articles, the two variants counting once. + assert_eq!(docs[0]["toVote"], 33); + assert_eq!(docs[0]["withoutMyVote"], 33); + + let doc = the_doc(&mut c, "monnaie-libre").await; + c.vote(&version(&doc, "E3", "0"), "for").await; + c.vote(&version(&doc, "E3", "1"), "against").await; + c.vote(&version(&doc, "I1", "inForce"), "for").await; + let d = decisions(&mut c, "monnaie-libre").await; + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31))); + // Someone else has not voted yet. + let mut yvv = c.other(); + yvv.login("Yvv", false).await; + assert_eq!(decisions(&mut yvv, "monnaie-libre").await["docs"][0]["withoutMyVote"], 33); +} + +#[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, C = 1; two « for » adopt. + let doc = the_doc(&mut yvv, "essai").await; + let e3 = version(&doc, "E3", "0"); + yvv.vote(&e3, "for").await; + let v = chiara.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(32), Some(32))); + + // 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"], 33); + yvv.vote(&added, "against").await; + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32); + // 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"], 33); +} + +#[tokio::test] +async fn where_one_does_not_vote_there_is_no_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; + let d = decisions(&mut guest, "monnaie-libre").await; + assert_eq!(d["canVote"], false); + assert_eq!(d["docs"][0]["toVote"], 33); + assert!(d["docs"][0]["withoutMyVote"].is_null()); + // 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"], 33); +} diff --git a/seeds/upcoming/monnaie-libre.json b/seeds/upcoming/monnaie-libre.json new file mode 100644 index 0000000..9818c1b --- /dev/null +++ b/seeds/upcoming/monnaie-libre.json @@ -0,0 +1,40 @@ +{ + "docs": [ + { + "key": "acte-engagement-forgerons", + "title": { + "fr": "Acte d'engagement forgerons", + "en": "Smith Commitment Act", + "es": "Acta de compromiso de forjadores" + } + } + ], + "mandats": [ + { + "key": "administration-forum-duniter", + "title": { + "fr": "Administration forum Duniter", + "en": "Duniter forum administration", + "es": "Administración del foro Duniter" + } + }, + { + "key": "administration-forum-monnaie-libre", + "title": { + "fr": "Administration forum Monnaie libre", + "en": "Monnaie libre forum administration", + "es": "Administración del foro Monnaie libre" + } + } + ], + "protocoles": [ + { + "key": "runtime-upgrade", + "title": { + "fr": "Runtime upgrade — décision on-chain", + "en": "Runtime upgrade — on-chain decision", + "es": "Runtime upgrade — decisión on-chain" + } + } + ] +}