forked from yvv/decision
2202 lines
105 KiB
Rust
2202 lines
105 KiB
Rust
//! The vote, grouped, and the vote on the Ğ1 chain (ADR 0008): sealed by the
|
|
//! server, confirmed by reading an indexer — here a small simulated indexer that
|
|
//! answers the GraphQL query in the shape of the real one
|
|
//! (`tests/fixtures/indexer-merci.json`, relevé on indexer.duniter.org).
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::State;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::post;
|
|
use axum::{Json, Router};
|
|
use http_body_util::BodyExt;
|
|
use ld_engine::address::{self, G1_PREFIX};
|
|
use ld_engine::ballot::{blake2_256, to_hex};
|
|
use ld_server::config::VoteChain;
|
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, ballots, open_db, seed};
|
|
use serde_json::{Value, json};
|
|
use std::sync::{Arc, Mutex};
|
|
use tower::ServiceExt;
|
|
|
|
// Substrate's well-known development keys, as Ğ1 addresses: no real account is touched.
|
|
const VOTE_ACCOUNT: &str = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; // Dave
|
|
const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
|
|
const ALICE_GENERIC: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
|
|
const BOB: &str = "g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g";
|
|
const CHARLIE: &str = "g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi";
|
|
const OTHER: &str = "g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj"; // Eve
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A simulated indexer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct Chain {
|
|
comments: Vec<Value>,
|
|
silent: bool,
|
|
asked: usize,
|
|
/// The last final block, served as `squidStatus.height`; `None`: every block is final.
|
|
finalized: Option<u32>,
|
|
/// An indexer that ignores the filter it is given (the hash only holds).
|
|
lax: bool,
|
|
/// The filters the server asked with, in order.
|
|
filters: Vec<Value>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Indexer {
|
|
chain: Arc<Mutex<Chain>>,
|
|
url: String,
|
|
}
|
|
|
|
/// Who signed, and the identity of that account, as the indexer serves it.
|
|
struct Signer<'a> {
|
|
account: &'a str,
|
|
identity: Option<(u32, &'a str, bool)>,
|
|
}
|
|
|
|
/// What the real indexer's `filter` keeps, for the two filters the server may ask.
|
|
fn kept_by(node: &Value, filter: &Value) -> bool {
|
|
if let Some(author) = filter["authorId"]["equalTo"].as_str()
|
|
&& node["author"]["id"] != author
|
|
{
|
|
return false;
|
|
}
|
|
let some = &filter["transfersByCommentId"]["some"];
|
|
if some.is_null() {
|
|
return true;
|
|
}
|
|
let min: u64 = some["amount"]["greaterThanOrEqualTo"].as_str().and_then(|a| a.parse().ok()).unwrap_or(0);
|
|
node["transfersByCommentId"]["nodes"].as_array().into_iter().flatten().any(|t| {
|
|
some["toId"]["equalTo"].as_str().is_none_or(|to| t["toId"] == to)
|
|
&& t["amount"].as_str().and_then(|a| a.parse::<u64>().ok()).is_some_and(|a| a >= min)
|
|
})
|
|
}
|
|
|
|
/// The size of a page: `first: N` in the query, as the server writes it.
|
|
fn page_size(query: &str) -> usize {
|
|
query
|
|
.split("first:")
|
|
.nth(1)
|
|
.and_then(|rest| rest.trim_start().split(|c: char| !c.is_ascii_digit()).next())
|
|
.and_then(|n| n.parse().ok())
|
|
.unwrap_or(100)
|
|
}
|
|
|
|
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
|
|
let mut c = chain.lock().expect("lock");
|
|
c.asked += 1;
|
|
if c.silent {
|
|
return StatusCode::BAD_GATEWAY.into_response();
|
|
}
|
|
let vars = &body["variables"];
|
|
let hash = vars["hash"].as_str().unwrap_or_default().to_owned();
|
|
let filter = vars["filter"].clone();
|
|
c.filters.push(filter.clone());
|
|
let mut nodes: Vec<Value> = c
|
|
.comments
|
|
.iter()
|
|
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
|
|
.filter(|n| c.lax || kept_by(n, &filter))
|
|
.cloned()
|
|
.collect();
|
|
nodes.sort_by_key(|n| (n["blockNumber"].as_u64(), n["id"].as_str().map(str::to_owned)));
|
|
// Pages, as PostGraphile serves them: `first`, then `after` the cursor of the last one.
|
|
let start: usize =
|
|
vars["after"].as_str().and_then(|a| a.strip_prefix("at:")).and_then(|n| n.parse().ok()).unwrap_or(0);
|
|
let page: Vec<Value> =
|
|
nodes.iter().skip(start).take(page_size(body["query"].as_str().unwrap_or_default())).cloned().collect();
|
|
let end = start + page.len();
|
|
Json(json!({ "data": {
|
|
"squidStatus": { "height": c.finalized.unwrap_or(1_000_000_000) },
|
|
"txComments": {
|
|
"pageInfo": { "hasNextPage": end < nodes.len(), "endCursor": format!("at:{end}") },
|
|
"nodes": page,
|
|
},
|
|
} }))
|
|
.into_response()
|
|
}
|
|
|
|
impl Indexer {
|
|
async fn start() -> Self {
|
|
let chain = Arc::new(Mutex::new(Chain::default()));
|
|
let router = Router::new().route("/v1/graphql", post(graphql)).with_state(chain.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
|
|
Self { chain, url: format!("http://{addr}/v1/graphql") }
|
|
}
|
|
|
|
/// A transaction on the chain: the remark, signed by `signer`, with its transfers.
|
|
fn add(&self, block: u32, event: u32, remark: &str, signer: Signer<'_>, transfers: &[(&str, u64)]) {
|
|
let identity =
|
|
signer.identity.map(|(index, name, member)| json!({ "index": index, "name": name, "isMember": member }));
|
|
let transfers: Vec<Value> = transfers
|
|
.iter()
|
|
.map(|(to, amount)| json!({ "fromId": signer.account, "toId": to, "amount": amount.to_string() }))
|
|
.collect();
|
|
self.chain.lock().expect("lock").comments.push(json!({
|
|
"id": format!("{block:010}-abcde-{event:06}"), "blockNumber": block, "remark": remark,
|
|
"author": { "id": signer.account, "identity": identity },
|
|
"transfersByCommentId": { "nodes": transfers },
|
|
}));
|
|
}
|
|
|
|
/// The usual vote: 0.1 Ğ1 to the vote account.
|
|
fn pay(&self, block: u32, remark: &str, signer: Signer<'_>) {
|
|
self.add(block, 1, remark, signer, &[(VOTE_ACCOUNT, 10)]);
|
|
}
|
|
|
|
fn silent(&self, on: bool) {
|
|
self.chain.lock().expect("lock").silent = on;
|
|
}
|
|
|
|
fn asked(&self) -> usize {
|
|
self.chain.lock().expect("lock").asked
|
|
}
|
|
|
|
/// The chain is final up to this block.
|
|
fn finalized(&self, block: Option<u32>) {
|
|
self.chain.lock().expect("lock").finalized = block;
|
|
}
|
|
|
|
fn lax(&self, on: bool) {
|
|
self.chain.lock().expect("lock").lax = on;
|
|
}
|
|
|
|
fn filters(&self) -> Vec<Value> {
|
|
self.chain.lock().expect("lock").filters.clone()
|
|
}
|
|
}
|
|
|
|
fn alice() -> Signer<'static> {
|
|
Signer { account: ALICE, identity: Some((9001, "Alice", true)) }
|
|
}
|
|
|
|
fn chain_config(indexer: &Indexer) -> Config {
|
|
let mut cfg = Config::for_tests();
|
|
cfg.indexers = vec![indexer.url.clone()];
|
|
cfg.vote = VoteChain::new(VOTE_ACCOUNT, 10, vec!["wss://node.invalid".into()], None);
|
|
cfg
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A client of the API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct Client {
|
|
router: axum::Router,
|
|
cookie: Option<String>,
|
|
}
|
|
|
|
impl Client {
|
|
async fn new(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 }, state)
|
|
}
|
|
|
|
fn other(&self) -> Self {
|
|
Self { router: self.router.clone(), cookie: None }
|
|
}
|
|
|
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
|
let (status, _, value) = self.call_with_headers(method, uri, body).await;
|
|
(status, value)
|
|
}
|
|
|
|
async fn call_with_headers(
|
|
&mut self,
|
|
method: &str,
|
|
uri: &str,
|
|
body: Option<Value>,
|
|
) -> (StatusCode, axum::http::HeaderMap, 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 headers = res.headers().clone();
|
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
|
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
|
}
|
|
|
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
|
let (s, v) = self.call(method, uri, body).await;
|
|
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
|
v
|
|
}
|
|
|
|
async fn login(&mut self, name: &str) {
|
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
|
}
|
|
|
|
async fn login_admin(&mut self, name: &str) {
|
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": true }))).await;
|
|
}
|
|
|
|
async fn doc(&mut self, group: &str) -> Value {
|
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
|
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
|
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
|
}
|
|
|
|
/// A pending ballot for `lines` (version id, choice).
|
|
async fn ballot(&mut self, doc: &Value, lines: &[(&str, Option<&str>)], author: Option<&str>) -> Value {
|
|
let id = doc["doc"]["id"].as_str().expect("id");
|
|
let lines: Vec<Value> = lines.iter().map(|(p, c)| json!({ "proposalId": p, "choice": c })).collect();
|
|
self.ok("POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": lines, "author": author }))).await["ballot"]
|
|
.clone()
|
|
}
|
|
|
|
async fn status(&mut self, ballot: &Value) -> Value {
|
|
let id = ballot["id"].as_str().expect("id");
|
|
self.ok("GET", &format!("/api/ballots/{id}"), None).await["ballot"].clone()
|
|
}
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
|
|
/// A web of two members and the quorum 2 at every level of a document, for the test
|
|
/// only (ADR 0013): two voices pass, one never does.
|
|
async fn small_web(state: &AppState, doc_id: &str) {
|
|
sqlx::query("UPDATE documents SET vote_floor = 1, quorums = '[2,2,2,2,2]' WHERE id = ?")
|
|
.bind(doc_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("quorums");
|
|
sqlx::query(
|
|
"INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z')
|
|
ON CONFLICT(key) DO UPDATE SET value = '2'",
|
|
)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("web");
|
|
}
|
|
|
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
|
}
|
|
|
|
/// The id of a version: `inForce`, or the n-th open proposal.
|
|
fn version(doc: &Value, code: &str, which: &str) -> String {
|
|
let u = unit(doc, code);
|
|
let v = if which == "inForce" { &u["inForce"] } else { &u["proposals"][which.parse::<usize>().expect("n")] };
|
|
v["id"].as_str().expect("id").to_owned()
|
|
}
|
|
|
|
/// The version as the document shows it now, by id.
|
|
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
|
doc["units"]
|
|
.as_array()
|
|
.expect("units")
|
|
.iter()
|
|
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
|
|
.find(|p| p["id"] == id)
|
|
.expect("version")
|
|
}
|
|
|
|
fn tally(doc: &Value, id: &str) -> (u64, u64) {
|
|
pair(&find(doc, id)["tally"])
|
|
}
|
|
|
|
fn pair(t: &Value) -> (u64, u64) {
|
|
(t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against"))
|
|
}
|
|
|
|
/// The part of the totals shown still waiting for its proof (ADR 0012).
|
|
fn pending(doc: &Value, id: &str) -> (u64, u64) {
|
|
pair(&find(doc, id)["tallyPending"])
|
|
}
|
|
|
|
/// What adoption counts: never an intention without proof.
|
|
async fn counted(state: &AppState, doc: &Value, id: &str) -> (u32, u32) {
|
|
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
|
let t = st.tally(id);
|
|
(t.for_votes, t.against)
|
|
}
|
|
|
|
/// « Ton vote » and where it stands.
|
|
fn mine<'a>(doc: &'a Value, id: &str) -> (Option<&'a str>, Option<&'a str>) {
|
|
let p = find(doc, id);
|
|
(p["myVote"].as_str(), p["myVoteState"].as_str())
|
|
}
|
|
|
|
/// The messages of a session about its ballots (ADR 0012): (kind, ballot id).
|
|
async fn ballot_messages(c: &mut Client) -> Vec<(String, String)> {
|
|
let m = c.ok("GET", "/api/messages", None).await;
|
|
m["messages"]
|
|
.as_array()
|
|
.expect("messages")
|
|
.iter()
|
|
.filter(|m| m["topic"] == "ballot")
|
|
.map(|m| (m["kind"].as_str().expect("kind").to_owned(), m["ballot"]["id"].as_str().expect("id").to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
async fn rewind_check(state: &AppState) {
|
|
// The page asks often, the indexer is read at most every few seconds: tests rewind that clock.
|
|
sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The vote on the chain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
// Monnaie libre votes on the chain; this session may ask for a ballot.
|
|
assert_eq!(doc["vote"]["way"], "chain");
|
|
assert_eq!(doc["vote"]["open"], true);
|
|
assert_eq!(doc["canVote"], true);
|
|
assert_eq!(doc["vote"]["chain"]["account"], VOTE_ACCOUNT);
|
|
assert_eq!(doc["vote"]["chain"]["amount"], 10);
|
|
assert_eq!(doc["vote"]["chain"]["test"], false);
|
|
|
|
let i1 = version(&doc, "I1", "0");
|
|
let e3 = version(&doc, "E3", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
|
|
let comment = b["comment"].as_str().expect("comment");
|
|
assert!(comment.starts_with("ld1:") && comment.len() == 68 && comment[4..].bytes().all(|x| x.is_ascii_hexdigit()));
|
|
assert_eq!(b["status"], "pending");
|
|
assert_eq!(b["account"], VOTE_ACCOUNT);
|
|
assert_eq!(b["author"], ALICE, "the bound account, written as a Ğ1 address");
|
|
assert_eq!(b["lines"], 2);
|
|
// What the server keeps: the lines, the salt, and the seal they make.
|
|
let (digest, salt): (String, String) =
|
|
sqlx::query_as("SELECT digest, salt FROM ballots").fetch_one(&state.db).await.expect("ballot");
|
|
assert_eq!(format!("ld1:{digest}"), comment);
|
|
assert_eq!(salt.len(), 32);
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id");
|
|
let lines = [
|
|
ld_engine::ballot::Line { proposal_id: i1.clone(), revision: 1, choice: ld_engine::ballot::Choice::For },
|
|
ld_engine::ballot::Line { proposal_id: e3.clone(), revision: 1, choice: ld_engine::ballot::Choice::Against },
|
|
];
|
|
assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment);
|
|
|
|
// Nothing on the chain yet: the intention is seen at once — her vote, the totals
|
|
// shown — and waits for its proof; adoption does not count it (ADR 0012).
|
|
assert_eq!(c.status(&b).await["status"], "pending");
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
|
|
assert_eq!((tally(&doc, &e3), pending(&doc, &e3)), ((0, 1), (0, 1)));
|
|
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
|
|
assert_eq!(mine(&doc, &i1), (Some("for"), Some("pending")));
|
|
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending");
|
|
assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for");
|
|
|
|
// The transaction lands: one transaction for both lines.
|
|
indexer.pay(3_400_000, comment, alice());
|
|
rewind_check(&state).await;
|
|
let s = c.status(&b).await;
|
|
assert_eq!(s["status"], "confirmed", "{s}");
|
|
assert_eq!(s["voter"], "Alice");
|
|
assert_eq!(s["block"], 3_400_000);
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (0, 0)));
|
|
assert_eq!(tally(&doc, &e3), (0, 1));
|
|
assert_eq!(counted(&state, &doc, &i1).await, (1, 0));
|
|
// The session that asked sees « ton vote », confirmed on the chain.
|
|
assert_eq!(mine(&doc, &i1), (Some("for"), Some("recorded")));
|
|
assert_eq!(find(&doc, &i1)["myVoteChain"], true);
|
|
assert!(find(&doc, &i1)["myBallot"].is_null());
|
|
assert_eq!(find(&doc, &e3)["myVote"], "against");
|
|
// The vote counts for the Ğ1 identity of the signer, created as a member.
|
|
let row: (String, String, bool, Option<String>) =
|
|
sqlx::query_as("SELECT kind, display_name, g1_member, address FROM identities WHERE id = 'idty:9001'")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("identity");
|
|
assert_eq!(row, ("g1".into(), "Alice".into(), true, Some(ALICE.into())));
|
|
let voters: Vec<(String, String)> = sqlx::query_as("SELECT identity_id, source FROM votes ORDER BY proposal_id")
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("votes");
|
|
assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}");
|
|
// One voice never makes a text pass: at level 2, the quorum 12, the 11 voters missing
|
|
// counted as against — 9 « pour » are asked (ADR 0013).
|
|
let e = &find(&doc, &i1)["evaluation"];
|
|
assert_eq!((e["quorum"].as_u64(), e["required"].as_u64(), e["yesNeeded"].as_u64()), (Some(12), Some(9), Some(9)));
|
|
assert_eq!(e["reached"], false);
|
|
// Someone else sees the totals, not the vote.
|
|
let mut yvv = c.other();
|
|
yvv.login("Yvv").await;
|
|
let seen = yvv.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&seen, &i1), (1, 0));
|
|
assert!(find(&seen, &i1)["myVote"].is_null());
|
|
// Asked again, a confirmed ballot stays as it is.
|
|
rewind_check(&state).await;
|
|
assert_eq!(c.status(&b).await["status"], "confirmed");
|
|
assert_eq!(
|
|
yvv.call("GET", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await.0,
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Yvv").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let p = version(&doc, "E3", "0");
|
|
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
|
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&b).await;
|
|
assert_eq!(s["status"], "confirmed");
|
|
assert_eq!(s["entered"], json!([]));
|
|
let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await;
|
|
// Level 3, the quorum 50: 39 « pour » of 50 voters, the 49 missing counted as against.
|
|
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
|
|
assert_eq!(d["proposal"]["evaluation"]["required"], 39);
|
|
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
|
|
assert_eq!(d["proposal"]["myVote"], "for");
|
|
assert_eq!(d["proposal"]["myVoteChain"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_silent_indexer_keeps_the_ballot_pending() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(100, b["comment"].as_str().expect("comment"), alice());
|
|
indexer.silent(true);
|
|
assert_eq!(c.status(&b).await["status"], "pending");
|
|
assert!(indexer.asked() >= 1);
|
|
// Asked again at once: the indexer is not read again before a few seconds.
|
|
let asked = indexer.asked();
|
|
assert_eq!(c.status(&b).await["status"], "pending");
|
|
assert_eq!(indexer.asked(), asked);
|
|
// The background task reads it again; silent still, nothing changes.
|
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
|
|
assert_eq!(c.status(&b).await["status"], "pending");
|
|
// The indexer is back.
|
|
indexer.silent(false);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "confirmed");
|
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_signer_who_is_not_a_member_is_refused_and_told() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(
|
|
100,
|
|
former["comment"].as_str().expect("c"),
|
|
Signer { account: BOB, identity: Some((42, "Bob", false)) },
|
|
);
|
|
let nobody = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(101, nobody["comment"].as_str().expect("c"), Signer { account: BOB, identity: None });
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&former).await;
|
|
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("not_member")));
|
|
let s = c.status(&nobody).await;
|
|
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("no_identity")));
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&doc, &i1), (0, 0));
|
|
// The page says so on the version.
|
|
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "refused");
|
|
assert_eq!(find(&doc, &i1)["myBallot"]["reason"], "no_identity");
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE id = 'idty:42'")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("n");
|
|
assert_eq!(n, 0, "no identity is created for a refused ballot");
|
|
// And in her messages, once each (ADR 0012).
|
|
let mut told = ballot_messages(&mut c).await;
|
|
told.sort();
|
|
let mut expected = vec![
|
|
("ballot_refused".to_owned(), id_of(&former).to_owned()),
|
|
("ballot_refused".to_owned(), id_of(&nobody).to_owned()),
|
|
];
|
|
expected.sort();
|
|
assert_eq!(told, expected);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(ballot_messages(&mut c).await.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_wrong_amount_or_recipient_is_refused() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
|
|
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
|
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
|
|
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
|
|
// Unbound (Ğecko): an unpaid comment settles nothing — anyone could send it for free.
|
|
let gecko = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.add(103, 1, gecko["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
for b in [&short, &elsewhere, &remark_only] {
|
|
let s = c.status(b).await;
|
|
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
|
}
|
|
assert_eq!(c.status(&gecko).await["status"], "pending");
|
|
// The refused ones are set aside; the Ğecko code still waits: its intention shows, uncounted.
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
|
|
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn confirming_twice_changes_nothing() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
|
// The page and the background task at once.
|
|
let row = sqlx::query_as::<_, ballots::BallotRow>(
|
|
"SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status, reason, voter_id, block,
|
|
entered, created_at, expires_at, checked_at FROM ballots",
|
|
)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("row");
|
|
let (a, b2) = tokio::join!(ballots::check(&state, &row), ballots::check(&state, &row));
|
|
a.expect("check");
|
|
b2.expect("check");
|
|
ballots::check(&state, &row).await.expect("again");
|
|
assert_eq!(c.status(&b).await["status"], "confirmed");
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("n");
|
|
assert_eq!(n, 1);
|
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_last_one_stands_in_the_order_of_the_chain() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let e1 = version(&doc, "E1", "0");
|
|
// Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1).
|
|
let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await;
|
|
let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
let third = c.ballot(&doc, &[(&e1, None)], None).await;
|
|
// The most recent is confirmed first; the oldest last, as when an indexer lags behind.
|
|
indexer.pay(300, third["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
indexer.add(200, 4, second["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&doc, &i1), tally(&doc, &e1)), ((0, 1), (0, 0)));
|
|
indexer.add(200, 2, first["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
for b in [&first, &second, &third] {
|
|
assert_eq!(c.status(b).await["status"], "confirmed");
|
|
}
|
|
// Block 200 event 2 comes before block 200 event 4, before block 300: the first ballot
|
|
// decides nothing any more. I1 stays « against »; E1 stays withdrawn, never brought back.
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&doc, &i1), (0, 1));
|
|
assert_eq!(tally(&doc, &e1), (0, 0));
|
|
assert_eq!(find(&doc, &i1)["myVote"], "against");
|
|
assert!(find(&doc, &e1)["myVote"].is_null());
|
|
// A new ballot, later on the chain, decides again.
|
|
let fourth = c.ballot(&doc, &[(&e1, Some("against"))], None).await;
|
|
indexer.pay(301, fourth["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &e1), (0, 1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_revision_changed_meanwhile_leaves_the_vote_to_reconfirm() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let created = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/proposals"),
|
|
Some(json!({ "title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true })),
|
|
)
|
|
.await;
|
|
let p = created["id"].as_str().expect("id").to_owned();
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
|
|
// The author changes its meaning before the transaction is confirmed.
|
|
let edit = c
|
|
.ok(
|
|
"PATCH",
|
|
&format!("/api/proposals/{p}"),
|
|
Some(
|
|
json!({ "title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours." }),
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(edit["edit"], "substantive");
|
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "confirmed");
|
|
// The vote was cast on revision 1: it waits to be reconfirmed, as any vote does.
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&doc, &p), (0, 0));
|
|
assert_eq!(find(&doc, &p)["myVote"], "for");
|
|
assert_eq!(find(&doc, &p)["myVoteStale"], true);
|
|
let rev: i64 = sqlx::query_scalar("SELECT revision FROM votes WHERE proposal_id = ?")
|
|
.bind(&p)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("vote");
|
|
assert_eq!(rev, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
let comment = b["comment"].as_str().expect("c");
|
|
// Charlie copies the remark seen in the pool and gets it in first.
|
|
indexer.pay(99, comment, Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "pending", "a copy settles nothing");
|
|
indexer.pay(100, comment, alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&b).await;
|
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")));
|
|
let voters: Vec<String> =
|
|
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
|
|
assert_eq!(voters, ["idty:9001"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_ballot_not_seen_in_time_is_let_go() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let expires = b["expiresAt"].as_str().expect("expires");
|
|
assert!(
|
|
expires > ld_server::util::in_seconds(23 * 3600).as_str()
|
|
&& expires <= ld_server::util::in_seconds(24 * 3600).as_str()
|
|
);
|
|
sqlx::query("UPDATE ballots SET expires_at = ?")
|
|
.bind(ld_server::util::in_seconds(-1))
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("age");
|
|
// Read one last time at its end: nothing on the chain, it is let go.
|
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
|
|
assert_eq!(c.status(&b).await["status"], "expired");
|
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
|
|
// Seen on the chain afterwards: too late, it no longer counts.
|
|
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "expired");
|
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]);
|
|
// Another session may not let it go.
|
|
let mut other = c.other();
|
|
other.login("Yvv").await;
|
|
let (s, _) = other.call("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
|
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
|
// Its own session does: no longer waiting, and it says nothing under the article.
|
|
let dropped = c.ok("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
|
|
assert_eq!(
|
|
(dropped["ballot"]["status"].as_str(), dropped["ballot"]["reason"].as_str()),
|
|
(Some("expired"), Some("dropped"))
|
|
);
|
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], first["id"]);
|
|
// A confirmed ballot is never let go.
|
|
indexer.pay(100, first["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let kept = c.ok("DELETE", &format!("/api/ballots/{}", first["id"].as_str().expect("id")), None).await;
|
|
assert_eq!(kept["ballot"]["status"], "confirmed");
|
|
let now = c.doc("monnaie-libre").await;
|
|
assert_eq!((find(&now, &i1)["myVote"].as_str(), find(&now, &i1)["myBallot"].is_null()), (Some("against"), true));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_session_sees_its_own_ballots_only() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
|
|
chiara.login("Chiara").await;
|
|
let mut other = chiara.other();
|
|
other.login("Yvv").await;
|
|
let doc = chiara.doc("monnaie-libre").await;
|
|
let (i1, e1) = (version(&doc, "I1", "0"), version(&doc, "E1", "0"));
|
|
// Both sign with the same wallet (one person, two sessions): two ballots, two versions.
|
|
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await;
|
|
indexer.pay(100, a["comment"].as_str().expect("c"), alice());
|
|
indexer.pay(101, b["comment"].as_str().expect("c"), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let mine = chiara.doc("monnaie-libre").await;
|
|
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
|
|
let theirs = other.doc("monnaie-libre").await;
|
|
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
|
|
// « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide).
|
|
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
|
|
assert_eq!(d["canVote"], true);
|
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let e4 = version(&doc, "E4", "0");
|
|
let e5 = version(&doc, "E5", "0");
|
|
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
|
|
let e1 = version(&doc, "E1", "0");
|
|
// Level 3, the quorum 50 (ADR 0013): 39 « pour » with no « contre ».
|
|
let needed = find(&doc, &e4)["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded");
|
|
assert_eq!(needed, 39);
|
|
let needed = u8::try_from(needed).expect("n");
|
|
let keys: Vec<String> = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
|
let mut last = Value::Null;
|
|
let mut luna = c.other();
|
|
luna.login("Luna").await;
|
|
for (n, account) in keys.iter().enumerate() {
|
|
// The last voice comes from another session: its intention is seen at once.
|
|
let last_one = n + 1 == keys.len();
|
|
let asker = if last_one { &mut luna } else { &mut c };
|
|
let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
|
|
if last_one {
|
|
// Thirty-nine voices shown, thirty-eight proven: nothing enters before the proof.
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((39, 0), (1, 0)));
|
|
assert_eq!(counted(&state, &d, &e4).await, (38, 0));
|
|
let id = d["doc"]["id"].as_str().expect("id");
|
|
assert!(ld_server::adoption::run(&state, id).await.expect("run").is_empty());
|
|
assert_ne!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
|
|
}
|
|
let index = 10_000 + u32::try_from(n).expect("n");
|
|
indexer.pay(
|
|
1_000 + index,
|
|
b["comment"].as_str().expect("c"),
|
|
Signer { account, identity: Some((index, "Membre", true)) },
|
|
);
|
|
if n + 1 == keys.len() {
|
|
// The last ballot is signed; before the chain confirms it, an objection holds
|
|
// E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it.
|
|
let o = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/proposals/{e5}/objections"),
|
|
Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })),
|
|
)
|
|
.await;
|
|
assert_eq!(o["heldId"], e5.as_str());
|
|
let id = doc["doc"]["id"].as_str().expect("id");
|
|
let (s, v) = c
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{id}/ballots"),
|
|
Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")));
|
|
}
|
|
last = b;
|
|
if n % 10 == 9 {
|
|
// A session holds at most 20 pending ballots: the background task confirms on the way.
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
}
|
|
if n + 2 == keys.len() {
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&d, &e4), (38, 0));
|
|
assert!(find(&d, &e4)["enteredAt"].is_null(), "38 voices: one short of the 39 the quorum 50 asks");
|
|
assert_eq!(find(&d, &e4)["evaluation"]["missing"], 1);
|
|
}
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = luna.status(&last).await;
|
|
assert_eq!(s["status"], "confirmed");
|
|
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
|
|
// Sealed before the objection, proven after it: its *for* on E5 is set aside
|
|
// (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told.
|
|
assert_eq!(tally(&d, &e5), (38, 0));
|
|
assert_eq!(find(&d, &e5)["votable"], false);
|
|
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
|
|
let outcomes: Vec<(String, Option<String>)> =
|
|
sqlx::query_as("SELECT proposal_id, outcome FROM ballot_lines WHERE ballot_id = ? ORDER BY proposal_id = ?")
|
|
.bind(id_of(&last))
|
|
.bind(&e5)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("lines");
|
|
assert_eq!(outcomes, [(e4.clone(), Some("counted".into())), (e5.clone(), Some("objection".into()))]);
|
|
assert_eq!(ballot_messages(&mut luna).await, [("ballot_objection".to_owned(), id_of(&last).to_owned())]);
|
|
let seen = luna.doc("monnaie-libre").await;
|
|
let held = &find(&seen, &e5)["myBallot"];
|
|
assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection")));
|
|
assert_eq!(mine(&seen, &e4), (Some("for"), Some("recorded")));
|
|
}
|
|
|
|
/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a
|
|
/// ballot against it or a withdrawal, never a *for* — alone or among free lines.
|
|
#[tokio::test]
|
|
async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let (e1, e4, e5) = (version(&doc, "E1", "0"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
|
|
let o = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/proposals/{e5}/objections"),
|
|
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
|
|
)
|
|
.await;
|
|
assert_eq!(o["heldId"], e5.as_str());
|
|
let uri = format!("/api/docs/{doc_id}/ballots");
|
|
let refused = (StatusCode::CONFLICT, Some("objection"));
|
|
for lines in [
|
|
json!([{ "proposalId": e5, "choice": "for" }]),
|
|
json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]),
|
|
] {
|
|
let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await;
|
|
assert_eq!((s, v["error"].as_str()), refused, "{lines}");
|
|
}
|
|
// Against the held one, for a free one: sealed, then confirmed by the chain.
|
|
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await;
|
|
indexer.pay(200, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "confirmed");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1)));
|
|
assert_eq!(find(&d, &e5)["myVote"], "against");
|
|
// A withdrawal: the last ballot stands.
|
|
let w = c.ballot(&doc, &[(&e5, None)], None).await;
|
|
indexer.pay(201, comment_of(&w), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&w).await["status"], "confirmed");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&d, &e5), (0, 0));
|
|
assert!(find(&d, &e5)["myVote"].is_null());
|
|
assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// After the independent review of 74f96be (lot B2): its probes, kept as tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn db_status(state: &AppState, id: &str) -> (String, Option<String>) {
|
|
sqlx::query_as("SELECT status, reason FROM ballots WHERE id = ?").bind(id).fetch_one(&state.db).await.expect("row")
|
|
}
|
|
|
|
fn id_of(b: &Value) -> &str {
|
|
b["id"].as_str().expect("id")
|
|
}
|
|
|
|
fn comment_of(b: &Value) -> &str {
|
|
b["comment"].as_str().expect("comment")
|
|
}
|
|
|
|
async fn age(state: &AppState, set: &str, seconds: i64, id: &str) {
|
|
sqlx::query(&format!("UPDATE ballots SET {set} = ? WHERE id = ?"))
|
|
.bind(ld_server::util::in_seconds(seconds))
|
|
.bind(id)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("age");
|
|
}
|
|
|
|
/// P5: the `author` field was decoded in a time that grows as the square of its
|
|
/// length, on a thread of the server (250 000 characters: 32 s).
|
|
#[tokio::test]
|
|
async fn a_long_author_is_refused_at_once() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = version(&doc, "I1", "0");
|
|
// The body may hold 256 KiB: an author of 250 000 characters passes it.
|
|
for n in [80_000usize, 250_000] {
|
|
let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) });
|
|
let t = std::time::Instant::now();
|
|
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(body)).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("author")));
|
|
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{n} characters: {:?}", t.elapsed());
|
|
}
|
|
}
|
|
|
|
/// P1: the background task read the same 50 oldest pending ballots at every pass;
|
|
/// a vote paid behind them was never confirmed, then expired.
|
|
#[tokio::test]
|
|
async fn the_background_task_reaches_every_pending_ballot() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
|
a.login("Anna").await;
|
|
let doc = a.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let mut b = a.other();
|
|
b.login("Bruno").await;
|
|
let mut c = a.other();
|
|
c.login("Carla").await;
|
|
for _ in 0..20 {
|
|
a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
b.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
}
|
|
for _ in 0..10 {
|
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
}
|
|
let mut dora = a.other();
|
|
dora.login("Dora").await;
|
|
let mine = dora.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
indexer.pay(100, comment_of(&mine), alice());
|
|
// 51 pending, at most 50 read per pass: the least recently read come first.
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&mine)).await.0, "confirmed");
|
|
let unread: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballots WHERE checked_at IS NULL")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("n");
|
|
assert_eq!(unread, 0, "every pending ballot was read");
|
|
}
|
|
|
|
/// P2: `DELETE` freed the place at once — one session made 100 ballots and 3 500 lines.
|
|
#[tokio::test]
|
|
async fn letting_ballots_go_does_not_lift_the_cap() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut a, state) = Client::new(chain_config(&indexer)).await;
|
|
a.login("Anna").await;
|
|
let doc = a.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = version(&doc, "I1", "0");
|
|
for _ in 0..20 {
|
|
let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await;
|
|
}
|
|
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
|
|
let url = format!("/api/docs/{doc_id}/ballots");
|
|
let (s, v) = a.call("POST", &url, Some(one.clone())).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
|
|
// The cap holds for the identity, whatever the session.
|
|
let mut again = a.other();
|
|
again.login("Anna").await;
|
|
assert_eq!(again.call("POST", &url, Some(one.clone())).await.0, StatusCode::CONFLICT);
|
|
// An hour later, there is room again.
|
|
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
|
.bind(ld_server::util::in_seconds(-3601))
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("age");
|
|
let kept = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
// Let go more than a day ago: purged, lines and all; the pending one stays.
|
|
sqlx::query("UPDATE ballots SET settled_at = ? WHERE reason = 'dropped'")
|
|
.bind(ld_server::util::in_seconds(-(24 * 3600 + 1)))
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("age");
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let count = || async {
|
|
sqlx::query_as::<_, (i64, i64)>("SELECT (SELECT COUNT(*) FROM ballots), (SELECT COUNT(*) FROM ballot_lines)")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("n")
|
|
};
|
|
assert_eq!(count().await, (1, 1));
|
|
// Not seen on the chain in time: it says so under the article for a week, then it is purged.
|
|
age(&state, "expires_at", -3601 - 5, id_of(&kept)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&kept)).await.0, "expired");
|
|
age(&state, "settled_at", -6 * 24 * 3600, id_of(&kept)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(count().await, (1, 1));
|
|
age(&state, "settled_at", -8 * 24 * 3600, id_of(&kept)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(count().await, (0, 0));
|
|
}
|
|
|
|
/// P3: a bare copy of the remark — free to send — refused a Ğecko ballot for good.
|
|
#[tokio::test]
|
|
async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() {
|
|
let indexer = Indexer::start().await;
|
|
// An indexer that does not filter: the rule of the engine holds alone.
|
|
indexer.lax(true);
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]);
|
|
indexer.add(
|
|
99,
|
|
2,
|
|
comment_of(&b),
|
|
Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) },
|
|
&[(VOTE_ACCOUNT, 9)],
|
|
);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(c.status(&b).await["status"], "pending", "an unpaid copy settles nothing");
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&b).await;
|
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
|
}
|
|
|
|
/// P4, the limit that stays (ADR 0008): a paid copy, earlier on the chain, takes
|
|
/// an unbound ballot — its lines count for the one who paid.
|
|
#[tokio::test]
|
|
async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&b).await;
|
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Charlie")));
|
|
let voters: Vec<String> =
|
|
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
|
|
assert_eq!(voters, ["idty:7"]);
|
|
}
|
|
|
|
/// Point 5 of the review: a pending ballot older than a confirmed vote on the same
|
|
/// line no longer shows under the article.
|
|
#[tokio::test]
|
|
async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
// A Ğecko code shown, never paid; then a vote from the wallet, confirmed.
|
|
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
indexer.pay(100, comment_of(&newer), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(find(&d, &i1)["myVote"], "for");
|
|
assert!(find(&d, &i1)["myBallot"].is_null(), "{}", find(&d, &i1)["myBallot"]);
|
|
let p = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
|
assert!(p["proposal"]["myBallot"].is_null());
|
|
// A ballot asked after the vote shows, waiting.
|
|
let after = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
|
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], after["id"]);
|
|
}
|
|
|
|
/// Point 6: only a comment in a final block counts, or refuses.
|
|
#[tokio::test]
|
|
async fn a_comment_counts_once_its_block_is_final() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await;
|
|
indexer.finalized(Some(99));
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
indexer.add(
|
|
100,
|
|
2,
|
|
comment_of(&r),
|
|
Signer { account: BOB, identity: Some((42, "Bob", false)) },
|
|
&[(VOTE_ACCOUNT, 10)],
|
|
);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
|
|
assert_eq!(db_status(&state, id_of(&r)).await.0, "pending");
|
|
// Her latest intention shows; nothing is counted before a final block.
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0)));
|
|
assert_eq!(counted(&state, &doc, &i1).await, (0, 0));
|
|
indexer.finalized(Some(100));
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
|
assert_eq!(db_status(&state, id_of(&r)).await, ("refused".into(), Some("not_member".into())));
|
|
}
|
|
|
|
/// Point 6: the search by hash went no further than 20 comments; copies could hide the real one.
|
|
#[tokio::test]
|
|
async fn copies_never_hide_the_real_comment() {
|
|
let indexer = Indexer::start().await;
|
|
// An indexer that would not filter: pages, one after the other.
|
|
indexer.lax(true);
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
|
for n in 0..120 {
|
|
indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
|
|
}
|
|
indexer.pay(300, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&b).await;
|
|
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")), "{s}");
|
|
// The indexer is asked for what may settle: the comments of the bound account;
|
|
// for a Ğecko code, those paid to the vote account.
|
|
indexer.lax(false);
|
|
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let filters = indexer.filters();
|
|
assert!(filters.iter().any(|f| f["authorId"]["equalTo"] == ALICE), "{filters:?}");
|
|
assert!(
|
|
filters.iter().any(|f| {
|
|
let some = &f["transfersByCommentId"]["some"];
|
|
some["toId"]["equalTo"] == VOTE_ACCOUNT && some["amount"]["greaterThanOrEqualTo"] == "10"
|
|
}),
|
|
"{filters:?}"
|
|
);
|
|
}
|
|
|
|
/// Point 5: a Ğecko code keeps its ballot; the background task settles it, the
|
|
/// page closed — even paid at the last moment, read once more at its end.
|
|
#[tokio::test]
|
|
async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
age(&state, "expires_at", -5, id_of(&b)).await;
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
|
// Its end come while no indexer answers: kept and read again, for an hour at most.
|
|
let late = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
age(&state, "expires_at", -5, id_of(&late)).await;
|
|
indexer.silent(true);
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "pending");
|
|
age(&state, "expires_at", -3601, id_of(&late)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
|
}
|
|
|
|
/// Point 7: the research note promises everyone can check their own vote with its
|
|
/// salt. The receipt goes to the one who asked, never to anyone else.
|
|
#[tokio::test]
|
|
async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let (i1, e3) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"));
|
|
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let url = format!("/api/ballots/{}/receipt", id_of(&b));
|
|
let (s, headers, r) = c.call_with_headers("GET", &url, None).await;
|
|
assert_eq!(s, StatusCode::OK, "{r}");
|
|
let disposition = headers.get(header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok()).unwrap_or_default();
|
|
assert!(disposition.starts_with("attachment;") && disposition.contains(".json"), "{disposition}");
|
|
assert_eq!(
|
|
(r["format"].as_str(), r["status"].as_str(), r["block"].as_u64(), r["voter"].as_str()),
|
|
(Some("ld1"), Some("confirmed"), Some(100), Some("Alice"))
|
|
);
|
|
assert_eq!((r["comment"].as_str(), r["author"].as_str()), (Some(comment_of(&b)), Some(ALICE)));
|
|
// It holds: its lines and its salt seal the remark the chain carries.
|
|
let lines: Vec<ld_engine::ballot::Line> = r["lines"]
|
|
.as_array()
|
|
.expect("lines")
|
|
.iter()
|
|
.map(|l| ld_engine::ballot::Line {
|
|
proposal_id: l["proposalId"].as_str().expect("p").into(),
|
|
revision: u32::try_from(l["revision"].as_u64().expect("r")).expect("r"),
|
|
choice: ld_engine::ballot::Choice::parse(l["choice"].as_str().expect("c")).expect("c"),
|
|
})
|
|
.collect();
|
|
assert_eq!(lines.len(), 2);
|
|
let doc_id = r["doc"].as_str().expect("doc");
|
|
let salt = r["salt"].as_str().expect("salt");
|
|
let sealed = ld_engine::ballot::seal(doc_id, r["author"].as_str(), &lines, salt).expect("seal");
|
|
assert_eq!(
|
|
(sealed.comment.as_str(), sealed.remark_hash.as_str()),
|
|
(comment_of(&b), r["remarkHash"].as_str().expect("h"))
|
|
);
|
|
assert_eq!(
|
|
r["canonical"].as_str(),
|
|
Some(ld_engine::ballot::canonical(doc_id, r["author"].as_str(), &lines, salt).expect("c").as_str())
|
|
);
|
|
// Each line says which article it is.
|
|
assert!(r["lines"].as_array().expect("l").iter().any(|l| l["article"] == "I1"));
|
|
// In her history, never in anyone else's.
|
|
let mine = c.ok("GET", "/api/ballots", None).await;
|
|
assert_eq!((mine["ballots"][0]["id"].as_str(), mine["ballots"][0]["lines"].as_u64()), (Some(id_of(&b)), Some(2)));
|
|
assert_eq!(mine["ballots"][0]["status"], "confirmed");
|
|
let mut yvv = c.other();
|
|
yvv.login("Yvv").await;
|
|
assert_eq!(yvv.call("GET", &url, None).await.0, StatusCode::NOT_FOUND);
|
|
assert_eq!(yvv.ok("GET", "/api/ballots", None).await["ballots"], json!([]));
|
|
let mut nobody = c.other();
|
|
assert_eq!(nobody.call("GET", &url, None).await.0, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
/// Point 8: an identity known only from a vote on the chain does not hold its name
|
|
/// against a person who joins by a team link; a Ğ1 identity that signed in does.
|
|
#[tokio::test]
|
|
async fn a_name_known_only_from_a_chain_vote_stays_free() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut admin, state) = Client::new(chain_config(&indexer)).await;
|
|
admin.login_admin("Yvv").await;
|
|
let doc = admin.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })]
|
|
{
|
|
let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.pay(block, comment_of(&b), signer);
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
// Charlie has since signed in with his Ğ1 identity.
|
|
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = 'idty:9003'")
|
|
.bind(ld_server::util::now())
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("seen");
|
|
let link = json!({ "label": "Équipe", "groups": ["essai"], "days": 30, "maxUses": 5 });
|
|
let v = admin.ok("POST", "/api/access/team-links", Some(link)).await;
|
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
|
let mut charlie = admin.other();
|
|
let (s, v) = charlie.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "charlie" }))).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
|
let mut alice = admin.other();
|
|
let (s, v) = alice.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Alice" }))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() {
|
|
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let order = doc["modules"]["orders"]
|
|
.as_array()
|
|
.expect("orders")
|
|
.iter()
|
|
.find(|o| o["section"] == "conclusion")
|
|
.expect("conclusion")
|
|
.clone();
|
|
let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2"));
|
|
assert_eq!(order["members"], json!([k1, k2]));
|
|
let o = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/units/{}/order", order["id"].as_str().expect("id")),
|
|
Some(json!({ "order": [k2, k1] })),
|
|
)
|
|
.await["id"]
|
|
.as_str()
|
|
.expect("order")
|
|
.to_owned();
|
|
let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned();
|
|
let t = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/units/{title_unit}/title"),
|
|
Some(json!({ "title": "Acte d'engagement de certification" })),
|
|
)
|
|
.await["id"]
|
|
.as_str()
|
|
.expect("title")
|
|
.to_owned();
|
|
let doc = c.doc("monnaie-libre").await;
|
|
// Each on the quorum of its level (ADR 0013): the order at level 2 (quorum 12) asks 9
|
|
// « pour », the title at level 4 (quorum 50) 43.
|
|
let mut needed = Vec::new();
|
|
for id in [&o, &t] {
|
|
let d = c.ok("GET", &format!("/api/proposals/{id}"), None).await;
|
|
needed.push(d["proposal"]["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded"));
|
|
}
|
|
assert_eq!(needed, [9, 43]);
|
|
let keys: Vec<String> = (1..=43u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
|
let mut sent = Vec::new();
|
|
for (n, account) in keys.iter().enumerate() {
|
|
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
|
|
let index = 20_000 + u32::try_from(n).expect("n");
|
|
indexer.pay(
|
|
2_000 + index,
|
|
b["comment"].as_str().expect("c"),
|
|
Signer { account, identity: Some((index, "Membre", true)) },
|
|
);
|
|
sent.push(b);
|
|
if n % 10 == 9 {
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
}
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
// What entered at each confirmation, in the order of the chain.
|
|
let mut entered: Vec<(usize, String, String)> = Vec::new();
|
|
for (n, b) in sent.iter().enumerate() {
|
|
let s = c.status(b).await;
|
|
assert_eq!(s["status"], "confirmed", "{s}");
|
|
for e in s["entered"].as_array().expect("entered") {
|
|
entered.push((n + 1, e["code"].as_str().expect("code").into(), e["kind"].as_str().expect("kind").into()));
|
|
}
|
|
}
|
|
assert_eq!(
|
|
entered,
|
|
[(9, "ordre.conclusion".into(), "order".into()), (43, "titre".into(), "title".into())],
|
|
"the ninth voice, then the forty-third"
|
|
);
|
|
let d = c.doc("monnaie-libre").await;
|
|
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
|
|
assert!(p2 < p1, "K2 now reads before K1");
|
|
assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification");
|
|
assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review");
|
|
// The adoptions keep what entered, by kind.
|
|
let kinds: Vec<String> =
|
|
sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k");
|
|
assert_eq!(kinds, ["order", "title"]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Without the chain, and in managed groups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_group_still_votes() {
|
|
let (mut c, _) = Client::new(Config::for_tests()).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
// The chain closed: the vote is simulated, written by the server (ADR 0012).
|
|
assert_eq!(doc["vote"]["way"], "server");
|
|
assert_eq!(doc["vote"]["open"], true);
|
|
assert_eq!(doc["vote"]["simulated"], true);
|
|
assert!(doc["vote"]["reason"].is_null());
|
|
assert!(doc["vote"]["chain"].is_null());
|
|
assert_eq!(doc["canVote"], true);
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = version(&doc, "I1", "0");
|
|
let (s, v) = c
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/ballots"),
|
|
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
|
|
let essai = c.doc("essai").await;
|
|
assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true)));
|
|
assert_eq!(essai["vote"]["simulated"], false);
|
|
let p = version(&essai, "I1", "0");
|
|
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
let essai = c.doc("essai").await;
|
|
assert_eq!(mine(&essai, &p), (Some("for"), Some("recorded")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn in_monnaie_libre_nobody_votes_on_the_server() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let p = version(&doc, "E3", "0");
|
|
for choice in [json!("for"), Value::Null] {
|
|
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": choice }))).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
|
|
}
|
|
let (s, v) = c
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/votes"),
|
|
Some(json!({ "lines": [{ "proposalId": p, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
|
|
// And no ballot in a group that votes on the server.
|
|
let essai = c.doc("essai").await;
|
|
let id = essai["doc"]["id"].as_str().expect("id").to_owned();
|
|
let q = version(&essai, "E3", "0");
|
|
let (s, v) = c
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{id}/ballots"),
|
|
Some(json!({ "lines": [{ "proposalId": q, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("server_vote")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn what_a_ballot_may_not_hold() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = version(&doc, "I1", "0");
|
|
let essai = c.doc("essai").await;
|
|
let elsewhere = version(&essai, "I1", "0");
|
|
for (lines, author, code) in [
|
|
(json!([]), Value::Null, "lines"),
|
|
(
|
|
json!([{ "proposalId": i1, "choice": "for" }, { "proposalId": i1, "choice": "against" }]),
|
|
Value::Null,
|
|
"lines",
|
|
),
|
|
(json!([{ "proposalId": elsewhere, "choice": "for" }]), Value::Null, "lines"),
|
|
(json!([{ "proposalId": i1, "choice": "maybe" }]), Value::Null, "choice"),
|
|
(json!([{ "proposalId": i1, "choice": "for" }]), json!("g1NotAnAddress"), "author"),
|
|
] {
|
|
let (s, v) = c
|
|
.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": lines, "author": author })))
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{lines}");
|
|
}
|
|
// A session holds a bounded number of pending ballots.
|
|
for _ in 0..20 {
|
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
}
|
|
let (s, v) = c
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/ballots"),
|
|
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_grouped_vote_in_a_managed_group_is_one_write() {
|
|
let (mut a, state) = Client::new(Config::for_tests()).await;
|
|
a.login("Yvv").await;
|
|
let mut b = a.other();
|
|
b.login("Chiara").await;
|
|
for code in ["E4", "E5", "E6"] {
|
|
two_voices(&state, "essai", code).await;
|
|
}
|
|
let doc = a.doc("essai").await;
|
|
let id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
|
|
let lines = json!([
|
|
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "against" }, { "proposalId": e6, "choice": "for" },
|
|
]);
|
|
let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
|
|
assert_eq!(v["count"], 3);
|
|
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two, on a quorum of 2");
|
|
// All or nothing: one line that does not hold, and no line is written.
|
|
let (s, _) = b
|
|
.call(
|
|
"POST",
|
|
&format!("/api/docs/{id}/votes"),
|
|
Some(
|
|
json!({ "lines": [{ "proposalId": e4, "choice": "for" }, { "proposalId": "nope", "choice": "for" }] }),
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE identity_id = 'dev:chiara'")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("n");
|
|
assert_eq!(n, 0);
|
|
// The second voice of a group of two: E4 and E6 enter at once; withdrawing in the same gesture.
|
|
let lines = json!([
|
|
{ "proposalId": e4, "choice": "for" }, { "proposalId": e6, "choice": "for" }, { "proposalId": e5, "choice": null },
|
|
]);
|
|
let v = b.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
|
|
let mut codes: Vec<&str> = v["entered"].as_array().expect("e").iter().filter_map(|e| e["code"].as_str()).collect();
|
|
codes.sort_unstable();
|
|
assert_eq!(codes, ["E4", "E6"]);
|
|
let doc = a.doc("essai").await;
|
|
assert_eq!(tally(&doc, &e5), (0, 1));
|
|
assert_eq!(find(&doc, &e5)["myVote"], "against");
|
|
assert_eq!(find(&doc, &e5)["myVoteChain"], false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The buffer of the votes (ADR 0012): the intention at once, the proof of the
|
|
// chain, the reconciliation; the simulation while the chain is closed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_simulated_vote_in_monnaie_libre_is_seen_at_once_in_my_vote_and_the_totals() {
|
|
let (mut c, state) = Client::new(Config::for_tests()).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let (i1, e3, e4) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"), version(&doc, "E4", "0"));
|
|
// One vote, without a wallet: written by the server, simulated.
|
|
let v = c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
assert_eq!((v["simulated"].as_bool(), v["entered"].as_array().map(Vec::len)), (Some(true), Some(0)));
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&d, &i1), (1, 0));
|
|
assert_eq!(pending(&d, &i1), (0, 0), "nothing waits for the chain: the simulated confirmer proved it");
|
|
assert_eq!(pair(&find(&d, &i1)["tallySimulated"]), (1, 0));
|
|
assert_eq!(mine(&d, &i1), (Some("for"), Some("simulated")));
|
|
assert_eq!(find(&d, &i1)["myVoteChain"], false);
|
|
// By default a simulated vote counts like a proven one, adoption included.
|
|
assert_eq!(counted(&state, &d, &i1).await, (1, 0));
|
|
// Through the same buffer: a sealed ballot, simulated, proven at block 0, for the session.
|
|
let row: (String, String, Option<i64>, Option<String>, String) =
|
|
sqlx::query_as("SELECT mode, status, block, voter_id, requested_by FROM ballots")
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("ballot");
|
|
assert_eq!((row.0.as_str(), row.1.as_str(), row.2), ("simulated", "confirmed", Some(0)));
|
|
assert_eq!(row.3.as_deref(), Some(row.4.as_str()), "the voice of the session: there is no wallet");
|
|
let source: String = sqlx::query_scalar("SELECT source FROM votes").fetch_one(&state.db).await.expect("vote");
|
|
assert_eq!(source, "simulated");
|
|
// A grouped vote: one ballot, every line; then a withdrawal.
|
|
let lines = json!([{ "proposalId": e3, "choice": "against" }, { "proposalId": e4, "choice": "for" }]);
|
|
let v = c.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
|
|
assert_eq!((v["count"].as_u64(), v["simulated"].as_bool()), (Some(2), Some(true)));
|
|
c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": null }))).await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), tally(&d, &e3), tally(&d, &e4)), ((0, 0), (0, 1), (1, 0)));
|
|
assert_eq!(mine(&d, &i1), (None, None));
|
|
assert_eq!(mine(&d, &e3), (Some("against"), Some("simulated")));
|
|
// Someone else sees the totals, never the vote.
|
|
let mut yvv = c.other();
|
|
yvv.login("Yvv").await;
|
|
let seen = yvv.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&seen, &e3), mine(&seen, &e3)), ((0, 1), (None, None)));
|
|
// Her ballots, marked simulated, with a receipt.
|
|
let history = c.ok("GET", "/api/ballots", None).await;
|
|
let list = history["ballots"].as_array().expect("ballots");
|
|
assert_eq!(list.len(), 3);
|
|
assert!(
|
|
list.iter().all(|b| b["mode"] == "simulated" && b["status"] == "confirmed" && b["block"].is_null()),
|
|
"{history}"
|
|
);
|
|
let id = list[0]["id"].as_str().expect("id");
|
|
let r = c.ok("GET", &format!("/api/ballots/{id}/receipt"), None).await;
|
|
assert_eq!(r["mode"], "simulated");
|
|
// No ballot to sign while the chain is closed.
|
|
let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] });
|
|
let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(one)).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shown_but_never_adopted_when_simulated_votes_are_switched_off_for_adoption() {
|
|
// Yvv may choose « simulé = affiché, jamais compté pour l'adoption » (ADR 0012).
|
|
for adopts in [true, false] {
|
|
let mut cfg = Config::for_tests();
|
|
cfg.simulated_adopts = adopts;
|
|
let (mut a, state) = Client::new(cfg).await;
|
|
a.login("Anna").await;
|
|
let doc = a.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
small_web(&state, &doc_id).await;
|
|
let e4 = version(&doc, "E4", "0");
|
|
let mut b = a.other();
|
|
b.login("Bruno").await;
|
|
a.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
let v = b.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
let entered = !v["entered"].as_array().expect("entered").is_empty();
|
|
let d = a.doc("monnaie-libre").await;
|
|
assert_eq!(pair(&find(&d, &e4)["tallySimulated"]), (2, 0), "shown either way");
|
|
assert_eq!(counted(&state, &d, &e4).await, if adopts { (2, 0) } else { (0, 0) });
|
|
assert_eq!(entered, adopts, "adopts = {adopts}");
|
|
}
|
|
}
|
|
|
|
/// Yvv, 2026-11-02: « Il compte, puis s'efface ». Opening the chain sets nothing
|
|
/// aside; the act « supprime » does, and examines again what the simulated votes
|
|
/// brought into force, with the proven votes only (`simulation::discard`).
|
|
#[tokio::test]
|
|
async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_what_entered() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
small_web(&state, &doc_id).await;
|
|
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
|
|
// The seed proposal of I1 (ADR 0011), and a rival written before the votes.
|
|
let seed_i1 = version(&doc, "I1", "0");
|
|
let i1_unit = unit(&doc, "I1")["id"].as_str().expect("unit").to_owned();
|
|
let rival = c
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/proposals"),
|
|
Some(json!({ "unitId": i1_unit, "title": "Raison d'être", "body": "Un autre texte pour I1, voté en simulation.", "submit": true })),
|
|
)
|
|
.await["id"]
|
|
.as_str()
|
|
.expect("rival")
|
|
.to_owned();
|
|
// The chain open: one proven voice on E5, one on E6 — not enough to enter; two on
|
|
// the seed proposal of I1, which enters on proven votes alone.
|
|
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
|
|
for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, charlie)] {
|
|
let b = c.ballot(&doc, &[(p, Some("for")), (&seed_i1, Some("for"))], None).await;
|
|
indexer.pay(block, comment_of(&b), signer);
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str());
|
|
assert!(unit(&d, "E5")["inForce"].is_null() && unit(&d, "E6")["inForce"].is_null());
|
|
// The chain closed: Luna, Bruno and Marc vote in simulation; E4, E5, E6 enter, and
|
|
// the rival of I1, three voices against two, takes the place of the seed proposal.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
|
let mut luna = c.other();
|
|
luna.login("Luna").await;
|
|
let mut bruno = c.other();
|
|
bruno.login("Bruno").await;
|
|
let mut marc = c.other();
|
|
marc.login("Marc").await;
|
|
let lines = json!([
|
|
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" },
|
|
{ "proposalId": e6, "choice": "for" }, { "proposalId": rival, "choice": "for" },
|
|
]);
|
|
luna.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
|
|
let lines = json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": rival, "choice": "for" }]);
|
|
bruno.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
|
|
marc.ok("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
for (code, id) in [("E4", &e4), ("E5", &e5), ("E6", &e6), ("I1", &rival)] {
|
|
assert_eq!(unit(&d, code)["inForce"]["id"], id.as_str(), "{code}");
|
|
}
|
|
// The chain opens again: nothing is set aside by itself — the simulated votes still count, marked.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
|
|
let d = luna.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e5), pair(&find(&d, &e5)["tallySimulated"])), ((2, 0), (1, 0)));
|
|
assert_eq!(mine(&d, &e5), (Some("for"), Some("simulated")));
|
|
let (s, v) = luna.call("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
|
|
// A second proven voice on E6, after it entered.
|
|
let b = c.ballot(&d, &[(&e6, Some("for"))], None).await;
|
|
indexer.pay(200, comment_of(&b), Signer { account: OTHER, identity: Some((9004, "Eve", true)) });
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
// Told first, nothing changed.
|
|
let told = ld_server::simulation::discard(&state, false).await.expect("dry run");
|
|
assert_eq!((told.applied, told.ballots, told.votes), (false, 3, 7));
|
|
let verdicts = |list: &[ld_server::simulation::Reexamined]| {
|
|
let mut v: Vec<(String, bool, bool)> = list.iter().map(|r| (r.code.clone(), r.holds, r.returned)).collect();
|
|
v.sort();
|
|
v
|
|
};
|
|
let expected_told: Vec<(String, bool, bool)> =
|
|
[("E4", false, false), ("E5", false, false), ("E6", true, false), ("I1", false, false)]
|
|
.iter()
|
|
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
|
|
.collect();
|
|
assert_eq!(verdicts(&told.reexamined), expected_told);
|
|
assert_eq!(unit(&c.doc("monnaie-libre").await, "E5")["inForce"]["id"], e5.as_str());
|
|
// Done: the simulated votes are gone; what does not hold on the proven votes returns.
|
|
let done = ld_server::simulation::discard(&state, true).await.expect("act");
|
|
let expected_done: Vec<(String, bool, bool)> =
|
|
[("E4", false, true), ("E5", false, true), ("E6", true, false), ("I1", false, true)]
|
|
.iter()
|
|
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
|
|
.collect();
|
|
assert_eq!(verdicts(&done.reexamined), expected_done);
|
|
let d = luna.doc("monnaie-libre").await;
|
|
assert!(unit(&d, "E4")["inForce"].is_null() && unit(&d, "E5")["inForce"].is_null());
|
|
assert_eq!(unit(&d, "E6")["inForce"]["id"], e6.as_str(), "two proven voices: it stays");
|
|
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str(), "the text it had displaced comes back");
|
|
assert_eq!(find(&d, &rival)["status"], "open");
|
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (None, None)));
|
|
let left: (i64, i64) = sqlx::query_as(
|
|
"SELECT (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated')",
|
|
)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("left");
|
|
assert_eq!(left, (0, 0));
|
|
let mut adopted: Vec<String> =
|
|
sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions");
|
|
adopted.sort();
|
|
let mut kept = vec![seed_i1.clone(), e6.clone()];
|
|
kept.sort();
|
|
assert_eq!(adopted, kept, "the adoptions that do not hold are void; those on proven votes alone stay");
|
|
// Once is enough.
|
|
let again = ld_server::simulation::discard(&state, true).await.expect("again");
|
|
assert_eq!((again.ballots, again.reexamined.len()), (0, 0));
|
|
// Never without the dev login.
|
|
let mut cfg = chain_config(&indexer);
|
|
cfg.dev_login = false;
|
|
let (mut other, _) = Client::new(cfg).await;
|
|
let (s, _) = other.call("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_intention_never_confirmed_is_set_aside_and_told() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
// A vote proven first: « contre ».
|
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
indexer.pay(100, comment_of(&first), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
// Then « pour », never paid: it takes the place of « contre » in the totals shown, for a while.
|
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
|
|
assert_eq!(mine(&d, &i1), (Some("for"), Some("pending")));
|
|
// Someone else sees the totals shown, intention included.
|
|
let mut yvv = c.other();
|
|
yvv.login("Yvv").await;
|
|
assert_eq!(tally(&yvv.doc("monnaie-libre").await, &i1), (1, 0));
|
|
// Its window passes: set aside, and said — under the article, and in her messages.
|
|
age(&state, "expires_at", -5, id_of(&b)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "expired");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), pending(&d, &i1)), ((0, 1), (0, 0)));
|
|
assert_eq!(mine(&d, &i1), (Some("against"), Some("recorded")), "the vote of before stays");
|
|
assert_eq!(find(&d, &i1)["myBallot"]["status"], "expired");
|
|
assert_eq!(ballot_messages(&mut c).await, [("ballot_expired".to_owned(), id_of(&b).to_owned())]);
|
|
let m = c.ok("GET", "/api/messages", None).await;
|
|
let message = &m["messages"][0];
|
|
assert_eq!((message["done"].as_bool(), message["gestures"].as_array().map(Vec::len)), (Some(true), Some(0)));
|
|
assert_eq!(message["ballot"]["lines"][0]["code"], "I1");
|
|
assert_eq!(message["ballot"]["lines"][0]["choice"], "for");
|
|
assert_eq!(m["unread"], 1);
|
|
// A ballot the wallet never signed is let go at once: nothing to tell.
|
|
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
|
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
|
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 1));
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(ballot_messages(&mut c).await.len(), 1);
|
|
// Purged a week later, its message goes with it.
|
|
age(&state, "settled_at", -8 * 24 * 3600, id_of(&b)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(ballot_messages(&mut c).await.len(), 0);
|
|
assert_eq!(c.ok("GET", "/api/messages/unread", None).await["unread"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn two_votes_of_one_identity_from_two_sessions_the_chain_keeps_the_last() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
|
|
chiara.login("Chiara").await;
|
|
let mut other = chiara.other();
|
|
other.login("Yvv").await;
|
|
let doc = chiara.doc("monnaie-libre").await;
|
|
let i1 = version(&doc, "I1", "0");
|
|
// One person, one wallet, two sessions: « pour » here, « contre » there.
|
|
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let b = other.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
// Before the proofs, each intention counts for its session (ADR 0012, the cost).
|
|
assert_eq!(tally(&chiara.doc("monnaie-libre").await, &i1), (1, 1));
|
|
// On the chain, « pour » comes last; the indexer reads it first.
|
|
indexer.pay(200, comment_of(&a), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
indexer.pay(100, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let both = (db_status(&state, id_of(&a)).await.0, db_status(&state, id_of(&b)).await.0);
|
|
assert_eq!(both, ("confirmed".into(), "confirmed".into()));
|
|
// One voice: the last in the order of the chain.
|
|
let d = chiara.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (0, 0), (1, 0)));
|
|
let outcomes: Vec<(String, Option<String>)> =
|
|
sqlx::query_as("SELECT ballot_id, outcome FROM ballot_lines ORDER BY ballot_id = ?")
|
|
.bind(id_of(&a))
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.expect("lines");
|
|
assert_eq!(
|
|
outcomes,
|
|
[(id_of(&b).to_owned(), Some("superseded".into())), (id_of(&a).to_owned(), Some("counted".into()))]
|
|
);
|
|
let votes: Vec<(String, String)> =
|
|
sqlx::query_as("SELECT identity_id, choice FROM votes").fetch_all(&state.db).await.expect("votes");
|
|
assert_eq!(votes, [("idty:9001".to_owned(), "for".to_owned())]);
|
|
// The session whose vote was overtaken sees no vote of its own; nothing to tell.
|
|
let theirs = other.doc("monnaie-libre").await;
|
|
assert_eq!(mine(&theirs, &i1), (None, None));
|
|
assert!(ballot_messages(&mut other).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_for_proven_while_an_objection_holds_the_version_is_set_aside() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let (e1, e5) = (version(&doc, "E1", "0"), version(&doc, "E5", "0"));
|
|
// Proven first: « contre » on E5.
|
|
let before = c.ballot(&doc, &[(&e5, Some("against"))], None).await;
|
|
indexer.pay(100, comment_of(&before), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
// « Pour », sealed: seen at once in place of « contre ».
|
|
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (Some("for"), Some("pending"))));
|
|
// An objection holds E5 back: the *for* still waiting weighs nothing.
|
|
let mut luna = c.other();
|
|
luna.login("Luna").await;
|
|
luna.ok(
|
|
"POST",
|
|
&format!("/api/proposals/{e5}/objections"),
|
|
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
|
|
)
|
|
.await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e5), pending(&d, &e5)), ((0, 1), (0, 0)));
|
|
// The chain proves it now: set aside; « contre » stays; she is told.
|
|
indexer.pay(200, comment_of(&b), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e5), counted(&state, &d, &e5).await), ((0, 1), (0, 1)));
|
|
assert_eq!(mine(&d, &e5), (Some("against"), Some("recorded")));
|
|
let held = &find(&d, &e5)["myBallot"];
|
|
assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection")));
|
|
assert_eq!(ballot_messages(&mut c).await, [("ballot_objection".to_owned(), id_of(&b).to_owned())]);
|
|
// A withdrawal, proven while held, counts.
|
|
let w = c.ballot(&doc, &[(&e5, None)], None).await;
|
|
indexer.pay(300, comment_of(&w), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((0, 0), (None, None)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_waiting_intention_counts_as_my_vote_in_decisions() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let without = |d: &Value| d["docs"][0]["withoutMyVote"].as_u64();
|
|
let before = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
|
let i1 = version(&doc, "I1", "0");
|
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
|
assert_eq!(after, before.map(|n| n - 1));
|
|
}
|
|
|
|
/// The evaluation as the engine gives it for these totals, on this version's letters.
|
|
async fn judged(state: &AppState, doc: &Value, id: &str, shown: (u32, u32)) -> Value {
|
|
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
|
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
|
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
|
let tally = ld_engine::threshold::Tally { for_votes: shown.0, against: shown.1 };
|
|
let e = ld_engine::threshold::evaluate(tally, st.electorate, &st.params(u, p)).expect("evaluation");
|
|
serde_json::to_value(e).expect("json")
|
|
}
|
|
|
|
/// What adoption judges: never an intention, simulated votes by default (ADR 0012).
|
|
async fn adopted_evaluation(state: &AppState, doc: &Value, id: &str) -> Value {
|
|
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
|
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
|
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
|
serde_json::to_value(st.evaluation(u, p).expect("evaluation")).expect("json")
|
|
}
|
|
|
|
/// The quorum (ADR 0013) is judged on the totals shown — votes recorded, pending and
|
|
/// simulated, the vote one sees as one's own replaced by one's intention: `quorum`,
|
|
/// `yesNeeded`, `noNeeded` and `missing` are those of the totals shown. Adoption counts
|
|
/// the proven votes and the simulated ones (`SIMULATED_ADOPTS`), never an intention.
|
|
#[tokio::test]
|
|
async fn the_quorum_is_judged_on_the_totals_shown_and_adoption_never_on_an_intention() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
// I1, a seed proposal at level 2: the quorum 12, 9 « pour » at no vote.
|
|
let i1 = version(&doc, "I1", "0");
|
|
assert_eq!(find(&doc, &i1)["evaluation"], judged(&state, &doc, &i1, (0, 0)).await);
|
|
assert_eq!(find(&doc, &i1)["evaluation"]["yesNeeded"], 9);
|
|
// Proven: « contre ». Then « pour », sealed, not yet proven: it takes the place of
|
|
// « contre » in the totals shown, and in the evaluation shown.
|
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
|
indexer.pay(100, comment_of(&first), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
|
|
let shown = find(&d, &i1)["evaluation"].clone();
|
|
assert_eq!(shown, judged(&state, &d, &i1, (1, 0)).await, "on the totals shown");
|
|
assert_eq!((shown["cast"].as_u64(), shown["missing"].as_u64()), (Some(1), Some(8)));
|
|
let adoption = adopted_evaluation(&state, &d, &i1).await;
|
|
assert_eq!(adoption, judged(&state, &d, &i1, (0, 1)).await, "adoption: the proven « contre » only");
|
|
assert!(adoption["missing"].as_u64() > Some(8), "the intention never brings adoption closer: {adoption}");
|
|
// The detail says the same, and its five levels are asked at the votes shown.
|
|
let detail = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
|
assert_eq!(detail["proposal"]["evaluation"], shown);
|
|
assert!(detail["levels"].as_array().expect("levels").iter().all(|l| l["cast"] == 1), "{detail}");
|
|
// The chain closed: a simulated « pour » counts in both, like a proven vote.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
|
let mut luna = c.other();
|
|
luna.login("Luna").await;
|
|
luna.ok(
|
|
"POST",
|
|
&format!("/api/docs/{doc_id}/votes"),
|
|
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
|
|
)
|
|
.await;
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((2, 0), (1, 0), (1, 1)));
|
|
assert_eq!(find(&d, &i1)["evaluation"], judged(&state, &d, &i1, (2, 0)).await);
|
|
assert_eq!(adopted_evaluation(&state, &d, &i1).await, judged(&state, &d, &i1, (1, 1)).await);
|
|
}
|
|
|
|
/// « Un vote existe » (ADR 0013, `quorum::voted_units`), for every state of the buffer
|
|
/// (ADR 0012): an intention still pending, a proven line whatever became of it (a
|
|
/// withdrawal, a vote overtaken, a *for* set aside by an objection), a simulated vote,
|
|
/// a simulated withdrawal — the history of the ballots keeps their trace. A ballot the
|
|
/// wallet never signed, one never seen on the chain in time, one the chain refused, is
|
|
/// no vote.
|
|
#[tokio::test]
|
|
async fn a_vote_exists_for_every_state_of_the_buffer() {
|
|
let indexer = Indexer::start().await;
|
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let unit_id = |code: &str| unit(&doc, code)["id"].as_str().expect("unit").to_owned();
|
|
let exists = |u: String| {
|
|
let state = state.clone();
|
|
async move { ld_server::quorum::a_vote_exists(&state, &u).await.expect("query") }
|
|
};
|
|
let (i1, e1, e2, e3, e4) = (
|
|
version(&doc, "I1", "0"),
|
|
version(&doc, "E1", "0"),
|
|
version(&doc, "E2", "0"),
|
|
version(&doc, "E3", "0"),
|
|
version(&doc, "E4", "0"),
|
|
);
|
|
for code in ["I1", "E1", "E2", "E3", "E4"] {
|
|
assert!(!exists(unit_id(code)).await, "{code}: nobody voted yet");
|
|
}
|
|
// A ballot the wallet never signed: nothing.
|
|
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
assert!(exists(unit_id("I1")).await, "an intention pending is a vote already");
|
|
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
|
|
assert!(!exists(unit_id("I1")).await, "let go at once: no vote");
|
|
// Never seen on the chain in time: no vote.
|
|
let late = c.ballot(&doc, &[(&e2, Some("for"))], None).await;
|
|
age(&state, "expires_at", -2 * 3600, id_of(&late)).await;
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
|
assert!(!exists(unit_id("E2")).await);
|
|
// Proven, then withdrawn on the chain: no row in `votes`, the trace stays.
|
|
let a = c.ballot(&doc, &[(&e1, Some("for"))], None).await;
|
|
indexer.pay(100, comment_of(&a), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let w = c.ballot(&doc, &[(&e1, None)], None).await;
|
|
indexer.pay(101, comment_of(&w), alice());
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e1).await, (0, 0));
|
|
assert!(exists(unit_id("E1")).await, "a vote withdrawn leaves its trace");
|
|
// The chain closed: a simulated vote; a simulated vote, then withdrawn.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
|
c.ok("POST", &format!("/api/proposals/{e3}/vote"), Some(json!({ "choice": "against" }))).await;
|
|
assert!(exists(unit_id("E3")).await, "a simulated vote");
|
|
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
|
|
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e4).await, (0, 0));
|
|
assert!(exists(unit_id("E4")).await, "a simulated vote withdrawn leaves its trace");
|
|
// The quorum is held: the author (an administrator, for a text of the seed) may not set it.
|
|
let mut admin = c.other();
|
|
admin.login_admin("Yvv").await;
|
|
for code in ["E1", "E3", "E4"] {
|
|
let (s, v) =
|
|
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
|
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "{code}");
|
|
}
|
|
let (s, _) =
|
|
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E2")), Some(json!({ "quorum": 20 }))).await;
|
|
assert_eq!(s, StatusCode::OK, "E2: no vote");
|
|
// The act « supprime » sets every simulated vote aside: with them, their trace.
|
|
ld_server::simulation::discard(&state, true).await.expect("act");
|
|
assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await);
|
|
assert!(exists(unit_id("E1")).await, "a proven trace stays");
|
|
let _ = doc_id;
|
|
}
|
|
|
|
/// A bare repository of the texts, for the publication (ADR 0011, 0012).
|
|
struct Texts {
|
|
dir: std::path::PathBuf,
|
|
bare: std::path::PathBuf,
|
|
}
|
|
|
|
impl Texts {
|
|
fn new(name: &str) -> Self {
|
|
let dir = std::env::temp_dir().join(format!("ld-v-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("dir");
|
|
let bare = dir.join("textes.git");
|
|
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
|
|
assert!(ok.success());
|
|
Self { dir, bare }
|
|
}
|
|
|
|
/// A configuration that publishes there; `simulated`: `LD_PUBLISH_SIMULATED`.
|
|
fn config(&self, mut cfg: Config, simulated: bool) -> Config {
|
|
cfg.data_dir = self.dir.join("data");
|
|
cfg.publish = Some(ld_server::config::Publish {
|
|
remote: self.bare.display().to_string(),
|
|
branch: "main".into(),
|
|
key: self.dir.join("data/keys/publish_ed25519"),
|
|
author_name: "libreDecision".into(),
|
|
author_email: "decision@example.org".into(),
|
|
without_articles: false,
|
|
simulated,
|
|
});
|
|
cfg
|
|
}
|
|
|
|
/// The French text of the Acte as the repository has it; `None`: nothing published.
|
|
fn french(&self) -> Option<String> {
|
|
let out = std::process::Command::new("git")
|
|
.arg("-C")
|
|
.arg(&self.bare)
|
|
.args(["show", "main:monnaie-libre/acte-engagement-certification/fr.md"])
|
|
.output()
|
|
.expect("git show");
|
|
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
|
|
}
|
|
}
|
|
|
|
impl Drop for Texts {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
/// A body of a version, as the document shows it.
|
|
fn body_of(doc: &Value, id: &str) -> String {
|
|
find(doc, id)["body"].as_str().expect("body").to_owned()
|
|
}
|
|
|
|
/// Yvv, 2026-11-03: « Pas avant des votes prouvés ». A text that enters by simulated
|
|
/// votes is in force on the site, but sends nothing to the repository of the texts:
|
|
/// the publication waits until everything in effect holds on the proven votes alone —
|
|
/// the re-examination of the act « supprime » (`simulation`), told only.
|
|
#[tokio::test]
|
|
async fn a_text_entered_by_simulated_votes_is_in_force_but_never_published_before_proven_votes() {
|
|
let indexer = Indexer::start().await;
|
|
let texts = Texts::new("proven");
|
|
let (mut c, state) = Client::new(texts.config(chain_config(&indexer), false)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
small_web(&state, &doc_id).await;
|
|
let (e4, e5) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"));
|
|
// The chain closed: two simulated « pour », E4 enters — in force on the site.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
|
for name in ["Luna", "Bruno"] {
|
|
let mut s = c.other();
|
|
s.login(name).await;
|
|
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
}
|
|
assert_eq!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
|
|
ld_server::publish::pass(&state).await.expect("pass");
|
|
assert!(texts.french().is_none(), "not before proven votes");
|
|
let publication = &c.doc("monnaie-libre").await["doc"]["publication"];
|
|
assert_eq!((publication["state"].as_str(), publication["waitsFor"].as_str()), (Some("suspended"), Some("proven")));
|
|
// The chain open: E5 enters on two proven votes — E4 still does not hold on them: nothing goes out.
|
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
|
|
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
|
|
for (block, signer) in [(100, alice()), (101, charlie)] {
|
|
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
|
|
indexer.pay(block, comment_of(&b), signer);
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(unit(&d, "E5")["inForce"]["id"], e5.as_str());
|
|
ld_server::publish::pass(&state).await.expect("pass");
|
|
assert!(texts.french().is_none(), "E4 does not hold on the proven votes yet");
|
|
// The act « supprime »: E4 returns among the proposals; what is in force holds on the
|
|
// proven votes alone — it goes out.
|
|
ld_server::simulation::discard(&state, true).await.expect("act");
|
|
ld_server::publish::pass(&state).await.expect("pass");
|
|
let fr = texts.french().expect("published");
|
|
let (b4, b5) = (body_of(&d, &e4), body_of(&d, &e5));
|
|
assert!(fr.contains(b5.lines().next().expect("line")), "{fr}");
|
|
assert!(!fr.contains(b4.lines().next().expect("line")), "{fr}");
|
|
assert_eq!(c.doc("monnaie-libre").await["doc"]["publication"]["state"], "done");
|
|
}
|
|
|
|
/// Yvv may choose the other way (`LD_PUBLISH_SIMULATED=1`): published anyway.
|
|
#[tokio::test]
|
|
async fn with_the_switch_a_text_entered_by_simulated_votes_is_published() {
|
|
let texts = Texts::new("switch");
|
|
let (mut c, state) = Client::new(texts.config(Config::for_tests(), true)).await;
|
|
c.login("Chiara").await;
|
|
let doc = c.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
small_web(&state, &doc_id).await;
|
|
let e4 = version(&doc, "E4", "0");
|
|
for name in ["Luna", "Bruno"] {
|
|
let mut s = c.other();
|
|
s.login(name).await;
|
|
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
}
|
|
ld_server::publish::pass(&state).await.expect("pass");
|
|
let fr = texts.french().expect("published");
|
|
assert!(fr.contains(body_of(&doc, &e4).lines().next().expect("line")), "{fr}");
|
|
}
|