forked from yvv/decision
v3 : Décisions et « À venir » côté serveur
- 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/<groupe>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<bool> = Some(false);
|
||||
const ADOPTED: Option<bool> = Some(true);
|
||||
|
||||
fn m(in_force: Option<bool>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
}
|
||||
|
||||
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<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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/<group>.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<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub mandats: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub protocoles: Vec<UpcomingItem>,
|
||||
}
|
||||
|
||||
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<String, GroupUpcoming>);
|
||||
|
||||
impl Upcoming {
|
||||
/// Reads `<seed_dir>/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<Self> {
|
||||
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<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<Vec<Module>> {
|
||||
#[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<Module> = Vec::new();
|
||||
let mut current: Option<String> = 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<Upcoming> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<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()
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user