forked from yvv/decision
Les tests qui lisaient F, N ou O rejouent leurs transitions sur le seed de M8 (tests/fixtures/seed-m8) : seed_quorum_texts, seed_rules, seed_proposals, api, quorum ; ils disent en plus que ces amendements sont dépassés par le retrait (superseded_by). Les comptes suivent le document de T4 : 27 articles, quatre Propositions seed (I1, E1, E12, E2), 33 modules à voter (27 articles, le titre, l'ordre de 5 sections). modules.rs : l'ordre sous vote se rejoue sur « conclusion » (K1, K2) ; « un ordre d'un seul article n'a rien à décider » sur une base née du seed de M8 (O1). seed_amend : tests unitaires de Version::SeedProposal et de la ligne seed_proposal (put_forward). order.rs ne lit que les colonnes d'avant 0012 : sections_now ne sert qu'à la naissance des modules, et article_amend n'en garde que les articles vivants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1277 lines
63 KiB
Rust
1277 lines
63 KiB
Rust
//! End-to-end API scenarios on an in-memory database seeded like production.
|
||
|
||
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 seed production runs on 2026-11-02 (3f10a0e): texts in force, seeds a minima.
|
||
fn production_seed_dir() -> std::path::PathBuf {
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
|
||
}
|
||
|
||
fn production_config() -> Config {
|
||
let mut cfg = Config::for_tests();
|
||
cfg.seed_dir = production_seed_dir();
|
||
cfg
|
||
}
|
||
|
||
struct Client {
|
||
router: axum::Router,
|
||
cookie: Option<String>,
|
||
/// The `Set-Cookie` of the last answer, whole.
|
||
set_cookie: Option<String>,
|
||
}
|
||
|
||
impl Client {
|
||
async fn new() -> (Self, AppState) {
|
||
Self::seeded(Config::for_tests()).await
|
||
}
|
||
|
||
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
|
||
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
|
||
/// For the scenarios that need texts in force; the seed of today has none.
|
||
async fn production() -> (Self, AppState) {
|
||
Self::seeded(production_config()).await
|
||
}
|
||
|
||
async fn seeded(cfg: Config) -> (Self, 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");
|
||
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||
}
|
||
|
||
fn with(&self, cookie: Option<String>) -> Self {
|
||
Self { router: self.router.clone(), cookie, set_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();
|
||
self.set_cookie = res.headers().get(header::SET_COOKIE).map(|v| v.to_str().expect("cookie").to_owned());
|
||
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 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}");
|
||
}
|
||
}
|
||
|
||
async fn doc_id(c: &mut Client, group: &str) -> String {
|
||
let (s, v) = c.call("GET", &format!("/api/groups/{group}"), None).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
v["docs"][0]["id"].as_str().expect("doc").to_owned()
|
||
}
|
||
|
||
/// The quorum of an article set to 2, as its author — an administrator, for a text of the
|
||
/// seed — sets it before the first vote (ADR 0013). In the test group, 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, group: &str, code: &str) {
|
||
let n = sqlx::query(
|
||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)",
|
||
)
|
||
.bind(code)
|
||
.bind(group)
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("quorum")
|
||
.rows_affected();
|
||
assert_eq!(n, 1, "{group} {code}");
|
||
}
|
||
|
||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn anonymous_sees_nothing_and_csrf_is_enforced() {
|
||
let (mut c, _) = Client::new().await;
|
||
let (s, _) = c.call("GET", "/api/groups", None).await;
|
||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||
let (s, v) = c.call("GET", "/api/me", None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(v["me"].is_null());
|
||
// A mutating call without the header is refused.
|
||
let req = Request::builder()
|
||
.method("POST")
|
||
.uri("/api/auth/dev")
|
||
.header(header::CONTENT_TYPE, "application/json")
|
||
.body(Body::from(r#"{"name":"x"}"#))
|
||
.expect("request");
|
||
let res = c.router.clone().oneshot(req).await.expect("response");
|
||
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||
// No neighbour may frame the product.
|
||
assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..]));
|
||
assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn the_seed_proposals_and_the_open_proposals() {
|
||
let (mut c, _) = Client::new().await;
|
||
c.login("Chiara", false).await;
|
||
let (_, groups) = c.call("GET", "/api/groups", None).await;
|
||
let slugs: Vec<&str> =
|
||
groups["groups"].as_array().expect("groups").iter().filter_map(|g| g["slug"].as_str()).collect();
|
||
assert_eq!(slugs, ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois", "essai"]);
|
||
|
||
let id = doc_id(&mut c, "monnaie-libre").await;
|
||
let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
// No article in force: I1, E1 and E2 are seed proposals (ADR 0011), open, put forward.
|
||
assert!(doc["units"].as_array().expect("units").iter().all(|u| u["inForce"].is_null()));
|
||
let e1 = &unit(&doc, "E1")["proposals"][0];
|
||
assert_eq!((e1["seedProposal"].as_bool(), e1["status"].as_str()), (Some(true), Some("open")));
|
||
assert_eq!(e1["tally"]["for"], 0);
|
||
assert_eq!(doc["doc"]["voteFloor"], 50.0);
|
||
assert_eq!(doc["doc"]["electorate"], 5745);
|
||
// Every other article is an open proposal at zero votes, at the middle level of inertia.
|
||
let e3 = unit(&doc, "E3");
|
||
assert!(e3["inForce"].is_null());
|
||
// In the order of the seed, whatever their random identifiers.
|
||
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
|
||
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
|
||
assert_eq!(e3["inertia"], 3);
|
||
// The seed proposals start low (Yvv, 2026-11-02).
|
||
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
|
||
for code in ["I1", "E1", "E2"] {
|
||
assert_eq!(unit(&doc, code)["inertia"].as_u64(), Some(low), "{code}");
|
||
}
|
||
// Translations seeded.
|
||
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn adoption_in_the_test_group_and_publication_queued_only_where_published() {
|
||
let (mut a, state) = Client::new().await;
|
||
a.login("Yvv", false).await;
|
||
let mut b = a.with(None);
|
||
b.login("Chiara", false).await;
|
||
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
|
||
// (ADR 0013): one voice passes in the test group.
|
||
let id = doc_id(&mut a, "essai").await;
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e3 = unit(&doc, "E3");
|
||
assert_eq!(e3["proposals"][0]["evaluation"]["quorum"], 1);
|
||
let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||
let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert_eq!(v["entered"][0]["code"], "E3", "one voice in the test group");
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e3 = unit(&doc, "E3");
|
||
assert_eq!(e3["inForce"]["id"], p.as_str());
|
||
assert!(e3["inForce"]["enteredAt"].is_string());
|
||
// The test group publishes nothing.
|
||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?")
|
||
.bind(&id)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(n, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||
let (mut a, _) = Client::production().await;
|
||
a.login("Yvv", false).await;
|
||
let mut b = a.with(None);
|
||
b.login("Chiara", false).await;
|
||
let id = doc_id(&mut a, "essai").await;
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e1 = unit(&doc, "E1");
|
||
let (uid, seed_id) =
|
||
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||
let (s, v) = a
|
||
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
|
||
"unitId": uid, "title": "Unicité du compte",
|
||
"body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true,
|
||
})))
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
let rival = v["id"].as_str().expect("id").to_owned();
|
||
// The quorum 1 of the test group (ADR 0013): its *for* against nothing replaces the seed.
|
||
let (_, v) = a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert_eq!(v["entered"][0]["kind"], "text");
|
||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert!(v["entered"].as_array().expect("e").is_empty(), "already in force");
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e1 = unit(&doc, "E1");
|
||
assert_eq!(e1["inForce"]["id"], rival.as_str());
|
||
assert!(
|
||
e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editing_is_not_voting() {
|
||
let (mut a, state) = Client::production().await;
|
||
// An administrator of trustinWallet answers for the starting texts (no author).
|
||
a.login("Yvv", true).await;
|
||
let mut b = a.with(None);
|
||
b.login("Chiara", false).await;
|
||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||
let pid = e1["id"].as_str().expect("id").to_owned();
|
||
// Anyone may give the text in force its typographic apostrophes, without a vote:
|
||
// since 2026-10-31 it waits for validation (tests/typos.rs).
|
||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||
assert!(fixed.contains("m\u{2019}engage"));
|
||
let (s, v) =
|
||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["edit"], "typo_pending");
|
||
// A change of meaning in force is refused: it goes through a proposal.
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{pid}"),
|
||
Some(json!({ "title": e1["title"], "body": "Tout autre chose." })),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::CONFLICT);
|
||
assert_eq!(v["error"], "substantive_in_force");
|
||
// Flipping a relation in a seed text is not a typo, even with the same letters.
|
||
let k1 = unit(&doc, "K1")["proposals"][0].clone();
|
||
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
|
||
Some(json!({ "title": k1["title"], "body": flipped })),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||
assert_eq!(v["error"], "substantive_not_author");
|
||
// One word turned around in a text in force, and an invisible bidi override: refused.
|
||
let e2 = unit(&doc, "E2")["inForce"].clone();
|
||
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
||
let body = e2["body"].as_str().expect("body").to_owned();
|
||
assert!(body.contains("ces deux présents"));
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{e2_id}"),
|
||
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{e2_id}"),
|
||
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||
// A title is one line.
|
||
let (s, v) = b
|
||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
|
||
// The title never absorbs the start of the body.
|
||
let title = e2["title"].as_str().expect("title");
|
||
let (head, rest) = body.split_once(' ').expect("two words");
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{e2_id}"),
|
||
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
||
// Typo fixes are measured against the voted text: slips never add up (applied at
|
||
// once here: the administrator answers for a starting text).
|
||
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
||
let (_, v) = a
|
||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
||
.await;
|
||
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
||
let (s, v) = a
|
||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
||
.await;
|
||
assert_eq!(
|
||
(s, v["error"].as_str()),
|
||
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
||
"a second slip on top is not"
|
||
);
|
||
let (_, v) = a.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||
assert_eq!(v["edit"], "typo", "back to the voted text");
|
||
// An address is compared exactly.
|
||
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
||
let x11_body = x11["body"].as_str().expect("body");
|
||
assert!(x11_body.contains("git.duniter.org"));
|
||
let (s, v) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
|
||
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
|
||
|
||
// A member proposal: typo keeps votes, substance asks to reconfirm (in the test group:
|
||
// Monnaie libre votes on the chain, tests/ballots.rs).
|
||
let essai = doc_id(&mut a, "essai").await;
|
||
let (_, essai_doc) = a.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||
let uid = unit(&essai_doc, "E2")["id"].as_str().expect("u").to_owned();
|
||
// Votes stay on it while it is edited: one voice of two does not make it pass.
|
||
two_voices(&state, "essai", "E2").await;
|
||
let (_, v) = a
|
||
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
|
||
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
|
||
})))
|
||
.await;
|
||
let p = v["id"].as_str().expect("id").to_owned();
|
||
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
// A misspelt word fixed: the vote stays.
|
||
let (_, v) = a
|
||
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.",
|
||
})))
|
||
.await;
|
||
assert_eq!(v["edit"], "typo");
|
||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
||
// Words added: a new revision, the vote waits for reconfirmation.
|
||
let (_, v) = a
|
||
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.",
|
||
})))
|
||
.await;
|
||
assert_eq!(v["edit"], "substantive");
|
||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||
assert_eq!(d["proposal"]["myVoteStale"], true);
|
||
assert_eq!(d["proposal"]["tally"]["for"], 0);
|
||
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||
assert_eq!(d["proposal"]["myVoteStale"], false);
|
||
assert_eq!(d["proposal"]["tally"]["for"], 1);
|
||
assert_eq!(d["history"].as_array().expect("h").len(), 3);
|
||
// Someone else cannot change the substance.
|
||
let (s, _) = b
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{p}"),
|
||
Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::CONFLICT);
|
||
}
|
||
|
||
/// Replaces « a contradiction signal blocks entering into force » (2026-10-21):
|
||
/// since 2026-10-31 an objection makes the latest arrival non-votable (ADR 0010;
|
||
/// the full cycle: tests/objections.rs).
|
||
#[tokio::test]
|
||
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
|
||
let (mut a, state) = Client::production().await;
|
||
a.login("Yvv", false).await;
|
||
let mut b = a.with(None);
|
||
b.login("Chiara", false).await;
|
||
two_voices(&state, "essai", "K1").await;
|
||
let id = doc_id(&mut a, "essai").await;
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
let (s, v) = b
|
||
.call(
|
||
"POST",
|
||
&format!("/api/proposals/{k1}/objections"),
|
||
Some(json!({ "otherId": e2, "kind": "contredit", "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["heldId"], k1.as_str());
|
||
let oid = v["id"].as_str().expect("id").to_owned();
|
||
let (s, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")), "not votable while it stands");
|
||
// Only its author lifts it (or an administrator); then the second voice lets the text in.
|
||
let (s, _) = a.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||
let (_, v) = b.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two");
|
||
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert_eq!(v["entered"][0]["code"], "K1");
|
||
}
|
||
|
||
/// Replaces « inertia change is voted one level up » (2026-10-21): since
|
||
/// 2026-10-30 a change of inertia inherits the level of its article (ADR 0007).
|
||
#[tokio::test]
|
||
async fn inertia_change_is_voted_at_the_level_of_the_article() {
|
||
let (mut a, _) = Client::new().await;
|
||
a.login("Yvv", false).await;
|
||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
|
||
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": 5 }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
let p = v["id"].as_str().expect("id").to_owned();
|
||
// The word of before reads as its level: the same proposal, not a second one.
|
||
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await;
|
||
assert_eq!(again["id"], p.as_str());
|
||
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
|
||
assert_eq!(
|
||
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
|
||
(Some(5), Some(low))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn previews_and_assistant() {
|
||
let (mut a, _) = Client::new().await;
|
||
a.login("Yvv", false).await;
|
||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||
// 27 articles since lot T4 (the articles of operation out of the document, ADR 0014; E12 in).
|
||
assert_eq!(v["units"].as_array().expect("u").len(), 27);
|
||
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
|
||
assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes");
|
||
let (_, v) = a
|
||
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
|
||
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
|
||
})))
|
||
.await;
|
||
let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect();
|
||
assert!(kinds.contains(&"split"), "{v}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn split_a_draft() {
|
||
let (mut a, _) = Client::new().await;
|
||
a.login("Yvv", false).await;
|
||
let id = doc_id(&mut a, "essai").await;
|
||
let (_, v) = a
|
||
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
|
||
.await;
|
||
let p = v["id"].as_str().expect("id").to_owned();
|
||
let (s, v) = a
|
||
.call(
|
||
"POST",
|
||
&format!("/api/proposals/{p}/split"),
|
||
Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||
let (s, v) =
|
||
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["drafts"].as_array().expect("d").len(), 2);
|
||
let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn access_management_is_for_administrators_only() {
|
||
let (mut user, _) = Client::new().await;
|
||
user.login("Chiara", false).await;
|
||
let (s, _) = user.call("GET", "/api/access", None).await;
|
||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||
let mut admin = user.with(None);
|
||
admin.login("Yvv", true).await;
|
||
let (s, v) =
|
||
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
let link = v["link"].as_str().expect("link").to_owned();
|
||
let token = link.split('#').nth(1).expect("token").to_owned();
|
||
let mut invited = user.with(None);
|
||
let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["me"]["kind"], "invite");
|
||
let (_, g) = invited.call("GET", "/api/groups", None).await;
|
||
let open: Vec<&str> = g["groups"]
|
||
.as_array()
|
||
.expect("g")
|
||
.iter()
|
||
.filter(|g| g["open"] == true)
|
||
.filter_map(|g| g["slug"].as_str())
|
||
.collect();
|
||
assert_eq!(open, ["essai"]);
|
||
// Single use.
|
||
let mut again = user.with(None);
|
||
let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||
// Monnaie libre is seen but closed to an invited identity.
|
||
let (s, _) = invited.call("GET", "/api/groups/axiom", None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||
// « Publier maintenant » is for administrators, and needs a configured publication.
|
||
let (s, _) = user.call("POST", "/api/access/publication/retry", None).await;
|
||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||
let (s, v) = admin.call("POST", "/api/access/publication/retry", None).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_session_lasts_until_it_is_closed() {
|
||
const FOUR_HUNDRED_DAYS: &str = "Max-Age=34560000";
|
||
let (mut chiara, state) = Client::new().await;
|
||
chiara.login("Chiara", false).await;
|
||
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
|
||
// Renewed once a day at most: a visit right after changes nothing.
|
||
chiara.call("GET", "/api/me", None).await;
|
||
assert_eq!(chiara.set_cookie, None);
|
||
// Ten days left: the next visit starts the 400 days again, here and in the browser.
|
||
sqlx::query("UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+10 days')")
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("age the session");
|
||
sqlx::query("UPDATE identities SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-10 days')")
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("and the last visit");
|
||
let (_, v) = chiara.call("GET", "/api/me", None).await;
|
||
assert_eq!(v["me"]["displayName"], "Chiara");
|
||
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
|
||
let far: i64 = sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM sessions WHERE expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+399 days')",
|
||
)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(far, 1);
|
||
let seen: i64 = sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM identities WHERE id = 'dev:chiara' AND last_seen_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 minute')",
|
||
)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(seen, 1, "the renewal says when the person was last seen");
|
||
// A session already over does not come back.
|
||
let mut old = chiara.with(None);
|
||
old.login("Chiara", false).await;
|
||
let old_cookie = old.cookie.clone();
|
||
sqlx::query(
|
||
"UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')
|
||
WHERE rowid = (SELECT MAX(rowid) FROM sessions)",
|
||
)
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("end the session");
|
||
let (_, v) = old.call("GET", "/api/me", None).await;
|
||
assert_eq!((v["me"].clone(), old.set_cookie.clone()), (Value::Null, None));
|
||
assert_eq!(old.cookie, old_cookie);
|
||
|
||
// Until an administrator closes them: a lost device, a person who left.
|
||
let mut phone = chiara.with(None);
|
||
phone.login("Chiara", false).await;
|
||
let (s, _) = chiara.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
|
||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||
let mut admin = chiara.with(None);
|
||
admin.login("Yvv", true).await;
|
||
let sessions_of = |ov: &Value, id: &str| {
|
||
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
|
||
};
|
||
// A sign-in link sent to the lost device goes with the sessions.
|
||
sqlx::query(
|
||
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
|
||
VALUES ('waiting', 'Chiara', 0, '[]', 'dev:yvv', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+14 days'), 'dev:chiara')",
|
||
)
|
||
.execute(&state.db)
|
||
.await
|
||
.expect("a sign-in link waiting");
|
||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||
assert_eq!(sessions_of(&ov, "dev:chiara"), 2, "the session already over is not counted");
|
||
assert_eq!(sessions_of(&ov, "dev:yvv"), 0, "the session this page is read with is not counted");
|
||
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
|
||
assert_eq!((s, v["closed"].as_u64(), v["links"].as_u64()), (StatusCode::OK, Some(2), Some(1)));
|
||
for c in [&mut chiara, &mut phone] {
|
||
let (_, v) = c.call("GET", "/api/me", None).await;
|
||
assert_eq!(v["me"], Value::Null);
|
||
}
|
||
let waiting: i64 = sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM invitations WHERE token_hash = 'waiting' AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||
)
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(waiting, 0);
|
||
// An administrator's own: the others close, this one stays open.
|
||
let mut laptop = admin.with(None);
|
||
laptop.login("Yvv", true).await;
|
||
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:yvv/sessions", None).await;
|
||
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(1)));
|
||
let (_, v) = admin.call("GET", "/api/me", None).await;
|
||
assert_eq!(v["me"]["displayName"], "Yvv");
|
||
let (_, v) = laptop.call("GET", "/api/me", None).await;
|
||
assert_eq!(v["me"], Value::Null);
|
||
let (s, _) = admin.call("DELETE", "/api/access/identities/nobody/sessions", None).await;
|
||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_team_link_makes_one_identity_per_opening() {
|
||
let (mut admin, _) = Client::new().await;
|
||
admin.login("Yvv", true).await;
|
||
let mut user = admin.with(None);
|
||
user.login("Chiara", false).await;
|
||
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
|
||
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
|
||
for (body, code) in [
|
||
(team(json!([]), 30, 2), "groups"),
|
||
(team(json!(["nope"]), 30, 2), "groups"),
|
||
(team(json!(["essai"]), 0, 2), "team_limits"),
|
||
(team(json!(["essai"]), 30, 501), "team_limits"),
|
||
] {
|
||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
|
||
}
|
||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
let link = v["link"].as_str().expect("link").to_owned();
|
||
assert!(link.contains("/equipe#"), "{link}");
|
||
let token = link.split('#').nth(1).expect("token").to_owned();
|
||
|
||
let mut first = admin.with(None);
|
||
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["label"], "Équipe dev");
|
||
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
|
||
assert_eq!(v["signedInAs"], Value::Null);
|
||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
|
||
assert_eq!(v["me"]["displayName"], "Luna");
|
||
let luna = v["me"]["id"].as_str().expect("id").to_owned();
|
||
let (_, g) = first.call("GET", "/api/groups", None).await;
|
||
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
|
||
// What an invited person writes says so next to their name.
|
||
let essai = doc_id(&mut first, "essai").await;
|
||
let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||
let (_, v) = first
|
||
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
|
||
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true,
|
||
})))
|
||
.await;
|
||
let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await;
|
||
assert_eq!(
|
||
(d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()),
|
||
(Some("Luna"), Some(true))
|
||
);
|
||
assert_eq!(d["history"][0]["authorInvited"], true);
|
||
|
||
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||
let mut second = admin.with(None);
|
||
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
|
||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
|
||
}
|
||
// Nor under the name an invitation still waits for.
|
||
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await;
|
||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||
// Look-alike letters from other alphabets are not a name.
|
||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
||
// Two openings allowed: the third is refused.
|
||
let mut third = admin.with(None);
|
||
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
|
||
|
||
// Someone signed in keeps their identity and receives the groups.
|
||
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
|
||
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||
assert_eq!(v["signedInAs"], "Luna");
|
||
for _ in 0..3 {
|
||
// Back again with the session open: no place taken once the groups are there.
|
||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||
}
|
||
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
|
||
// Administrators see the links still open and where each identity came from; a link revokes.
|
||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||
let links = ov["teamLinks"].as_array().expect("links");
|
||
assert_eq!(links.len(), 2, "{ov}");
|
||
assert!(
|
||
ov["identities"]
|
||
.as_array()
|
||
.expect("i")
|
||
.iter()
|
||
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
|
||
);
|
||
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
|
||
let (s, _) =
|
||
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let mut late = admin.with(None);
|
||
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
|
||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn in_monnaie_libre_an_invitation_votes_on_the_server_only_in_simulation() {
|
||
let (mut admin, _) = Client::new().await;
|
||
admin.login("Yvv", true).await;
|
||
let (_, v) = admin
|
||
.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] })))
|
||
.await;
|
||
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||
let mut invited = admin.with(None);
|
||
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
// She reads the text and may propose; her vote goes through the chain, for the Ğ1
|
||
// identity of her wallet (tests/ballots.rs) — closed here, without a vote account: the
|
||
// vote is simulated, for the identity of her session, marked as such (ADR 0012).
|
||
let id = doc_id(&mut invited, "monnaie-libre").await;
|
||
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert_eq!(doc["canVote"], true);
|
||
assert_eq!((doc["vote"]["way"].as_str(), doc["vote"]["simulated"].as_bool()), (Some("server"), Some(true)));
|
||
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||
assert_eq!(s, StatusCode::OK, "{v}");
|
||
assert_eq!(v["simulated"], true);
|
||
let (_, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let voted = &unit(&doc, "E3")["proposals"][0];
|
||
assert_eq!((voted["myVote"].as_str(), voted["myVoteState"].as_str()), (Some("for"), Some("simulated")));
|
||
// In the test group, every member votes.
|
||
let essai = doc_id(&mut admin, "essai").await;
|
||
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||
assert_eq!(d["canVote"], true);
|
||
// A new link may only target an invitation identity, never another kind.
|
||
let (_, me) = admin.call("GET", "/api/me", None).await;
|
||
let dev_id = me["me"]["id"].as_str().expect("id").to_owned();
|
||
let (s, _) =
|
||
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await;
|
||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn feedback_reaches_administrators() {
|
||
let (mut user, _) = Client::new().await;
|
||
user.login("Chiara", false).await;
|
||
let (s, _) = user
|
||
.call("POST", "/api/feedback", Some(json!({ "route": "/g/essai", "body": "Le bouton est trop discret." })))
|
||
.await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let mut admin = user.with(None);
|
||
admin.login("Yvv", true).await;
|
||
let (_, v) = admin.call("GET", "/api/access/feedback", None).await;
|
||
assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret.");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||
use std::process::Command;
|
||
let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("dir");
|
||
let remote = dir.join("textes.git");
|
||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||
let mut cfg = production_config();
|
||
cfg.data_dir = dir.join("data");
|
||
cfg.publish = Some(ld_server::config::Publish {
|
||
remote: remote.display().to_string(),
|
||
branch: "main".into(),
|
||
key: dir.join("data/keys/publish_ed25519"),
|
||
author_name: "libreDecision".into(),
|
||
author_email: "decision@example.org".into(),
|
||
without_articles: false,
|
||
simulated: false,
|
||
});
|
||
let db = open_db("sqlite::memory:").await.expect("db");
|
||
let state = AppState::new(cfg, db).await.expect("state");
|
||
seed::ensure(&state).await.expect("seed");
|
||
ld_server::publish::pass(&state).await.expect("pass");
|
||
let show = |path: &str| {
|
||
let out = Command::new("git")
|
||
.arg("-C")
|
||
.arg(&remote)
|
||
.args(["show", &format!("main:{path}")])
|
||
.output()
|
||
.expect("git show");
|
||
String::from_utf8(out.stdout).expect("utf8")
|
||
};
|
||
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
|
||
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
|
||
assert!(fr.contains("## Raison d'être") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
|
||
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
|
||
let json: Value =
|
||
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
|
||
assert_eq!(json["units"].as_array().expect("units").len(), 3);
|
||
assert_eq!(json["units"][0]["status"], "seed-minimal");
|
||
// Levels of inertia are published as numbers, 1 to 5.
|
||
let levels: Vec<Option<u64>> =
|
||
json["units"].as_array().expect("units").iter().map(|u| u["inertia"].as_u64()).collect();
|
||
assert_eq!(levels, [Some(3), Some(4), Some(4)]);
|
||
assert!(show("README.md").contains("libreDecision"));
|
||
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(done, 0);
|
||
// A restart queues the document again; nothing changed, so nothing is committed.
|
||
let head = || {
|
||
let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse");
|
||
String::from_utf8(out.stdout).expect("utf8")
|
||
};
|
||
let before = head();
|
||
ld_server::publish::resync(&state).await.expect("resync");
|
||
ld_server::publish::pass(&state).await.expect("pass");
|
||
assert_eq!(head(), before, "an unchanged document produces no commit");
|
||
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||
.fetch_one(&state.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!(pending, 0);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives_it() {
|
||
// Tenth independent review (850f9c4), 2026-10-21: a first push refused (a key
|
||
// without write access) left the commit in the server's clone, and the next pass,
|
||
// finding nothing to commit, marked the queue done with the repository still empty.
|
||
use std::process::Command;
|
||
let dir = std::env::temp_dir().join(format!("ld-pub-retry-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("dir");
|
||
let bare = |name: &str| {
|
||
let path = dir.join(name);
|
||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&path).status().expect("git").success());
|
||
path
|
||
};
|
||
let config = |remote: &std::path::Path| {
|
||
let mut cfg = production_config();
|
||
cfg.data_dir = dir.join("data");
|
||
cfg.publish = Some(ld_server::config::Publish {
|
||
remote: remote.display().to_string(),
|
||
branch: "main".into(),
|
||
key: dir.join("data/keys/publish_ed25519"),
|
||
author_name: "libreDecision".into(),
|
||
author_email: "decision@example.org".into(),
|
||
without_articles: false,
|
||
simulated: false,
|
||
});
|
||
cfg
|
||
};
|
||
let has_main = |remote: &std::path::Path| {
|
||
Command::new("git")
|
||
.arg("-C")
|
||
.arg(remote)
|
||
.args(["rev-parse", "--verify", "-q", "main"])
|
||
.output()
|
||
.expect("git")
|
||
.status
|
||
.success()
|
||
};
|
||
let pending = |db: &sqlx::SqlitePool| {
|
||
let db = db.clone();
|
||
async move {
|
||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||
.fetch_one(&db)
|
||
.await
|
||
.expect("count")
|
||
}
|
||
};
|
||
|
||
let first = bare("first.git");
|
||
let hook = first.join("hooks/pre-receive");
|
||
std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook");
|
||
assert!(Command::new("chmod").arg("755").arg(&hook).status().expect("chmod").success());
|
||
let db = open_db("sqlite::memory:").await.expect("db");
|
||
let state = AppState::new(config(&first), db.clone()).await.expect("state");
|
||
seed::ensure(&state).await.expect("seed");
|
||
ld_server::publish::pass(&state).await.expect("pass");
|
||
assert!(!has_main(&first), "the push was refused");
|
||
assert_eq!(pending(&db).await, 1, "the document still waits");
|
||
|
||
// The key gets its write access: the next start sends what waited, at once.
|
||
std::fs::remove_file(&hook).expect("unhook");
|
||
ld_server::publish::resync(&state).await.expect("resync");
|
||
ld_server::publish::pass(&state).await.expect("pass");
|
||
assert!(has_main(&first), "the commit kept in the clone went out");
|
||
assert_eq!(pending(&db).await, 0);
|
||
|
||
// A change of remote to a new, empty repository: it receives the history.
|
||
let second = bare("second.git");
|
||
let moved = AppState::new(config(&second), db.clone()).await.expect("state");
|
||
ld_server::publish::resync(&moved).await.expect("resync");
|
||
ld_server::publish::pass(&moved).await.expect("pass");
|
||
assert!(has_main(&second), "the new repository received the texts");
|
||
assert_eq!(pending(&db).await, 0);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// I1 « Raison d'être » (Yvv's review, 2026-10-30): the seed and its amendment
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The Préambule every base seeded before 2026-10-30 holds (the seed file had not
|
||
/// changed since 67ddee2, the first commit of the v3).
|
||
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 RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification";
|
||
|
||
/// The Raison d'être since lot T4 (Yvv, 03/11): its beginning.
|
||
const RAISON_D_ETRE_T4: &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";
|
||
|
||
fn today_seed() -> Value {
|
||
let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||
}
|
||
|
||
/// The seed of M8 (de1b7eb): the last that gave I1 the Raison d'être of 30/10.
|
||
fn m8_seed() -> Value {
|
||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("tests/fixtures/seed-m8/acte-engagement-certification.json");
|
||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||
}
|
||
|
||
/// The seed production runs, I1 in force: the amendment of 30/10 is replayed on it.
|
||
fn production_seed() -> Value {
|
||
let path = production_seed_dir().join("acte-engagement-certification.json");
|
||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||
}
|
||
|
||
/// The seed as it was before 2026-10-30: the Préambule in force.
|
||
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("dir");
|
||
let mut seed = production_seed();
|
||
let i1 = seed["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);
|
||
}
|
||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||
dir
|
||
}
|
||
|
||
/// A base seeded with the Préambule, as production was: groups and documents, by the
|
||
/// code of the time (no amendment).
|
||
async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppState) {
|
||
cfg.seed_dir = first_seed_dir(tag);
|
||
let db = open_db("sqlite::memory:").await.expect("db");
|
||
let state = AppState::new(cfg, db).await.expect("state");
|
||
seed::documents(&state).await.expect("first seed");
|
||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||
}
|
||
|
||
/// The same base, started again with the seed of production (and its amendments): the
|
||
/// seed of today then puts I1 forward (tests/seed_proposals.rs).
|
||
async fn restart(before: &AppState, mut cfg: Config) -> (Client, AppState) {
|
||
cfg.seed_dir = production_seed_dir();
|
||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||
seed::ensure(&state).await.expect("seed");
|
||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||
}
|
||
|
||
/// Each document's decision on the amendment of I1: (group, outcome, reason, at).
|
||
async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> {
|
||
sqlx::query_as(
|
||
"SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
|
||
WHERE s.amendment_id = '2026-10-30-i1-raison-d-etre' ORDER BY d.group_slug",
|
||
)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.expect("decisions")
|
||
}
|
||
|
||
fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &str, Option<&str>)> {
|
||
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
|
||
}
|
||
|
||
/// I1 as it reads: its text in force, else its seed proposal (ADR 0011).
|
||
async fn i1_of(c: &mut Client, group: &str) -> Value {
|
||
let id = doc_id(c, group).await;
|
||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let u = unit(&doc, "I1");
|
||
if u["inForce"].is_null() { u["proposals"][0].clone() } else { u["inForce"].clone() }
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_new_base_starts_with_the_raison_d_etre() {
|
||
let (mut c, state) = Client::new().await;
|
||
c.login("Chiara", false).await;
|
||
for group in ["monnaie-libre", "essai"] {
|
||
let i1 = i1_of(&mut c, group).await;
|
||
assert_eq!(i1["title"], "Raison d'être");
|
||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE_T4), "{i1}");
|
||
assert_eq!((i1["revision"].as_i64(), i1["seedProposal"].as_bool()), (Some(1), Some(true)));
|
||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||
let tr = &i1["translations"][lang];
|
||
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
|
||
}
|
||
}
|
||
// Born with the text of lot T4, put forward: nothing to replace, and it is said once; the
|
||
// amendment of 30/10, superseded, decides nothing on a new base.
|
||
assert!(decisions(&state).await.is_empty());
|
||
let t4: Vec<(String, String)> = sqlx::query_as(
|
||
"SELECT d.group_slug, s.outcome FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
|
||
WHERE s.amendment_id = '2026-11-03-i1-amorce' ORDER BY d.group_slug",
|
||
)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.expect("decisions");
|
||
assert_eq!(t4, [("essai".to_owned(), "present".to_owned()), ("monnaie-libre".to_owned(), "present".to_owned())]);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
|
||
let a = ld_server::seed_amend::AMENDMENTS.iter().find(|a| a.unit == "I1").expect("I1 amendment");
|
||
assert_eq!(a.doc, "acte-engagement-certification");
|
||
let (_, title, body) = PREAMBULE[0];
|
||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold");
|
||
let i1_of = |seed: &Value| -> (String, String, Value) {
|
||
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
|
||
let v = &i1["versions"][0];
|
||
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
|
||
(title.to_owned(), body.to_owned(), v.clone())
|
||
};
|
||
// Until M8 the starting text is a seed proposal (ADR 0011); the text is the same.
|
||
let (title, body, v) = i1_of(&m8_seed());
|
||
assert_eq!((v["status"].as_str(), v["seedProposal"].as_bool()), (Some("open"), Some(true)));
|
||
assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), a.to, "the text of the seed of M8");
|
||
// Lot T4 replaces it, after it only (Yvv's text of 03/11): this one decides nothing more.
|
||
let t4 = ld_server::seed_amend::AMENDMENTS.iter().find(|x| x.id == "2026-11-03-i1-amorce").expect("lot T4");
|
||
assert_eq!((t4.from, t4.follows, a.superseded_by), (a.to, Some(a.id), Some(t4.id)));
|
||
let (title, body, _) = i1_of(&today_seed());
|
||
assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), t4.to, "the text of the seed today");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_it() {
|
||
use std::process::Command;
|
||
let dir = std::env::temp_dir().join(format!("ld-amend-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("dir");
|
||
let remote = dir.join("textes.git");
|
||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||
let mut cfg = production_config();
|
||
cfg.data_dir = dir.join("data");
|
||
cfg.publish = Some(ld_server::config::Publish {
|
||
remote: remote.display().to_string(),
|
||
branch: "main".into(),
|
||
key: dir.join("data/keys/publish_ed25519"),
|
||
author_name: "libreDecision".into(),
|
||
author_email: "decision@example.org".into(),
|
||
without_articles: false,
|
||
simulated: false,
|
||
});
|
||
let git = |args: &[&str]| {
|
||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||
String::from_utf8(out.stdout).expect("utf8")
|
||
};
|
||
let show = |file: &str| git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]);
|
||
|
||
// Production as it is: the Préambule, published.
|
||
let (_, before) = base_with_the_preambule("applied", cfg.clone()).await;
|
||
ld_server::publish::pass(&before).await.expect("pass");
|
||
assert!(show("fr.md").contains("## Préambule"));
|
||
|
||
// The new version starts: the untouched Préambule becomes the Raison d'être, in both groups.
|
||
let (mut c, state) = restart(&before, cfg.clone()).await;
|
||
let first = decisions(&state).await;
|
||
assert_eq!(outcomes(&first), [("essai", "applied", None), ("monnaie-libre", "applied", None)]);
|
||
c.login("Chiara", false).await;
|
||
for group in ["monnaie-libre", "essai"] {
|
||
let i1 = i1_of(&mut c, group).await;
|
||
assert_eq!(i1["title"], "Raison d'être", "{group}");
|
||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE));
|
||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(2), Some(true)));
|
||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||
let tr = &i1["translations"][lang];
|
||
assert_eq!(
|
||
(tr["title"].as_str(), tr["stale"].as_bool(), tr["origin"].as_str()),
|
||
(Some(title), Some(false), Some("seed")),
|
||
"{group} {lang}"
|
||
);
|
||
}
|
||
// The article's history says so, with what changed.
|
||
let (_, d) = c.call("GET", &format!("/api/proposals/{}", i1["id"].as_str().expect("id")), None).await;
|
||
let history = d["history"].as_array().expect("history");
|
||
assert_eq!(history.len(), 2, "{d}");
|
||
assert_eq!((history[1]["kind"].as_str(), history[1]["author"].as_str()), (Some("amendment"), None));
|
||
let parts = history[1]["diff"].as_array().expect("diff");
|
||
let said =
|
||
|op: &str| parts.iter().filter(|p| p["op"] == op).filter_map(|p| p["text"].as_str()).collect::<String>();
|
||
assert!(said("removed").contains("Préambule") && said("added").contains("Raison"), "{parts:?}");
|
||
}
|
||
// Republished where the document publishes — never from the test group.
|
||
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 WHERE o.done_at IS NULL",
|
||
)
|
||
.fetch_all(&state.db)
|
||
.await
|
||
.expect("outbox");
|
||
assert_eq!(queued, [("monnaie-libre".to_owned(), "texte de départ remplacé : I1".to_owned())]);
|
||
ld_server::publish::pass(&state).await.expect("pass");
|
||
let fr = show("fr.md");
|
||
assert!(fr.contains("## Raison d'être") && !fr.contains("Préambule"), "{fr}");
|
||
assert!(show("en.md").contains("## Purpose") && show("es.md").contains("## Razón de ser"));
|
||
assert!(git(&["log", "-1", "--format=%B", "main"]).contains("texte de départ remplacé : I1"));
|
||
|
||
// Once only: the next start changes nothing.
|
||
let (mut c, again) = restart(&state, cfg.clone()).await;
|
||
assert_eq!(decisions(&again).await, first);
|
||
c.login("Chiara", false).await;
|
||
let i1 = i1_of(&mut c, "monnaie-libre").await;
|
||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||
let revisions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revisions WHERE proposal_id = ?")
|
||
.bind(&pid)
|
||
.fetch_one(&again.db)
|
||
.await
|
||
.expect("count");
|
||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||
|
||
// From now on a typo is measured against the new text (written by a member, it
|
||
// waits for an administrator), and the Préambule is a change of meaning.
|
||
let body = i1["body"].as_str().expect("body");
|
||
let (s, v) = c
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{pid}"),
|
||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo_pending")), "{v}");
|
||
let (s, v) = c
|
||
.call(
|
||
"PATCH",
|
||
&format!("/api/proposals/{pid}"),
|
||
Some(json!({ "title": PREAMBULE[0].1, "body": PREAMBULE[0].2 })),
|
||
)
|
||
.await;
|
||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Touch {
|
||
Vote,
|
||
Typo,
|
||
Translation,
|
||
Signal,
|
||
Text,
|
||
}
|
||
for (touch, reason) in [
|
||
(Touch::Vote, "votes"),
|
||
(Touch::Typo, "corrected"),
|
||
(Touch::Translation, "translation_edited"),
|
||
(Touch::Signal, "signalled"),
|
||
(Touch::Text, "text_differs"),
|
||
] {
|
||
let tag = format!("{touch:?}").to_lowercase();
|
||
let (mut c, before) = base_with_the_preambule(&tag, Config::for_tests()).await;
|
||
c.login("Yvv", false).await;
|
||
// Touched in the test group only: Monnaie libre stays intact.
|
||
let id = doc_id(&mut c, "essai").await;
|
||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||
let (s, v) = match touch {
|
||
// A vote that does not confirm it (the quorum 2: one voice is not enough), so
|
||
// the refusal says the vote itself, not a confirmation.
|
||
Touch::Vote => {
|
||
two_voices(&before, "essai", "I1").await;
|
||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||
}
|
||
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
|
||
Touch::Typo => {
|
||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||
.await
|
||
}
|
||
Touch::Translation => {
|
||
let en = &i1["translations"]["en"];
|
||
let body =
|
||
en["body"].as_str().expect("en").replace("This commitment act", "The present commitment act");
|
||
c.call(
|
||
"PUT",
|
||
&format!("/api/proposals/{pid}/translations/en"),
|
||
Some(json!({ "title": en["title"], "body": body })),
|
||
)
|
||
.await
|
||
}
|
||
Touch::Signal => {
|
||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||
c.call(
|
||
"POST",
|
||
&format!("/api/proposals/{k1}/objections"),
|
||
Some(json!({ "otherId": pid, "kind": "contredit", "reason": "Ces règles contredisent le préambule." })),
|
||
)
|
||
.await
|
||
}
|
||
Touch::Text => {
|
||
sqlx::query("UPDATE proposals SET body = body || ' Fin.' WHERE id = ?")
|
||
.bind(&pid)
|
||
.execute(&before.db)
|
||
.await
|
||
.expect("another text");
|
||
(StatusCode::OK, Value::Null)
|
||
}
|
||
};
|
||
assert_eq!(s, StatusCode::OK, "{touch:?}: {v}");
|
||
|
||
let (mut c, state) = restart(&before, Config::for_tests()).await;
|
||
let first = decisions(&state).await;
|
||
assert_eq!(
|
||
outcomes(&first),
|
||
[("essai", "refused", Some(reason)), ("monnaie-libre", "applied", None)],
|
||
"{touch:?}"
|
||
);
|
||
c.login("Yvv", false).await;
|
||
let i1 = i1_of(&mut c, "essai").await;
|
||
assert_eq!(i1["title"], "Préambule", "{touch:?}");
|
||
assert_eq!(i1["revision"], 1, "{touch:?}");
|
||
let (_, d) = c.call("GET", &format!("/api/proposals/{pid}"), None).await;
|
||
assert!(d["history"].as_array().expect("h").iter().all(|h| h["kind"] != "amendment"), "{touch:?}");
|
||
// Decided once: a later start does not look again.
|
||
let (_, again) = restart(&state, Config::for_tests()).await;
|
||
assert_eq!(decisions(&again).await, first, "{touch:?}");
|
||
}
|
||
}
|