forked from yvv/decision
L'ordre des articles de chaque section (cran 2) et le titre du document (cran 4) deviennent des modules : des unités à part, dont les versions sont des propositions votées comme les articles (dépliées, panier, chaîne). - moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé, complété à la fin quand un article entre ou sort, places redistribuées, nouvel article en fin de section) ; pending : un ordre de moins de deux articles n'est jamais à voter ; - données : migration 0007 (units.kind, module_amendments) ; le seed déclare ses modules ; module_amend.rs les ajoute une fois, gardé et journalisé, aux documents nés sans eux, sans toucher articles ni votes ; - serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer, entrée en vigueur : les articles changent de place, le document change de titre, en et es passent « à revoir ») ; publication des modules ; - reste mineur : la médiane compte les statuts open, in_force, applied, et eux seuls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
894 lines
40 KiB
Rust
894 lines
40 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,
|
|
}
|
|
|
|
#[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)>,
|
|
}
|
|
|
|
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 hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
|
|
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)
|
|
.cloned()
|
|
.collect();
|
|
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
|
|
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).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
|
|
}
|
|
}
|
|
|
|
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 mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
|
if let Some(c) = &self.cookie {
|
|
req = req.header(header::COOKIE, c);
|
|
}
|
|
let req = match body {
|
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
|
None => req.body(Body::empty()),
|
|
}
|
|
.expect("request");
|
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
|
let status = res.status();
|
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
|
if raw.starts_with("ld_session=") {
|
|
self.cookie = Some(raw);
|
|
}
|
|
}
|
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
|
}
|
|
|
|
async fn 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 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()
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let t = &find(doc, id)["tally"];
|
|
(t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against"))
|
|
}
|
|
|
|
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", "inForce");
|
|
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: pending, nothing counted.
|
|
assert_eq!(c.status(&b).await["status"], "pending");
|
|
let doc = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&doc, &i1), (0, 0));
|
|
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), (1, 0));
|
|
assert_eq!(tally(&doc, &e3), (0, 1));
|
|
// The session that asked sees « ton vote », confirmed on the chain.
|
|
assert_eq!(find(&doc, &i1)["myVote"], "for");
|
|
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 (C = 50).
|
|
assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50);
|
|
// 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;
|
|
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
|
|
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", "inForce");
|
|
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", "inForce");
|
|
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");
|
|
}
|
|
|
|
#[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", "inForce");
|
|
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
|
|
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
|
|
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
|
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
|
|
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!(tally(&c.doc("monnaie-libre").await, &i1), (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", "inForce");
|
|
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", "inForce");
|
|
let e1 = version(&doc, "E1", "inForce");
|
|
// 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", "inForce");
|
|
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", "inForce");
|
|
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");
|
|
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
|
|
assert_eq!(c.status(&b).await["status"], "expired");
|
|
// 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", "inForce");
|
|
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", "inForce"), version(&doc, "E1", "inForce"));
|
|
// 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 fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_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");
|
|
// A contradiction signalled on E5 against the text in force E1: E5 cannot enter.
|
|
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
|
|
let e1 = version(&doc, "E1", "inForce");
|
|
c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await;
|
|
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
|
let mut last = Value::Null;
|
|
for (n, account) in keys.iter().enumerate() {
|
|
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
|
|
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)) },
|
|
);
|
|
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 == 48 {
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let d = c.doc("monnaie-libre").await;
|
|
assert_eq!(tally(&d, &e4), (49, 0));
|
|
assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50");
|
|
}
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.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());
|
|
assert_eq!(tally(&d, &e5), (50, 0));
|
|
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fifty_voices_on_the_chain_bring_an_order_and_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;
|
|
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
|
let mut last = Value::Null;
|
|
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)) },
|
|
);
|
|
last = b;
|
|
if n % 10 == 9 {
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
}
|
|
}
|
|
ballots::sweep(&state).await.expect("sweep");
|
|
let s = c.status(&last).await;
|
|
assert_eq!(s["status"], "confirmed");
|
|
let mut entered: Vec<(&str, &str)> = s["entered"]
|
|
.as_array()
|
|
.expect("entered")
|
|
.iter()
|
|
.map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default()))
|
|
.collect();
|
|
entered.sort_unstable();
|
|
assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}");
|
|
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_the_chain_vote_is_off_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;
|
|
assert_eq!(doc["vote"]["way"], "chain");
|
|
assert_eq!(doc["vote"]["open"], false);
|
|
assert_eq!(doc["vote"]["reason"], "chain_off");
|
|
assert!(doc["vote"]["chain"].is_null());
|
|
assert_eq!(doc["canVote"], false);
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let i1 = version(&doc, "I1", "inForce");
|
|
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)));
|
|
let p = version(&essai, "I1", "inForce");
|
|
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
}
|
|
|
|
#[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", "inForce");
|
|
let essai = c.doc("essai").await;
|
|
let elsewhere = version(&essai, "I1", "inForce");
|
|
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;
|
|
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 never makes a text pass");
|
|
// 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);
|
|
}
|