forked from yvv/decision
Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
-- Objections « redit » / « contredit », typo fixes validated, and the messages
|
||||
-- of trustinWallet (2026-10-31, lot F, ADR 0010). Additive only: nothing is
|
||||
-- dropped, nothing is rewritten that an older image needs.
|
||||
--
|
||||
-- The contradiction signals of before become objections « contredit », in the
|
||||
-- same table: an older image (rollback) still reads them as signals, and every
|
||||
-- lifting still writes `lifted_at` / `lifted_by` as before.
|
||||
|
||||
ALTER TABLE signals ADD COLUMN kind TEXT NOT NULL DEFAULT 'contredit'; -- redit | contredit
|
||||
ALTER TABLE signals ADD COLUMN held_id TEXT; -- the version held back (not votable): the latest arrival, never one in force
|
||||
ALTER TABLE signals ADD COLUMN phase TEXT NOT NULL DEFAULT 'author'; -- author | objector | lifted
|
||||
ALTER TABLE signals ADD COLUMN cycle INTEGER NOT NULL DEFAULT 1; -- a reiteration opens the next
|
||||
ALTER TABLE signals ADD COLUMN due_at TEXT; -- phase objector: lifted by itself at this time
|
||||
ALTER TABLE signals ADD COLUMN answered_at TEXT; -- the last answer of the author
|
||||
ALTER TABLE signals ADD COLUMN answer TEXT; -- revision | other | proposal
|
||||
ALTER TABLE signals ADD COLUMN answer_id TEXT; -- the version revised, or the proposal linked
|
||||
ALTER TABLE signals ADD COLUMN lift TEXT; -- objector | admin | deadline | withdrawn | revision
|
||||
|
||||
-- The signals of before that were lifted stay lifted: by their author, by an
|
||||
-- administrator, or because the text changed (the rule of before).
|
||||
UPDATE signals SET phase = 'lifted', lift = CASE
|
||||
WHEN lifted_by = 'revision' THEN 'revision'
|
||||
WHEN lifted_by = author_id THEN 'objector'
|
||||
ELSE 'admin' END
|
||||
WHERE lifted_at IS NOT NULL;
|
||||
|
||||
-- The standing ones wait for the author of the version they now hold back: the
|
||||
-- latest arrival of the two (submitted last; the same instant: written last),
|
||||
-- unless it is in force — then the other. Both in force: none is held.
|
||||
UPDATE signals SET held_id = (
|
||||
SELECT CASE
|
||||
WHEN a.status = 'in_force' AND b.status = 'in_force' THEN NULL
|
||||
WHEN (IFNULL(a.submitted_at, a.created_at), a.rowid, a.id) > (IFNULL(b.submitted_at, b.created_at), b.rowid, b.id)
|
||||
THEN CASE WHEN a.status = 'in_force' THEN b.id ELSE a.id END
|
||||
ELSE CASE WHEN b.status = 'in_force' THEN a.id ELSE b.id END
|
||||
END
|
||||
FROM proposals a, proposals b
|
||||
WHERE a.id = signals.proposal_id AND b.id = signals.target_proposal_id)
|
||||
WHERE lifted_at IS NULL;
|
||||
|
||||
CREATE INDEX signals_proposal ON signals(proposal_id);
|
||||
CREATE INDEX signals_target ON signals(target_proposal_id);
|
||||
CREATE INDEX signals_due ON signals(phase, due_at);
|
||||
|
||||
-- A proposal written to answer an objection (« modifier l'article visé »).
|
||||
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
|
||||
|
||||
-- A typo fix written by someone other than the one who answers for the version:
|
||||
-- it waits for them (or for the deadline), then applies — or not.
|
||||
CREATE TABLE typos (
|
||||
id TEXT PRIMARY KEY,
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
writer_id TEXT NOT NULL REFERENCES identities(id), -- who wrote the fix
|
||||
answerer_id TEXT, -- the author of the version, who answers; NULL = administrators (no author)
|
||||
revision INTEGER NOT NULL, -- the revision it was written over
|
||||
base_title TEXT NOT NULL, -- the text it was written over
|
||||
base_body TEXT NOT NULL,
|
||||
title TEXT NOT NULL, -- the fix
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- pending | applied | refused | stale
|
||||
created_at TEXT NOT NULL,
|
||||
due_at TEXT NOT NULL, -- applied by itself then, if nobody answered
|
||||
settled_at TEXT,
|
||||
settled_by TEXT -- identity id, 'deadline', or 'changed' (stale)
|
||||
);
|
||||
CREATE INDEX typos_proposal ON typos(proposal_id, status);
|
||||
CREATE INDEX typos_due ON typos(status, due_at);
|
||||
|
||||
-- The revision a validated fix wrote, or the one that took it back (kind 'revert'),
|
||||
-- and who let it in: the identity that validated it, or 'deadline'.
|
||||
ALTER TABLE revisions ADD COLUMN typo_id TEXT;
|
||||
ALTER TABLE revisions ADD COLUMN answered_by TEXT;
|
||||
|
||||
-- The messages of trustinWallet: one person, one box. The text is told by the
|
||||
-- interface from the subject (in the language of the reader); the gestures
|
||||
-- follow the subject's state, so a message dealt with offers none.
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipient_id TEXT NOT NULL REFERENCES identities(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
topic TEXT NOT NULL, -- objection | typo
|
||||
subject_id TEXT NOT NULL, -- signals.id | typos.id
|
||||
kind TEXT NOT NULL, -- objection_raised | objection_reiterated | objection_answered | typo_pending
|
||||
cycle INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
due_at TEXT,
|
||||
read_at TEXT,
|
||||
done_at TEXT,
|
||||
outcome TEXT -- answered | lifted | reiterated | deadline | admin | withdrawn | applied | refused | stale
|
||||
);
|
||||
CREATE INDEX messages_box ON messages(recipient_id, created_at);
|
||||
CREATE INDEX messages_subject ON messages(topic, subject_id);
|
||||
@@ -206,7 +206,8 @@ fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> se
|
||||
|
||||
/// The electorate changes (the web of trust grows or shrinks): every few
|
||||
/// minutes, every document is passed through the engine again — after the
|
||||
/// usual participation of each group is measured again, once a day.
|
||||
/// usual participation of each group is measured again, once a day, and after
|
||||
/// the deadlines of three days are passed (`deadlines`).
|
||||
pub async fn sweeper(state: AppState) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
@@ -214,6 +215,10 @@ pub async fn sweeper(state: AppState) {
|
||||
if let Err(e) = crate::participation::refresh(&state, false).await {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
// Objections lifted and typo fixes applied at their deadline (three days).
|
||||
if let Err(e) = crate::deadlines::sweep(&state).await {
|
||||
tracing::warn!(error = %e, "deadlines");
|
||||
}
|
||||
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
|
||||
+276
-39
@@ -15,9 +15,12 @@
|
||||
//! - **The last one stands**, per proposal and per identity, in the order of
|
||||
//! the chain: a ballot confirmed late never overrides a more recent one.
|
||||
//!
|
||||
//! A background task reads the indexer for pending ballots every 10 seconds
|
||||
//! while there are some, lets them expire after 24 hours, and runs the
|
||||
//! adoption pass after each confirmation.
|
||||
//! A background task reads the indexer for pending ballots every 10 seconds,
|
||||
//! at most 50 a pass, the least recently read first: every pending ballot is
|
||||
//! read at least every ⌈n / 50⌉ passes, whether a page waits for it or not (a
|
||||
//! Ğecko code). A ballot is read one last time at its end (24 hours), then let
|
||||
//! go; the adoption pass runs after each confirmation. Ballots let go are purged
|
||||
//! after a while (a day for those the wallet never signed, a week for the others).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption::{self, Entered};
|
||||
@@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderValue, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use ld_engine::address;
|
||||
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
||||
use rand::RngCore;
|
||||
@@ -40,15 +45,26 @@ use trustin_access::VoteWay;
|
||||
|
||||
/// Every article of a long document, with room to spare.
|
||||
const MAX_LINES: usize = 200;
|
||||
/// Pending ballots one session may hold at once.
|
||||
/// Ballots one identity may hold at once: those pending, and those let go within
|
||||
/// the last hour (`DELETE`, the wallet did not sign) — whatever the session.
|
||||
const MAX_PENDING: i64 = 20;
|
||||
const DROPPED_WINDOW_SECS: i64 = 3600;
|
||||
/// A ballot not seen on the chain within this time is let go.
|
||||
pub const EXPIRES_HOURS: i64 = 24;
|
||||
/// At its end, a ballot no indexer could be read for — or whose comment waits
|
||||
/// for a final block — is kept and read again, this long at most.
|
||||
const GRACE_SECS: i64 = 3600;
|
||||
/// Let go, a ballot is purged, lines and all: a day after the wallet did not sign
|
||||
/// it, a week after it was not seen in time (under the article, it says so meanwhile).
|
||||
const PURGE_DROPPED_SECS: i64 = 24 * 3600;
|
||||
const PURGE_EXPIRED_SECS: i64 = 7 * 24 * 3600;
|
||||
/// The page asks often; the indexer is read at most this often per ballot.
|
||||
const RECHECK_SECS: i64 = 3;
|
||||
const WATCH_EVERY: Duration = Duration::from_secs(10);
|
||||
/// Pending ballots read per pass of the background task.
|
||||
/// Pending ballots read per pass of the background task, the least recently read first.
|
||||
const PER_PASS: i64 = 50;
|
||||
/// Ballots a history shows, the most recent first.
|
||||
const HISTORY: i64 = 200;
|
||||
|
||||
/// One confirmation at a time: « the last one stands » reads the other ballots of
|
||||
/// the identity, then writes; two at once could both read before either writes.
|
||||
@@ -104,6 +120,10 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010).
|
||||
if !st.votable(p) {
|
||||
return Err(AppError::Conflict("objection"));
|
||||
}
|
||||
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
|
||||
}
|
||||
Ok(out)
|
||||
@@ -226,14 +246,16 @@ pub async fn create(
|
||||
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
|
||||
None => None,
|
||||
};
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
|
||||
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
||||
let held: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ballots WHERE requested_by = ?
|
||||
AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(util::in_seconds(-DROPPED_WINDOW_SECS))
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if pending >= MAX_PENDING {
|
||||
if held >= MAX_PENDING {
|
||||
return Err(AppError::Conflict("too_many_ballots"));
|
||||
}
|
||||
let lines: Vec<ballot::Line> = casts
|
||||
@@ -333,6 +355,160 @@ pub async fn drop(
|
||||
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The receipt: the ballot itself, for the one who asked — never anyone else
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A title as stored (`{lang: text}`), as JSON; a bare text if it is not a map.
|
||||
fn title_json(raw: &str) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(raw).ok().filter(|v| v.is_object()).unwrap_or_else(|| json!(raw))
|
||||
}
|
||||
|
||||
/// `GET /api/ballots` — the history of the person's ballots, the most recent
|
||||
/// first: those of its identity only, and not those the wallet never signed.
|
||||
pub async fn mine(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
doc_id: String,
|
||||
group_slug: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
digest: String,
|
||||
status: String,
|
||||
reason: Option<String>,
|
||||
block: Option<i64>,
|
||||
voter: Option<String>,
|
||||
lines: i64,
|
||||
created_at: String,
|
||||
settled_at: Option<String>,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(&format!(
|
||||
"SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block,
|
||||
i.display_name AS voter, (SELECT COUNT(*) FROM ballot_lines l WHERE l.ballot_id = b.id) AS lines,
|
||||
b.created_at, b.settled_at
|
||||
FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id
|
||||
WHERE b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
||||
ORDER BY b.created_at DESC, b.rowid DESC LIMIT {HISTORY}"
|
||||
))
|
||||
.bind(&user.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let list: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) },
|
||||
"comment": format!("{}{}", ballot::PREFIX, r.digest), "status": r.status, "reason": r.reason,
|
||||
"block": r.block, "voter": r.voter, "lines": r.lines, "createdAt": r.created_at, "settledAt": r.settled_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "ballots": list })))
|
||||
}
|
||||
|
||||
/// `GET /api/ballots/{id}/receipt` — the ballot itself, to download: its lines,
|
||||
/// its salt, the canonical text they make, its seal and where it stands. With
|
||||
/// it, the person recomputes the seal and finds it on the chain; nobody else
|
||||
/// ever gets it (the chain shows who voted, never what).
|
||||
pub async fn receipt(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Response> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Extra {
|
||||
salt: String,
|
||||
signer: Option<String>,
|
||||
event_id: Option<String>,
|
||||
settled_at: Option<String>,
|
||||
title: String,
|
||||
}
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LineRow {
|
||||
proposal_id: String,
|
||||
revision: i64,
|
||||
choice: String,
|
||||
code: String,
|
||||
title: Option<String>,
|
||||
}
|
||||
let row = load(&state, &id).await?;
|
||||
if row.requested_by != user.id {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let extra = sqlx::query_as::<_, Extra>(
|
||||
"SELECT b.salt, b.signer, b.event_id, b.settled_at, d.title FROM ballots b JOIN documents d ON d.id = b.doc_id
|
||||
WHERE b.id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let lines = sqlx::query_as::<_, LineRow>(
|
||||
"SELECT l.proposal_id, l.revision, l.choice, u.code, p.title FROM ballot_lines l
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE l.ballot_id = ? ORDER BY l.proposal_id",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let sealed: Vec<ballot::Line> = lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
Some(ballot::Line {
|
||||
proposal_id: l.proposal_id.clone(),
|
||||
revision: u32::try_from(l.revision).ok()?,
|
||||
choice: Choice::parse(&l.choice)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let canonical = ballot::canonical(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||
let seal = ballot::seal(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||
if seal.digest != row.digest || sealed.len() != lines.len() {
|
||||
return Err(anyhow::anyhow!("ballot {id}: its lines no longer make its seal").into());
|
||||
}
|
||||
let voter: Option<String> = match &row.voter_id {
|
||||
Some(v) => {
|
||||
sqlx::query_scalar("SELECT display_name FROM identities WHERE id = ?")
|
||||
.bind(v)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let body = json!({
|
||||
"format": "ld1",
|
||||
"id": row.id,
|
||||
"doc": row.doc_id,
|
||||
"docTitle": title_json(&extra.title),
|
||||
"author": row.author,
|
||||
"lines": lines.iter().map(|l| json!({
|
||||
"proposalId": l.proposal_id, "revision": l.revision, "choice": l.choice, "article": l.code, "title": l.title,
|
||||
})).collect::<Vec<_>>(),
|
||||
"salt": extra.salt,
|
||||
"canonical": canonical,
|
||||
"digest": seal.digest,
|
||||
"comment": seal.comment,
|
||||
"remarkHash": seal.remark_hash,
|
||||
"account": row.account,
|
||||
"amount": row.amount,
|
||||
"status": row.status,
|
||||
"reason": row.reason,
|
||||
"block": row.block,
|
||||
"event": extra.event_id,
|
||||
"signer": extra.signer,
|
||||
"voter": voter,
|
||||
"createdAt": row.created_at,
|
||||
"settledAt": extra.settled_at,
|
||||
});
|
||||
let day = row.created_at.get(..10).unwrap_or("vote");
|
||||
let name = format!("recu-vote-{day}-{}.json", row.id.get(..8).unwrap_or(&row.id));
|
||||
let mut res = Json(body).into_response();
|
||||
let headers = res.headers_mut();
|
||||
if let Ok(v) = HeaderValue::from_str(&format!("attachment; filename=\"{name}\"")) {
|
||||
headers.insert(header::CONTENT_DISPOSITION, v);
|
||||
}
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
||||
let voter: Option<String> = match &row.voter_id {
|
||||
Some(v) => {
|
||||
@@ -352,29 +528,47 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value>
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the indexer for one pending ballot and settles it if the chain has spoken.
|
||||
/// Reads the indexer for one pending ballot and settles it if the chain has spoken
|
||||
/// — in a final block. At its end, a ballot is read one last time, then let go.
|
||||
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||
let now = util::now();
|
||||
if row.expires_at <= now {
|
||||
expire(state).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let reading = indexer::comments(state, &row.remark_hash).await;
|
||||
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
|
||||
let Reading::Found(list) = reading else {
|
||||
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
|
||||
return Ok(());
|
||||
};
|
||||
let comment = row.comment();
|
||||
let list: Vec<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
|
||||
let due = row.expires_at <= now;
|
||||
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
||||
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
|
||||
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
|
||||
};
|
||||
let expected =
|
||||
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
|
||||
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
|
||||
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
|
||||
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
|
||||
let comment = row.comment();
|
||||
// Only the comments that carry exactly the remark of the ballot.
|
||||
let judged = |list: &[Found]| {
|
||||
let mine: Vec<ballot::Comment> =
|
||||
list.iter().filter(|f| f.remark == comment).map(|f| f.comment.clone()).collect();
|
||||
ballot::judge(&mine, &expected)
|
||||
};
|
||||
let filter = indexer::filter(&row.account, amount, row.author.as_deref());
|
||||
let reading = indexer::comments(state, &row.remark_hash, &filter, |list| judged(list).is_some()).await;
|
||||
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
|
||||
let (list, unfinal) = match reading {
|
||||
Reading::Found { comments, unfinal } => (comments, unfinal),
|
||||
Reading::Silent => {
|
||||
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
|
||||
if past_grace {
|
||||
let_go(state, row).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some((position, verdict)) = judged(&list) else {
|
||||
// Nothing final settles it. At its end: let go, unless a comment waits for its block to be final.
|
||||
if due && (!unfinal || past_grace) {
|
||||
let_go(state, row).await?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let Some(found) = list.iter().find(|f| f.comment.position == position && f.remark == comment) else {
|
||||
return Ok(());
|
||||
};
|
||||
match verdict {
|
||||
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
||||
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
|
||||
@@ -516,25 +710,59 @@ async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lets go of the pending ballots not seen on the chain in time.
|
||||
/// Lets go of one pending ballot not seen on the chain in time.
|
||||
async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||
sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'")
|
||||
.bind(util::now())
|
||||
.bind(&row.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lets go of the pending ballots past their end and the grace after it: those
|
||||
/// the indexer could not be read for in time.
|
||||
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
||||
let now = util::now();
|
||||
let done = sqlx::query(
|
||||
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(util::now())
|
||||
.bind(util::in_seconds(-GRACE_SECS))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(done.rows_affected())
|
||||
}
|
||||
|
||||
/// One pass of the background task: expire, then read the indexer for each
|
||||
/// pending ballot, oldest first. Returns how many were read.
|
||||
/// Purges the ballots let go long enough ago, lines and all. A ballot let go
|
||||
/// never counted: nothing of a vote is lost.
|
||||
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
||||
const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL
|
||||
AND ((reason = 'dropped' AND settled_at < ?) OR settled_at < ?)";
|
||||
let (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS));
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})"))
|
||||
.bind(&dropped)
|
||||
.bind(&expired)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let done = sqlx::query(&format!("DELETE FROM ballots WHERE id IN ({WHICH})"))
|
||||
.bind(&dropped)
|
||||
.bind(&expired)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(done.rows_affected())
|
||||
}
|
||||
|
||||
/// One pass of the background task: let go and purge, then read the indexer for
|
||||
/// the pending ballots, the least recently read first (never read, then the
|
||||
/// oldest reading). Returns how many were read.
|
||||
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||
expire(state).await?;
|
||||
purge(state).await?;
|
||||
let rows = sqlx::query_as::<_, BallotRow>(&format!(
|
||||
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
|
||||
"{BALLOT_SELECT} WHERE status = 'pending'
|
||||
ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
@@ -600,9 +828,10 @@ pub struct MyBallot {
|
||||
pub choice: String,
|
||||
}
|
||||
|
||||
/// Per version: the most recent pending ballot of `me`; else its most recent
|
||||
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
|
||||
/// A ballot the wallet never signed (dropped) says nothing.
|
||||
/// Per version: the most recent pending ballot of `me` asked after its last
|
||||
/// confirmed one; else its most recent ballot if that one was refused or let go
|
||||
/// (a confirmed one shows as its vote). A ballot the wallet never signed
|
||||
/// (dropped) says nothing; one asked before a confirmed vote says nothing either.
|
||||
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
@@ -626,12 +855,20 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
||||
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
||||
for r in rows {
|
||||
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
|
||||
if b.status == "pending" {
|
||||
pending.insert(r.proposal_id.clone(), b.clone());
|
||||
match b.status.as_str() {
|
||||
// A confirmed vote: what was asked before it says nothing more.
|
||||
"confirmed" => {
|
||||
latest.remove(&r.proposal_id);
|
||||
pending.remove(&r.proposal_id);
|
||||
}
|
||||
"pending" => {
|
||||
pending.insert(r.proposal_id, b);
|
||||
}
|
||||
_ => {
|
||||
latest.insert(r.proposal_id, b);
|
||||
}
|
||||
}
|
||||
latest.insert(r.proposal_id, b);
|
||||
}
|
||||
latest.retain(|_, b| b.status != "confirmed");
|
||||
latest.extend(pending);
|
||||
Ok(latest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The clock of the deadlines (objections, typo fixes): the time of the
|
||||
//! machine, plus an offset that only tests move — the API tests directly, the
|
||||
//! end-to-end tests through `POST /api/dev/clock`, open with the dev login only.
|
||||
//! A deadline is never waited for with a sleep.
|
||||
|
||||
use crate::util;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clock {
|
||||
offset_secs: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
impl Clock {
|
||||
pub fn at(&self) -> OffsetDateTime {
|
||||
OffsetDateTime::now_utc() + Duration::seconds(self.offset_secs.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Now, as a sortable stamp (RFC 3339 UTC, whole seconds).
|
||||
pub fn now(&self) -> String {
|
||||
util::stamp(self.at())
|
||||
}
|
||||
|
||||
pub fn in_days(&self, days: i64) -> String {
|
||||
util::stamp(self.at() + Duration::days(days))
|
||||
}
|
||||
|
||||
/// Moves the clock forward (tests only).
|
||||
pub fn advance(&self, secs: i64) {
|
||||
self.offset_secs.fetch_add(secs, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset_secs.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advancing_moves_now_and_the_deadlines() {
|
||||
let c = Clock::default();
|
||||
let before = c.now();
|
||||
c.advance(3 * 86_400);
|
||||
assert!(c.now() > before);
|
||||
assert!(c.in_days(0) >= c.now());
|
||||
assert_eq!(c.offset(), 3 * 86_400);
|
||||
// Clones share the offset: the server and its tasks read one clock.
|
||||
let other = c.clone();
|
||||
other.advance(1);
|
||||
assert_eq!(c.offset(), 3 * 86_400 + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The deadlines of three days (2026-10-31, lot F): an objection whose author
|
||||
//! answered and whose objector said nothing is lifted; a typo fix nobody
|
||||
//! answered applies. Run by the adoption sweeper (every 5 minutes) and once at
|
||||
//! start, so a deadline passed while the server was stopped is dealt with as
|
||||
//! soon as it starts again. Tests move the clock (`AppState::clock`), never sleep.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{objections, typos};
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Swept {
|
||||
/// Objections lifted at their deadline.
|
||||
pub lifted: usize,
|
||||
/// Typo fixes applied at their deadline.
|
||||
pub applied: usize,
|
||||
/// Typo fixes that could not apply: their text was gone.
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// One pass: the objections, then the typo fixes, then the adoption of the
|
||||
/// documents where an objection was lifted (a version may now enter into force).
|
||||
pub async fn sweep(state: &AppState) -> AppResult<Swept> {
|
||||
let (lifted, docs) = objections::due(state).await?;
|
||||
let fixes = typos::due(state).await?;
|
||||
for doc in &docs {
|
||||
adoption::run(state, doc).await?;
|
||||
}
|
||||
Ok(Swept { lifted, applied: fixes.applied, stale: fixes.stale })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClockBody {
|
||||
seconds: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/dev/clock` — the end-to-end tests move the server's clock forward
|
||||
/// and pass the deadlines at once. Open with the dev login only, like it.
|
||||
pub async fn dev_clock(State(state): State<AppState>, Json(b): Json<ClockBody>) -> AppResult<Json<Value>> {
|
||||
if !state.cfg.dev_login {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
if b.seconds <= 0 || b.seconds > 400 * 86_400 {
|
||||
return Err(AppError::Invalid("seconds"));
|
||||
}
|
||||
state.clock.advance(b.seconds);
|
||||
let swept = sweep(&state).await?;
|
||||
Ok(Json(json!({
|
||||
"now": state.clock.now(), "offset": state.clock.offset(),
|
||||
"lifted": swept.lifted, "applied": swept.applied, "stale": swept.stale,
|
||||
})))
|
||||
}
|
||||
@@ -73,6 +73,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this identity open the group now (see `trustin_access::opens`)?
|
||||
pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
Ok(trustin_access::opens(&user.to_identity(), &group.to_group(), has))
|
||||
}
|
||||
|
||||
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
@@ -259,7 +265,9 @@ pub struct DocState {
|
||||
pub module_kinds: HashMap<String, ModuleKind>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
/// Versions held back by a standing objection: not votable, never entering into force.
|
||||
pub blocked: HashSet<String>,
|
||||
/// Standing objections touching each version, either side.
|
||||
pub signal_counts: HashMap<String, i64>,
|
||||
pub electorate: u32,
|
||||
pub electorate_source: &'static str,
|
||||
@@ -344,32 +352,9 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SignalRow {
|
||||
proposal_id: String,
|
||||
target_proposal_id: String,
|
||||
}
|
||||
let signals = sqlx::query_as::<_, SignalRow>(
|
||||
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
|
||||
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||
let mut blocked = HashSet::new();
|
||||
let mut signal_counts: HashMap<String, i64> = HashMap::new();
|
||||
for s in &signals {
|
||||
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
|
||||
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
|
||||
// Two texts that contradict each other never stand in force together.
|
||||
if in_force.contains(s.target_proposal_id.as_str()) {
|
||||
blocked.insert(s.proposal_id.clone());
|
||||
}
|
||||
if in_force.contains(s.proposal_id.as_str()) {
|
||||
blocked.insert(s.target_proposal_id.clone());
|
||||
}
|
||||
}
|
||||
// A standing objection holds back the latest arrival of its pair (never a version
|
||||
// in force): not votable, and it cannot enter into force (ADR 0010).
|
||||
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
|
||||
let (electorate, electorate_source) = electorate(state, &group).await?;
|
||||
let measure = participation::stored(state, &group.slug).await?;
|
||||
let participation = Participation::of(
|
||||
@@ -410,6 +395,25 @@ impl DocState {
|
||||
self.tallies.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The totals shown to people: today the live count, as `tally`. The one
|
||||
/// place where a publication by steps would plug in — on the chain, who
|
||||
/// voted and when is public, and live totals tell what (the secret of the
|
||||
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
|
||||
/// `tally`; the published texts (`publish.rs`) would follow this one.
|
||||
pub fn shown_tally(&self, id: &str) -> Tally {
|
||||
self.tally(id)
|
||||
}
|
||||
|
||||
/// How far the threshold is, as shown: on `shown_tally`.
|
||||
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// Open to a vote: open or in force, and not held back by an objection.
|
||||
pub fn votable(&self, p: &ProposalRow) -> bool {
|
||||
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
|
||||
}
|
||||
|
||||
/// The module a unit is, if it is one.
|
||||
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
||||
self.module_kinds.get(unit_id).copied()
|
||||
@@ -627,7 +631,7 @@ fn proposal_json(
|
||||
me: &str,
|
||||
) -> serde_json::Value {
|
||||
let (mine, ballot) = mine;
|
||||
let eval = st.evaluation(unit, p);
|
||||
let eval = st.shown_evaluation(unit, p);
|
||||
let (kind, target) = match p.kind() {
|
||||
// A module's version says what it holds (ADR 0009).
|
||||
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
|
||||
@@ -646,11 +650,12 @@ fn proposal_json(
|
||||
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||
"tally": st.tally(&p.id),
|
||||
"tally": st.shown_tally(&p.id),
|
||||
"evaluation": eval,
|
||||
"votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id),
|
||||
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||
"votable": st.votable(p),
|
||||
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||
"myVote": mine.map(|v| v.choice.as_str()),
|
||||
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
||||
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
|
||||
|
||||
+200
-39
@@ -7,6 +7,25 @@
|
||||
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
|
||||
//! errors, is skipped; one that answers « nothing » may be behind, so the next
|
||||
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
|
||||
//!
|
||||
//! **Finality.** The indexer serves the best chain, blocks not yet final
|
||||
//! included. `squidStatus` exposes `height` and `hash` only (no
|
||||
//! `finalizedHeight`); `height` is the last final block it processed — relevé
|
||||
//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block
|
||||
//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the
|
||||
//! finality of Duniter v2. Only a comment at or below it settles a ballot: a
|
||||
//! comment above may still vanish with its block.
|
||||
//!
|
||||
//! **What is asked.** Only what may settle the ballot: for a ballot bound to an
|
||||
//! account, that account's comments; for one bound to none (a Ğecko code), the
|
||||
//! comments paid to the vote account — a bare copy of a remark costs nothing to
|
||||
//! send, and must never hide the real one. The engine judges again what comes
|
||||
//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes
|
||||
//! nothing but the number of pages.
|
||||
//!
|
||||
//! **Pages.** In the order of the chain, 50 comments a page, page after page
|
||||
//! until a comment settles the ballot, a block not yet final is reached, or the
|
||||
//! last page — at most 20 pages a reading.
|
||||
|
||||
use crate::AppState;
|
||||
use ld_engine::address;
|
||||
@@ -15,11 +34,19 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
|
||||
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
|
||||
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
|
||||
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
|
||||
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
|
||||
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
|
||||
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
|
||||
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \
|
||||
nodes { id blockNumber remark author { id identity { index name isMember } } \
|
||||
transfersByCommentId { nodes { fromId toId amount } } } } }";
|
||||
|
||||
/// Pages read at most for one ballot, one reading.
|
||||
pub const MAX_PAGES: usize = 20;
|
||||
/// The largest answer read: a page of 50 comments weighs about 30 KB.
|
||||
const MAX_BODY: usize = 1 << 20;
|
||||
/// A Ğ1 identity name is 42 characters at most.
|
||||
const MAX_NAME: usize = 64;
|
||||
const TIMEOUT: Duration = Duration::from_secs(6);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -32,7 +59,28 @@ struct Answer {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Data {
|
||||
tx_comments: Nodes<Node>,
|
||||
squid_status: Status,
|
||||
tx_comments: Connection,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Status {
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Connection {
|
||||
#[serde(default)]
|
||||
page_info: Option<PageInfo>,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PageInfo {
|
||||
has_next_page: bool,
|
||||
end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -85,12 +133,23 @@ pub struct Found {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// One page of an answer.
|
||||
#[derive(Debug)]
|
||||
pub struct Page {
|
||||
/// The last final block (`squidStatus.height`).
|
||||
pub finalized: u32,
|
||||
pub comments: Vec<Found>,
|
||||
/// The cursor of the next page, if there is one.
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Reading {
|
||||
/// No indexer answered.
|
||||
Silent,
|
||||
/// The comments found (possibly none).
|
||||
Found(Vec<Found>),
|
||||
/// What an indexer answered: the comments in final blocks, in the order of the
|
||||
/// chain (possibly none), and whether others wait in blocks not yet final.
|
||||
Found { comments: Vec<Found>, unfinal: bool },
|
||||
}
|
||||
|
||||
fn amount(v: &serde_json::Value) -> Option<u64> {
|
||||
@@ -106,6 +165,9 @@ fn found(node: Node) -> Option<Found> {
|
||||
if position.block != node.block_number {
|
||||
return None;
|
||||
}
|
||||
if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) {
|
||||
return None;
|
||||
}
|
||||
let (_, author) = address::decode(&node.author.id)?;
|
||||
let transfers = node
|
||||
.transfers_by_comment_id
|
||||
@@ -133,59 +195,127 @@ fn found(node: Node) -> Option<Found> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The comments of one answer of the indexer; `None` if it is not one.
|
||||
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
|
||||
/// One page of an answer of the indexer; `None` if it is not one.
|
||||
pub fn parse(body: &serde_json::Value) -> Option<Page> {
|
||||
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
||||
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
||||
return None;
|
||||
}
|
||||
let nodes = answer.data?.tx_comments.nodes;
|
||||
let data = answer.data?;
|
||||
let nodes = data.tx_comments.nodes;
|
||||
let count = nodes.len();
|
||||
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
||||
if out.len() != count {
|
||||
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
|
||||
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
||||
if comments.len() != count {
|
||||
tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside");
|
||||
}
|
||||
Some(out)
|
||||
let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor);
|
||||
Some(Page { finalized: data.squid_status.height, comments, next })
|
||||
}
|
||||
|
||||
/// The comments carrying the remark of hash `remark_hash` (`0x…`).
|
||||
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
|
||||
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
|
||||
/// What may settle a ballot, asked of the indexer: the comments of the bound
|
||||
/// account; unbound, those paid to the vote account — at least the amount.
|
||||
pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value {
|
||||
match author {
|
||||
Some(a) => json!({ "authorId": { "equalTo": a } }),
|
||||
None => json!({ "transfersByCommentId": { "some": {
|
||||
"toId": { "equalTo": account },
|
||||
"amount": { "greaterThanOrEqualTo": amount.to_string() },
|
||||
} } }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a body of at most `MAX_BODY` bytes as JSON.
|
||||
async fn read_json(mut res: reqwest::Response) -> Option<serde_json::Value> {
|
||||
if res.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = res.chunk().await.ok()? {
|
||||
if buf.len() + chunk.len() > MAX_BODY {
|
||||
return None;
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&buf).ok()
|
||||
}
|
||||
|
||||
async fn page(state: &AppState, url: &str, body: &serde_json::Value) -> Option<Page> {
|
||||
let res = state.http.post(url).timeout(TIMEOUT).json(body).send().await;
|
||||
let value = match res {
|
||||
Ok(r) if r.status().is_success() => read_json(r).await,
|
||||
Ok(r) => {
|
||||
tracing::debug!(%url, status = %r.status(), "indexer");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%url, error = %e, "indexer");
|
||||
None
|
||||
}
|
||||
};
|
||||
value.as_ref().and_then(parse)
|
||||
}
|
||||
|
||||
/// The comments in final blocks that carry the remark of hash `remark_hash`
|
||||
/// (`0x…`) and pass `filter`, page after page until `settled` says one settles
|
||||
/// the ballot.
|
||||
pub async fn comments(
|
||||
state: &AppState,
|
||||
remark_hash: &str,
|
||||
filter: &serde_json::Value,
|
||||
settled: impl Fn(&[Found]) -> bool,
|
||||
) -> Reading {
|
||||
let mut answered = false;
|
||||
for url in &state.cfg.indexers {
|
||||
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
|
||||
let value: Option<serde_json::Value> = match res {
|
||||
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
||||
Ok(r) => {
|
||||
tracing::debug!(%url, status = %r.status(), "indexer");
|
||||
None
|
||||
let mut kept: Vec<Found> = Vec::new();
|
||||
let mut unfinal = false;
|
||||
let mut after: Option<String> = None;
|
||||
let mut finalized: Option<u32> = None;
|
||||
for n in 0..MAX_PAGES {
|
||||
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } });
|
||||
let Some(p) = page(state, url, &body).await else { break };
|
||||
// The finality of the first page holds for the reading: later pages may only see more.
|
||||
let last_final = *finalized.get_or_insert(p.finalized);
|
||||
answered = true;
|
||||
for f in p.comments {
|
||||
if f.comment.position.block > last_final {
|
||||
unfinal = true;
|
||||
} else {
|
||||
kept.push(f);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%url, error = %e, "indexer");
|
||||
None
|
||||
if unfinal || settled(&kept) {
|
||||
break;
|
||||
}
|
||||
};
|
||||
match value.as_ref().and_then(parse) {
|
||||
Some(list) if !list.is_empty() => return Reading::Found(list),
|
||||
Some(_) => answered = true,
|
||||
None => {}
|
||||
match p.next {
|
||||
Some(cursor) => after = Some(cursor),
|
||||
None => break,
|
||||
}
|
||||
if n + 1 == MAX_PAGES {
|
||||
tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending");
|
||||
}
|
||||
}
|
||||
if !kept.is_empty() || unfinal {
|
||||
return Reading::Found { comments: kept, unfinal };
|
||||
}
|
||||
}
|
||||
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
|
||||
if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
|
||||
/// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), for the remark « Merci ».
|
||||
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
|
||||
|
||||
#[test]
|
||||
fn a_real_answer_reads() {
|
||||
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
||||
let list = parse(&body).expect("an answer");
|
||||
assert_eq!(list.len(), 4);
|
||||
let page = parse(&body).expect("an answer");
|
||||
assert_eq!(page.finalized, 3_407_492);
|
||||
assert_eq!(page.next, None, "one page: 26 comments, fewer than 50");
|
||||
let list = page.comments;
|
||||
assert_eq!(list.len(), 26, "the old query stopped at 20");
|
||||
let first = &list[0];
|
||||
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
||||
assert_eq!(first.remark, "Merci ");
|
||||
@@ -198,6 +328,8 @@ mod tests {
|
||||
// An account without identity.
|
||||
assert_eq!(list[2].comment.identity, None);
|
||||
assert_eq!(list[2].name, None);
|
||||
// In the order of the chain.
|
||||
assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,19 +337,48 @@ mod tests {
|
||||
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
||||
assert!(parse(&json!({ "data": null })).is_none());
|
||||
assert!(parse(&json!({ "nope": 1 })).is_none());
|
||||
assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0));
|
||||
// Without the finality, an answer cannot settle anything: no answer.
|
||||
assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none());
|
||||
let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } });
|
||||
assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None)));
|
||||
let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": {
|
||||
"pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } });
|
||||
assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_that_does_not_read_is_left_aside() {
|
||||
let body = json!({ "data": { "txComments": { "nodes": [
|
||||
let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
|
||||
let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
|
||||
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
|
||||
"author": { "id": author, "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": "not an address", "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": "z".repeat(100_000), "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
] } } });
|
||||
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
|
||||
assert_eq!(
|
||||
parse(&body).map(|p| p.comments.len()),
|
||||
Some(0),
|
||||
"block and id disagree; a bad address; a long one; a name no identity has"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_asked_is_what_may_settle() {
|
||||
let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM";
|
||||
let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
|
||||
assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } }));
|
||||
assert_eq!(
|
||||
filter(vote, 10, None),
|
||||
json!({ "transfersByCommentId": { "some": {
|
||||
"toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,27 @@
|
||||
//!
|
||||
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
||||
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
||||
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
|
||||
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
|
||||
//! that client applications read).
|
||||
//! web of trust), the adoption sweeper (with the deadlines of three days of
|
||||
//! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain,
|
||||
//! confirmed on the indexer) and the publisher (adopted texts pushed to the git
|
||||
//! repository that client applications read).
|
||||
|
||||
pub mod access;
|
||||
pub mod adoption;
|
||||
pub mod auth;
|
||||
pub mod ballots;
|
||||
pub mod clock;
|
||||
pub mod config;
|
||||
pub mod deadlines;
|
||||
pub mod doc_title;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod indexer;
|
||||
pub mod inertia_amend;
|
||||
pub mod messages;
|
||||
pub mod module_amend;
|
||||
pub mod modules;
|
||||
pub mod objections;
|
||||
pub mod order;
|
||||
pub mod overview;
|
||||
pub mod participation;
|
||||
@@ -26,6 +31,7 @@ pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod typos;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
pub mod wot;
|
||||
@@ -54,6 +60,8 @@ pub struct AppState {
|
||||
pub publish_wake: Arc<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
||||
pub clock: clock::Clock,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -63,7 +71,14 @@ impl AppState {
|
||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
|
||||
Ok(Self {
|
||||
db,
|
||||
cfg: Arc::new(cfg),
|
||||
http,
|
||||
publish_wake: Arc::new(Notify::new()),
|
||||
upcoming,
|
||||
clock: clock::Clock::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,15 +140,25 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/docs/{id}/proposals", post(proposals::create))
|
||||
.route("/docs/{id}/votes", post(ballots::vote_many))
|
||||
.route("/docs/{id}/ballots", post(ballots::create))
|
||||
.route("/ballots", get(ballots::mine))
|
||||
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
||||
.route("/ballots/{id}/receipt", get(ballots::receipt))
|
||||
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
||||
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||
.route("/proposals/{id}/split", post(proposals::split))
|
||||
.route("/proposals/{id}/vote", post(proposals::vote))
|
||||
.route("/proposals/{id}/signals", post(proposals::signal))
|
||||
.route("/proposals/{id}/objections", post(objections::raise))
|
||||
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||
.route("/signals/{id}/lift", post(proposals::lift_signal))
|
||||
.route("/objections/{id}/lift", post(objections::lift))
|
||||
.route("/objections/{id}/reiterate", post(objections::reiterate))
|
||||
.route("/typos/{id}/validate", post(typos::validate))
|
||||
.route("/typos/{id}/refuse", post(typos::refuse))
|
||||
.route("/messages", get(messages::list))
|
||||
.route("/messages/unread", get(messages::unread))
|
||||
.route("/messages/read", post(messages::read_all))
|
||||
.route("/messages/{id}/read", post(messages::read))
|
||||
.route("/dev/clock", post(deadlines::dev_clock))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/units/{id}/order", post(order::propose))
|
||||
.route("/units/{id}/title", post(doc_title::propose))
|
||||
@@ -216,6 +241,12 @@ pub async fn run() -> anyhow::Result<()> {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
auth::bootstrap(&state).await?;
|
||||
// A deadline passed while the server was stopped is dealt with now.
|
||||
match deadlines::sweep(&state).await {
|
||||
Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "deadlines"),
|
||||
}
|
||||
if let Err(e) = publish::ensure_key(&state).await {
|
||||
tracing::warn!(error = %e, "publication key");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the
|
||||
//! application, in the menu, with the number of unread messages — no mail.
|
||||
//!
|
||||
//! A message is about a subject (an objection, a typo fix) and carries the
|
||||
//! gestures that subject asks of its recipient now — lift or reiterate,
|
||||
//! validate or refuse — and its deadline. A message dealt with offers no
|
||||
//! gesture any more. Its text is told by the interface, in the language of the
|
||||
//! reader, from the subject. Each person reads their own messages only
|
||||
//! (`trustin_access::reads_message`); acting on a subject marks one's messages
|
||||
//! about it as read.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{docs, objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The most recent messages shown in the box.
|
||||
const SHOWN: i64 = 100;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Topic {
|
||||
Objection,
|
||||
Typo,
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objection => "objection",
|
||||
Self::Typo => "typo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a message in someone's box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn notify(
|
||||
state: &AppState,
|
||||
recipient: &str,
|
||||
doc_id: &str,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
due: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::id())
|
||||
.bind(recipient)
|
||||
.bind(doc_id)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.bind(cycle)
|
||||
.bind(state.clock.now())
|
||||
.bind(due)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The messages about a subject (of some kinds, or all) are dealt with: what became of it.
|
||||
pub async fn close(
|
||||
state: &AppState,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kinds: Option<&[&str]>,
|
||||
outcome: &str,
|
||||
) -> AppResult<()> {
|
||||
let now = state.clock.now();
|
||||
match kinds {
|
||||
None => {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Some(kinds) => {
|
||||
for kind in kinds {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ?
|
||||
WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Someone acted on a subject: their messages about it are read.
|
||||
pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL",
|
||||
)
|
||||
.bind(state.clock.now())
|
||||
.bind(recipient)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unread_of(state: &AppState, recipient: &str) -> AppResult<i64> {
|
||||
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(recipient)
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MessageRow {
|
||||
id: String,
|
||||
recipient_id: String,
|
||||
doc_id: String,
|
||||
topic: String,
|
||||
subject_id: String,
|
||||
kind: String,
|
||||
cycle: i64,
|
||||
created_at: String,
|
||||
due_at: Option<String>,
|
||||
read_at: Option<String>,
|
||||
done_at: Option<String>,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DocLine {
|
||||
group_slug: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
|
||||
let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?")
|
||||
.bind(&m.doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let Some(doc) = doc else { return Ok(None) };
|
||||
// A message tells what is in a group: shown only while its recipient opens it.
|
||||
if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let (subject, gestures) = match m.topic.as_str() {
|
||||
"objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? {
|
||||
Some((v, g)) => (json!({ "objection": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
"typo" => match typos::for_message(state, me, &m.subject_id).await? {
|
||||
Some((v, g)) => (json!({ "typo": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let mut out = json!({
|
||||
"id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at,
|
||||
"dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(),
|
||||
"outcome": m.outcome, "gestures": gestures,
|
||||
"doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) },
|
||||
});
|
||||
if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) {
|
||||
o.extend(s);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// `GET /api/messages` — one's own box, newest first.
|
||||
pub async fn list(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
let rows = sqlx::query_as::<_, MessageRow>(
|
||||
"SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome
|
||||
FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
)
|
||||
.bind(&me.id)
|
||||
.bind(SHOWN)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for m in &rows {
|
||||
if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = view(&state, &me, m).await? {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out })))
|
||||
}
|
||||
|
||||
/// `GET /api/messages/unread` — the number next to « Messages » in the menu.
|
||||
pub async fn unread(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/{id}/read` — one's own message only (404 otherwise).
|
||||
pub async fn read(State(state): State<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
if !trustin_access::reads_message(&me.to_identity(), &recipient) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/read` — every message of one's box.
|
||||
pub async fn read_all(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&me.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": 0 })))
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
|
||||
//!
|
||||
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
|
||||
//! the pair, never a version in force, is no longer votable while the objection
|
||||
//! stands; its author answers (a substantive revision, or a proposal linked to
|
||||
//! the objection — a revision of the other side answers too); the objector is
|
||||
//! then asked, by a message, to lift or reiterate; without an answer within 3
|
||||
//! days the objection is lifted (`due`, run by the sweeper and at start).
|
||||
//!
|
||||
//! Objections live in the table of the contradiction signals of before
|
||||
//! (`signals`, migration 0008): an older image still reads them as signals, and
|
||||
//! every lifting writes `lifted_at` / `lifted_by` as it did.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::messages::{self, Topic};
|
||||
use crate::proposals;
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::edit;
|
||||
use ld_engine::objection::{
|
||||
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub const MIN_REASON: usize = 10;
|
||||
pub const MAX_REASON: usize = 600;
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct ObjectionRow {
|
||||
pub id: String,
|
||||
/// The version the objection was raised from.
|
||||
pub proposal_id: String,
|
||||
/// The version it was raised against.
|
||||
pub target_proposal_id: String,
|
||||
pub reason: String,
|
||||
/// The objector.
|
||||
pub author_id: String,
|
||||
pub created_at: String,
|
||||
pub lifted_at: Option<String>,
|
||||
pub lifted_by: Option<String>,
|
||||
pub kind: String,
|
||||
/// The version held back (not votable).
|
||||
pub held_id: Option<String>,
|
||||
pub phase: String,
|
||||
pub cycle: i64,
|
||||
pub due_at: Option<String>,
|
||||
pub answered_at: Option<String>,
|
||||
pub answer: Option<String>,
|
||||
pub answer_id: Option<String>,
|
||||
pub lift: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
|
||||
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
|
||||
|
||||
impl ObjectionRow {
|
||||
pub fn phase(&self) -> Phase {
|
||||
let cycle = u32::try_from(self.cycle).unwrap_or(1);
|
||||
match self.phase.as_str() {
|
||||
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
|
||||
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
|
||||
_ => Phase::Author { cycle },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ObjectionKind {
|
||||
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
|
||||
}
|
||||
|
||||
/// The version the held one is said to repeat or contradict.
|
||||
pub fn other_id(&self) -> &str {
|
||||
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
|
||||
&self.target_proposal_id
|
||||
} else {
|
||||
&self.proposal_id
|
||||
}
|
||||
}
|
||||
|
||||
fn touches(&self, id: &str) -> Option<Touched> {
|
||||
if self.held_id.as_deref() == Some(id) {
|
||||
Some(Touched::Target)
|
||||
} else if self.proposal_id == id || self.target_proposal_id == id {
|
||||
Some(Touched::Other)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
|
||||
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Standing objections touching one version, either side.
|
||||
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
|
||||
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// For a document: the versions held back by a standing objection (never one in
|
||||
/// force), and how many standing objections touch each version.
|
||||
pub async fn held(
|
||||
state: &AppState,
|
||||
doc_id: &str,
|
||||
units: &[UnitRow],
|
||||
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
|
||||
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
|
||||
))
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||
let mut held = HashSet::new();
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
for o in &rows {
|
||||
*counts.entry(o.proposal_id.clone()).or_default() += 1;
|
||||
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
|
||||
if let Some(h) = &o.held_id
|
||||
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
|
||||
{
|
||||
held.insert(h.clone());
|
||||
}
|
||||
}
|
||||
Ok((held, counts))
|
||||
}
|
||||
|
||||
/// The order a version arrived in: its submission (or creation), then the order it was written in.
|
||||
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
|
||||
let seq: i64 =
|
||||
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
|
||||
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
|
||||
}
|
||||
|
||||
fn is_in_force(st: &DocState, id: &str) -> bool {
|
||||
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
|
||||
}
|
||||
|
||||
fn who(user: &IdentityRow, objector: &str) -> Who {
|
||||
let me = user.to_identity();
|
||||
if trustin_access::reiterates_objection(&me, objector) {
|
||||
Who::Objector
|
||||
} else if trustin_access::lifts_objection(&me, objector) {
|
||||
Who::Admin
|
||||
} else {
|
||||
Who::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(r: Refusal) -> AppError {
|
||||
match r {
|
||||
Refusal::Lifted => AppError::Conflict("lifted"),
|
||||
Refusal::NotYours => AppError::Forbidden,
|
||||
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Stores a step, only over the phase it was computed from (two people acting at
|
||||
/// once: the second gets a conflict), then does its effects. Returns false when
|
||||
/// the phase had changed meanwhile.
|
||||
async fn store(
|
||||
state: &AppState,
|
||||
o: &ObjectionRow,
|
||||
step: &Step,
|
||||
by: &str,
|
||||
extra: Option<(&str, &str)>,
|
||||
) -> AppResult<bool> {
|
||||
let now = state.clock.now();
|
||||
let (phase, cycle, due, lift) = match &step.phase {
|
||||
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
|
||||
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
|
||||
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
|
||||
};
|
||||
let (answer, answer_id) =
|
||||
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
|
||||
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
|
||||
let done = sqlx::query(
|
||||
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
|
||||
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
|
||||
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
|
||||
WHERE id = ? AND phase = ? AND cycle = ?",
|
||||
)
|
||||
.bind(phase)
|
||||
.bind(cycle)
|
||||
.bind(&due)
|
||||
.bind(lift.map(Lift::as_str))
|
||||
.bind(&answer)
|
||||
.bind(&answer_id)
|
||||
.bind(&answered_at)
|
||||
.bind(phase)
|
||||
.bind(&now)
|
||||
.bind(phase)
|
||||
.bind(by)
|
||||
.bind(&o.id)
|
||||
.bind(&o.phase)
|
||||
.bind(o.cycle)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc_id = doc_of(state, &o.proposal_id).await?;
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
for effect in &step.effects {
|
||||
match effect {
|
||||
Effect::AskObjector => {
|
||||
// The author's question of this cycle is answered.
|
||||
messages::close(
|
||||
state,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
Some(&["objection_raised", "objection_reiterated"]),
|
||||
"answered",
|
||||
)
|
||||
.await?;
|
||||
if held_author.as_deref() != Some(o.author_id.as_str()) {
|
||||
messages::notify(
|
||||
state,
|
||||
&o.author_id,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_answered",
|
||||
cycle,
|
||||
due.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::TellAuthor => {
|
||||
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
|
||||
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
|
||||
messages::notify(
|
||||
state,
|
||||
author,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_reiterated",
|
||||
cycle,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::Ended => {
|
||||
let outcome = match lift {
|
||||
Some(Lift::Admin) => "admin",
|
||||
Some(Lift::Deadline) => "deadline",
|
||||
Some(Lift::Withdrawn) => "withdrawn",
|
||||
_ => "lifted",
|
||||
};
|
||||
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raise, lift, reiterate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RaiseBody {
|
||||
other_id: String,
|
||||
kind: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
|
||||
/// contradicts) that one ». The latest arrival of the two is held back.
|
||||
pub async fn raise(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<RaiseBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (p, st) = proposals::guarded(&state, &user, &id).await?;
|
||||
let reason = b.reason.trim();
|
||||
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
||||
return Err(AppError::Invalid("reason"));
|
||||
}
|
||||
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
|
||||
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
|
||||
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
|
||||
if other.id == p.id || !voted(other) || !voted(&p) {
|
||||
return Err(AppError::Invalid("other"));
|
||||
}
|
||||
if other.unit_id == p.unit_id {
|
||||
// Rivals on one article never stand together anyway.
|
||||
return Err(AppError::Invalid("same_unit"));
|
||||
}
|
||||
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
|
||||
let held = objection::target(
|
||||
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
|
||||
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
|
||||
objection::PairError::Same => AppError::Invalid("other"),
|
||||
})?
|
||||
.to_owned();
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
|
||||
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if exists > 0 {
|
||||
return Err(AppError::Conflict("objection_exists"));
|
||||
}
|
||||
let oid = util::id();
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
||||
)
|
||||
.bind(&oid)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.bind(reason)
|
||||
.bind(&user.id)
|
||||
.bind(state.clock.now())
|
||||
.bind(kind.as_str())
|
||||
.bind(&held)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
|
||||
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
|
||||
}
|
||||
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
|
||||
Ok(Json(json!({ "id": oid, "heldId": held })))
|
||||
}
|
||||
|
||||
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
|
||||
let o = load(state, id).await?;
|
||||
proposals::guarded(state, user, &o.proposal_id).await?;
|
||||
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
|
||||
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
|
||||
if !store(state, &o, &step, &user.id, None).await? {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
|
||||
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
|
||||
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let o = act(&state, &user, &id, true).await?;
|
||||
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
|
||||
Ok(Json(json!({ "entered": entered })))
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
|
||||
pub async fn reiterate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
act(&state, &user, &id, false).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What answers an objection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A version changed (by `by`): the objections around it whose author it
|
||||
/// answers move on; the messages of the one who answered are read.
|
||||
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
let Some(side) = o.touches(proposal_id) else { continue };
|
||||
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
|
||||
if !objection::answers(change) {
|
||||
continue;
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
|
||||
let answer = if side == Touched::Target { "revision" } else { "other" };
|
||||
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
|
||||
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// May this person write a proposal linked to this objection, on this article?
|
||||
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
|
||||
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
|
||||
let unit_of = |id: String| async move {
|
||||
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
};
|
||||
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
|
||||
let author = author_of(state, held).await?;
|
||||
if !o.phase().standing()
|
||||
|| !objection::may_link(author.as_deref(), &user.id)
|
||||
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
|
||||
{
|
||||
return Err(AppError::Invalid("link"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A proposal linked to an objection was submitted (by `by`): it answers the objection.
|
||||
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await?;
|
||||
if !objection::answers(Change::LinkedProposal) {
|
||||
return Ok(());
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)?
|
||||
&& store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?
|
||||
{
|
||||
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A version was withdrawn: the objections around it end.
|
||||
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
|
||||
store(state, &o, &step, by, None).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The objections whose deadline passed are lifted by themselves; returns how
|
||||
/// many, and the documents touched.
|
||||
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
|
||||
let now = state.clock.now();
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
|
||||
.bind(&now)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut docs = Vec::new();
|
||||
let mut lifted = 0;
|
||||
for o in rows {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
|
||||
&& store(state, &o, &step, "deadline", None).await?
|
||||
{
|
||||
tracing::info!(objection = %o.id, "objection lifted at its deadline");
|
||||
lifted += 1;
|
||||
let doc = doc_of(state, &o.proposal_id).await?;
|
||||
if !docs.contains(&doc) {
|
||||
docs.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((lifted, docs))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the screen shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn version_json(st: &DocState, id: &str) -> Value {
|
||||
let p = st.proposals.iter().find(|p| p.id == id);
|
||||
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||
json!({
|
||||
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
|
||||
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
|
||||
"authorId": p.and_then(|p| p.author_id.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||
let row: Option<(String, bool)> =
|
||||
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||
}
|
||||
|
||||
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
|
||||
/// it stands, and what the viewer may do.
|
||||
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
|
||||
let (objector, invited) = name_of(state, &o.author_id).await?;
|
||||
let phase = o.phase();
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
let me = viewer.to_identity();
|
||||
let standing = phase.standing();
|
||||
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
|
||||
let asked = matches!(phase, Phase::Objector { .. });
|
||||
Ok(json!({
|
||||
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
|
||||
"mine": o.author_id == viewer.id, "at": o.created_at,
|
||||
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
|
||||
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
|
||||
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
|
||||
"other": version_json(st, o.other_id()),
|
||||
"iAnswer": i_answer,
|
||||
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
|
||||
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The objections around one version, newest first, each as `viewer` sees it;
|
||||
/// `held` tells whether this version is the one held back.
|
||||
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for o in rows {
|
||||
let mut v = view(state, st, viewer, &o).await?;
|
||||
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
|
||||
out.push(v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An objection, for a message of its box: what it is about, and the gestures
|
||||
/// the message offers now (none once dealt with).
|
||||
pub async fn for_message(
|
||||
state: &AppState,
|
||||
viewer: &IdentityRow,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
|
||||
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
|
||||
let v = view(state, &st, viewer, &o).await?;
|
||||
let current = o.cycle == cycle;
|
||||
let gestures = match (o.phase(), kind) {
|
||||
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
|
||||
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
|
||||
vec!["lift", "reiterate"]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
Ok(Some((v, gestures)))
|
||||
}
|
||||
@@ -137,10 +137,12 @@ pub async fn decisions(
|
||||
/// order of each section (ADR 0009): the version in force (adopted by a vote,
|
||||
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
||||
/// on one of them. A change of inertia is not a version of the text; the order
|
||||
/// of a section of fewer than two articles has nothing to decide.
|
||||
/// of a section of fewer than two articles has nothing to decide; a version held
|
||||
/// by an objection is nobody's to vote while it is held (ADR 0010).
|
||||
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VersionRow {
|
||||
id: String,
|
||||
unit_id: String,
|
||||
in_force: bool,
|
||||
adopted: bool,
|
||||
@@ -154,7 +156,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
||||
.map(|u| u.id.clone())
|
||||
.collect();
|
||||
let rows = sqlx::query_as::<_, VersionRow>(
|
||||
"SELECT u.id AS unit_id,
|
||||
"SELECT p.id AS id, u.id AS unit_id,
|
||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
||||
EXISTS (SELECT 1 FROM votes v
|
||||
@@ -179,7 +181,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
||||
if let Some(m) = out.last_mut() {
|
||||
if r.in_force {
|
||||
m.in_force = Some(r.adopted);
|
||||
} else {
|
||||
} else if st.proposals.iter().any(|p| p.id == r.id && st.votable(p)) {
|
||||
m.open += 1;
|
||||
}
|
||||
m.voted |= r.voted;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Writing: drafts, submission, corrections, votes, contradiction signals,
|
||||
//! inertia changes, editorial translations.
|
||||
//! Writing: drafts, submission, corrections, votes, inertia changes, editorial
|
||||
//! translations. Objections live in `objections`, typo fixes of others in `typos`.
|
||||
//!
|
||||
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
||||
//! système qui nécessite un vote pour corriger une coquille »):
|
||||
@@ -7,9 +7,10 @@
|
||||
//! - once **submitted**, a correction classified *typographic* by the engine
|
||||
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
||||
//! new revision and every earlier vote waits for its author to reconfirm;
|
||||
//! - a text **in force** accepts typographic corrections from any member of
|
||||
//! the group, recorded with their author and diff, and republished — a
|
||||
//! change of meaning goes through a proposal.
|
||||
//! - any member of the group may write a typo fix on a version; it applies at
|
||||
//! once when its writer answers for the version (its author; an administrator
|
||||
//! for a text without author), and waits for them otherwise — three days at
|
||||
//! most (2026-10-31, `typos`). A change of meaning goes through a proposal.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
@@ -17,8 +18,7 @@ use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules;
|
||||
use crate::util;
|
||||
use crate::{modules, objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::assist::{self, Hint};
|
||||
@@ -30,10 +30,8 @@ use serde_json::json;
|
||||
|
||||
const MAX_TITLE: usize = 120;
|
||||
const MAX_BODY: usize = 4000;
|
||||
const MIN_REASON: usize = 10;
|
||||
const MAX_REASON: usize = 600;
|
||||
|
||||
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -50,7 +48,11 @@ async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
}
|
||||
|
||||
/// Loads the document of a proposal and checks the person may open its group.
|
||||
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
|
||||
pub(crate) async fn guarded(
|
||||
state: &AppState,
|
||||
user: &IdentityRow,
|
||||
proposal_id: &str,
|
||||
) -> AppResult<(ProposalRow, DocState)> {
|
||||
let p = load_proposal(state, proposal_id).await?;
|
||||
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
||||
let st = docs::load_state(state, &doc_id).await?;
|
||||
@@ -172,6 +174,9 @@ pub struct CreateBody {
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
submit: bool,
|
||||
/// Written to answer an objection (« modifier l'article visé »): on the
|
||||
/// article the held version repeats or contradicts, or on its own.
|
||||
objection_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A new article goes at the end of its section (ADR 0009): the order of the
|
||||
@@ -259,7 +264,7 @@ pub async fn create(
|
||||
}
|
||||
unit.id.clone()
|
||||
}
|
||||
None if kind == "text" => {
|
||||
None if kind == "text" && b.objection_id.is_none() => {
|
||||
// A new article belongs to the section asked for, or to that of the article named.
|
||||
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
||||
let section = match b.section.as_deref() {
|
||||
@@ -269,8 +274,12 @@ pub async fn create(
|
||||
};
|
||||
new_unit(&state, &st, section).await?
|
||||
}
|
||||
None if kind == "text" => return Err(AppError::Invalid("link")),
|
||||
None => return Err(AppError::Invalid("unit")),
|
||||
};
|
||||
if let Some(oid) = &b.objection_id {
|
||||
objections::check_link(&state, &user, oid, &unit_id).await?;
|
||||
}
|
||||
let id = insert_proposal(
|
||||
&state,
|
||||
NewProposal {
|
||||
@@ -284,6 +293,16 @@ pub async fn create(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(oid) = &b.objection_id {
|
||||
sqlx::query("UPDATE proposals SET objection_id = ? WHERE id = ?")
|
||||
.bind(oid)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if b.submit {
|
||||
objections::on_linked(&state, oid, &id, &user.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
||||
}
|
||||
|
||||
@@ -341,43 +360,8 @@ pub async fn edit(
|
||||
};
|
||||
match kind {
|
||||
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||
EditKind::Typographic => {
|
||||
// Anyone in the group may fix a typo of a seed text or of a text in force.
|
||||
if !(is_author || p.origin == "seed" || p.status == "in_force") {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
// Written only over the text that was judged: an entry into force or another
|
||||
// correction in between makes it a conflict, never a silent change.
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.bind(p.revision)
|
||||
.bind(&p.status)
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
|
||||
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
||||
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
||||
.bind(util::source_hash(title, body))
|
||||
.bind(&id)
|
||||
.bind(util::source_hash(&p.title, &p.body))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if p.status == "in_force" {
|
||||
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
|
||||
}
|
||||
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
|
||||
}
|
||||
// Anyone in the group may fix a typo; who applies it, and when, is `typos`.
|
||||
EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await,
|
||||
EditKind::Substantive => {
|
||||
if p.status == "in_force" {
|
||||
return Err(AppError::Conflict("substantive_in_force"));
|
||||
@@ -406,14 +390,10 @@ pub async fn edit(
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
||||
// Signals were about the former text: they lapse, their authors may signal again.
|
||||
sqlx::query(
|
||||
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// It answers the objections around it (the objector judges again), and
|
||||
// the typo fixes waiting on the former text no longer fit.
|
||||
objections::on_change(&state, &id, true, &user.id).await?;
|
||||
typos::on_text_changed(&state, &id).await?;
|
||||
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
||||
}
|
||||
}
|
||||
@@ -450,6 +430,12 @@ pub async fn submit(
|
||||
if initial.rows_affected() == 0 {
|
||||
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
||||
}
|
||||
// Written to answer an objection: submitted, it answers it (a draft answers nothing).
|
||||
let linked: Option<String> =
|
||||
sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
|
||||
if let Some(oid) = linked {
|
||||
objections::on_linked(&state, &oid, &id, &user.id).await?;
|
||||
}
|
||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||
}
|
||||
@@ -472,6 +458,9 @@ pub async fn withdraw(
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// Nothing left to object to, nor to correct.
|
||||
objections::on_withdrawn(&state, &id, &user.id).await?;
|
||||
typos::on_text_changed(&state, &id).await?;
|
||||
Ok(Json(json!({ "status": "withdrawn" })))
|
||||
}
|
||||
|
||||
@@ -561,66 +550,37 @@ pub async fn detail(
|
||||
author_name: Option<String>,
|
||||
author_invited: Option<bool>,
|
||||
created_at: String,
|
||||
typo_id: Option<String>,
|
||||
answered_by: Option<String>,
|
||||
answered_name: Option<String>,
|
||||
}
|
||||
let revs = sqlx::query_as::<_, Rev>(
|
||||
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
|
||||
i.kind = 'invite' AS author_invited, r.created_at
|
||||
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
|
||||
i.kind = 'invite' AS author_invited, r.created_at, r.typo_id, r.answered_by, a.display_name AS answered_name
|
||||
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id LEFT JOIN identities a ON a.id = r.answered_by
|
||||
WHERE r.proposal_id = ? ORDER BY r.id",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
// Each correction with the diff from the text before it.
|
||||
// Each correction with the diff from the text before it; a fix someone else
|
||||
// wrote says who let it in (or that it passed at its deadline).
|
||||
let mut history = Vec::new();
|
||||
let mut previous: Option<&Rev> = None;
|
||||
for r in &revs {
|
||||
let diff = previous
|
||||
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
|
||||
let typo = r.typo_id.as_ref().map(|t| {
|
||||
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
|
||||
});
|
||||
history.push(json!({
|
||||
"revision": r.revision, "kind": r.kind, "author": r.author_name,
|
||||
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
|
||||
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
|
||||
}));
|
||||
previous = Some(r);
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Sig {
|
||||
id: String,
|
||||
proposal_id: String,
|
||||
target_proposal_id: String,
|
||||
reason: String,
|
||||
author_id: String,
|
||||
author_name: String,
|
||||
author_invited: bool,
|
||||
created_at: String,
|
||||
lifted_at: Option<String>,
|
||||
lifted_by: Option<String>,
|
||||
}
|
||||
let sigs = sqlx::query_as::<_, Sig>(
|
||||
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
|
||||
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
|
||||
FROM signals s JOIN identities i ON i.id = s.author_id
|
||||
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut signals = Vec::new();
|
||||
for s in sigs {
|
||||
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
|
||||
let other = load_proposal(&state, other_id).await.ok();
|
||||
let other_code = match &other {
|
||||
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
|
||||
None => None,
|
||||
};
|
||||
signals.push(json!({
|
||||
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
|
||||
"mine": s.author_id == user.id,
|
||||
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
|
||||
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
|
||||
}));
|
||||
}
|
||||
let objections = objections::around(&state, &st, &user, &id).await?;
|
||||
let typos = typos::of_proposal(&state, &user, &p).await?;
|
||||
|
||||
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||
@@ -628,7 +588,7 @@ pub async fn detail(
|
||||
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||
_ => None,
|
||||
};
|
||||
let eval = st.evaluation(unit, &p);
|
||||
let eval = st.shown_evaluation(unit, &p);
|
||||
let voted_at = st.voting_level(unit, &p);
|
||||
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
||||
let tr = docs::translations_for(&state, &[&p]).await?;
|
||||
@@ -640,8 +600,8 @@ pub async fn detail(
|
||||
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id),
|
||||
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
||||
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
||||
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
|
||||
@@ -651,13 +611,14 @@ pub async fn detail(
|
||||
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
||||
"diff": diff,
|
||||
"history": history,
|
||||
"signals": signals,
|
||||
"objections": objections,
|
||||
"typos": typos,
|
||||
"electorate": st.electorate,
|
||||
"electorateSource": st.electorate_source,
|
||||
"voteFloor": st.doc.vote_floor,
|
||||
"participation": st.participation_json(),
|
||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
||||
"levels": st.levels_json(st.tally(&p.id).cast()),
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -685,6 +646,10 @@ pub async fn vote(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Held back by an objection: its votes stay, no new one (ADR 0010).
|
||||
if !st.votable(&p) {
|
||||
return Err(AppError::Conflict("objection"));
|
||||
}
|
||||
if !docs::may_vote(&state, &user, &st.group).await? {
|
||||
return Err(AppError::Conflict("members_only"));
|
||||
}
|
||||
@@ -700,81 +665,6 @@ pub async fn vote(
|
||||
Ok(Json(json!({ "entered": entered })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contradiction signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SignalBody {
|
||||
target_id: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// « These two texts contradict each other. » While the signal stands, the two
|
||||
/// can never be in force together: the one not yet in force is blocked.
|
||||
pub async fn signal(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<SignalBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
let reason = b.reason.trim();
|
||||
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
||||
return Err(AppError::Invalid("reason"));
|
||||
}
|
||||
if b.target_id == id {
|
||||
return Err(AppError::Invalid("target"));
|
||||
}
|
||||
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
|
||||
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Invalid("target"));
|
||||
}
|
||||
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
|
||||
// Rivals on one unit never stand together anyway.
|
||||
return Err(AppError::Invalid("same_unit"));
|
||||
}
|
||||
let sid = util::id();
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&sid)
|
||||
.bind(&id)
|
||||
.bind(&b.target_id)
|
||||
.bind(reason)
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": sid })))
|
||||
}
|
||||
|
||||
pub async fn lift_signal(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (proposal_id, author): (String, String) =
|
||||
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let (_, st) = guarded(&state, &user, &proposal_id).await?;
|
||||
if author != user.id && !user.admin {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
|
||||
.bind(util::now())
|
||||
.bind(&user.id)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "entered": entered })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inertia
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -390,7 +390,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tally = st.tally(&p.id);
|
||||
let tally = st.shown_tally(&p.id);
|
||||
json!({
|
||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||
@@ -409,7 +409,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||
.collect();
|
||||
let tally = st.tally(&p.id);
|
||||
let tally = st.shown_tally(&p.id);
|
||||
Some(json!({
|
||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||
|
||||
@@ -71,8 +71,10 @@ pub struct Facts {
|
||||
pub revisions: Vec<String>,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Contradiction signals naming it, standing or lifted.
|
||||
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
|
||||
pub signals: i64,
|
||||
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
|
||||
pub typos: i64,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
||||
"revised"
|
||||
} else if f.votes > 0 {
|
||||
"votes"
|
||||
} else if f.revisions != ["initial"] {
|
||||
} else if f.revisions != ["initial"] || f.typos > 0 {
|
||||
"corrected"
|
||||
} else if f.member_translations > 0 {
|
||||
"translation_edited"
|
||||
@@ -214,6 +216,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts {
|
||||
origin: p.origin,
|
||||
entered: p.entered_at.is_some(),
|
||||
@@ -223,6 +229,7 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
||||
revisions,
|
||||
member_translations,
|
||||
signals,
|
||||
typos,
|
||||
fingerprint: fingerprint(&p.title, &p.body),
|
||||
}))
|
||||
}
|
||||
@@ -383,6 +390,7 @@ mod tests {
|
||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||
(Some(Facts { typos: 1, ..intact() }), "corrected"),
|
||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||
|
||||
@@ -97,8 +97,13 @@ pub struct JoinBody {
|
||||
/// `name_key` settles two openings at the same instant.
|
||||
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
||||
// People already there, and those an invitation still waits for.
|
||||
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
|
||||
// People already there, and those an invitation still waits for. A Ğ1
|
||||
// identity known only from a vote on the chain (never signed in here) is not
|
||||
// there: as for any member of the web of trust who never came, its name stays free.
|
||||
let mut taken: Vec<String> =
|
||||
sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
taken.extend(
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010).
|
||||
//!
|
||||
//! What a typo is does not change (`ld_engine::edit`, judged against the voted
|
||||
//! text). Who applies it, and when, is `ld_engine::correction`: the one who
|
||||
//! answers for the version (its author; for a text without author, an
|
||||
//! administrator of trustinWallet — `trustin_access::answers_typo`) applies a
|
||||
//! fix at once; anyone else's waits for them, told by a message, and applies by
|
||||
//! itself after 3 days (`due`, run by the sweeper and at start). While the
|
||||
//! version is not in force its author keeps the hand: a fix applied may be taken
|
||||
//! back, a fix refused validated after all. A fix is written only over the text
|
||||
//! it was written over: once another one applies, or the substance changes, it
|
||||
//! is stale. Translations stay out of this: editorial, without validation.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IDENTITY_COLUMNS, IdentityRow, User};
|
||||
use crate::docs::{self, DocState, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::messages::{self, Topic};
|
||||
use crate::{proposals, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::correction::{self, Answer, Route, Settle, Text, TextChange, TypoStatus};
|
||||
use ld_engine::edit;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct TypoRow {
|
||||
pub id: String,
|
||||
pub proposal_id: String,
|
||||
pub doc_id: String,
|
||||
pub writer_id: String,
|
||||
pub answerer_id: Option<String>,
|
||||
pub revision: i64,
|
||||
pub base_title: String,
|
||||
pub base_body: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub due_at: String,
|
||||
pub settled_at: Option<String>,
|
||||
pub settled_by: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body,
|
||||
status, created_at, due_at, settled_at, settled_by FROM typos";
|
||||
|
||||
impl TypoRow {
|
||||
fn status(&self) -> TypoStatus {
|
||||
TypoStatus::parse(&self.status).unwrap_or(TypoStatus::Stale)
|
||||
}
|
||||
|
||||
fn base(&self) -> Text<'_> {
|
||||
Text { revision: self.revision, title: &self.base_title, body: &self.base_body }
|
||||
}
|
||||
|
||||
fn fixed(&self) -> Text<'_> {
|
||||
Text { revision: self.revision, title: &self.title, body: &self.body }
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(p: &ProposalRow) -> Text<'_> {
|
||||
Text { revision: p.revision, title: &p.title, body: &p.body }
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: &str) -> AppResult<TypoRow> {
|
||||
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Writes `to` over a version, only where its text is still `from` — and, when
|
||||
/// `status` is given, only while its status is that one; records the revision and
|
||||
/// keeps up-to-date translations up to date. False when the text was not there.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_text(
|
||||
tx: &mut SqliteConnection,
|
||||
proposal_id: &str,
|
||||
from: Text<'_>,
|
||||
to: (&str, &str),
|
||||
status: Option<&str>,
|
||||
author: &str,
|
||||
link: Option<(&str, &str)>,
|
||||
kind: &str,
|
||||
now: &str,
|
||||
) -> AppResult<bool> {
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
|
||||
AND (?9 IS NULL OR status = ?9)",
|
||||
)
|
||||
.bind(to.0)
|
||||
.bind(to.1)
|
||||
.bind(now)
|
||||
.bind(proposal_id)
|
||||
.bind(from.revision)
|
||||
.bind(from.title)
|
||||
.bind(from.body)
|
||||
.bind(status)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at, typo_id, answered_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(from.revision)
|
||||
.bind(kind)
|
||||
.bind(to.0)
|
||||
.bind(to.1)
|
||||
.bind(author)
|
||||
.bind(now)
|
||||
.bind(link.map(|l| l.0))
|
||||
.bind(link.map(|l| l.1))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
||||
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
||||
.bind(util::source_hash(to.0, to.1))
|
||||
.bind(proposal_id)
|
||||
.bind(util::source_hash(from.title, from.body))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// After the text of a version changed, or it closed: the fixes waiting on a text
|
||||
/// that is gone are stale, and their messages say so.
|
||||
pub async fn on_text_changed(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||
let pending = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? AND status = 'pending'"))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for t in pending {
|
||||
if open && correction::fits(t.base(), text_of(&p)) {
|
||||
continue;
|
||||
}
|
||||
stale(state, &t, "pending").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stale(state: &AppState, t: &TypoRow, from: &str) -> AppResult<()> {
|
||||
let done = sqlx::query(
|
||||
"UPDATE typos SET status = 'stale', settled_at = ?, settled_by = 'changed' WHERE id = ? AND status = ?",
|
||||
)
|
||||
.bind(state.clock.now())
|
||||
.bind(&t.id)
|
||||
.bind(from)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
messages::close(state, Topic::Typo, &t.id, None, "stale").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn republish(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||
if p.status == "in_force" {
|
||||
let doc_id: String =
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?").bind(&p.unit_id).fetch_one(&state.db).await?;
|
||||
adoption::enqueue_publication(state, &docs::load_doc(state, &doc_id).await?, "correction typographique")
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fix the engine judged typographic, written by `user` on `p`: applied at once
|
||||
/// by the one who answers for the version, waiting for them otherwise.
|
||||
pub async fn write(
|
||||
state: &AppState,
|
||||
user: &IdentityRow,
|
||||
p: &ProposalRow,
|
||||
st: &DocState,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let diff = edit::diff(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
|
||||
let answers = trustin_access::answers_typo(&user.to_identity(), p.author_id.as_deref());
|
||||
let now = state.clock.now();
|
||||
match correction::route(answers) {
|
||||
Route::Apply => {
|
||||
// Written only over the text that was judged: an entry into force or another
|
||||
// correction in between makes it a conflict, never a silent change.
|
||||
let mut tx = state.db.begin().await?;
|
||||
if !write_text(&mut tx, &p.id, text_of(p), (title, body), Some(&p.status), &user.id, None, "typo", &now)
|
||||
.await?
|
||||
{
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
tx.commit().await?;
|
||||
if p.status == "in_force" {
|
||||
adoption::enqueue_publication(state, &st.doc, "correction typographique").await?;
|
||||
}
|
||||
on_text_changed(state, &p.id).await?;
|
||||
Ok(Json(json!({ "edit": "typo", "diff": diff })))
|
||||
}
|
||||
Route::Await => {
|
||||
let id = util::id();
|
||||
let due = state.clock.in_days(correction::ANSWER_DAYS);
|
||||
sqlx::query(
|
||||
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
|
||||
title, body, status, created_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&p.id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(&user.id)
|
||||
.bind(&p.author_id)
|
||||
.bind(p.revision)
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(&now)
|
||||
.bind(&due)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// Who answers is told: the author, or for a text without author, every
|
||||
// administrator who opens the group (the others could not answer).
|
||||
let recipients: Vec<String> = match &p.author_id {
|
||||
Some(a) => vec![a.clone()],
|
||||
None => {
|
||||
let admins = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
"SELECT {IDENTITY_COLUMNS} FROM identities WHERE admin = 1 AND id <> ? ORDER BY created_at"
|
||||
))
|
||||
.bind(&user.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut open = Vec::new();
|
||||
for a in admins {
|
||||
if docs::opens(state, &a, &st.group).await? {
|
||||
open.push(a.id);
|
||||
}
|
||||
}
|
||||
open
|
||||
}
|
||||
};
|
||||
for r in recipients {
|
||||
messages::notify(state, &r, &st.doc.id, Topic::Typo, &id, "typo_pending", 1, Some(&due)).await?;
|
||||
}
|
||||
let answerer = if p.author_id.is_some() { "author" } else { "admin" };
|
||||
tracing::info!(typo = %id, proposal = %p.id, answerer, "typo fix waiting for validation");
|
||||
Ok(Json(json!({
|
||||
"edit": "typo_pending", "diff": diff, "typo": { "id": id, "dueAt": due, "answerer": answerer },
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles one fix, in one transaction: its status (only over the one read), and
|
||||
/// its text. `Ok(Err("settled"))`: answered meanwhile; `Ok(Err("changed"))`: the
|
||||
/// text it needs is gone — nothing is written.
|
||||
async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -> AppResult<Result<(), &'static str>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let claimed =
|
||||
sqlx::query("UPDATE typos SET status = ?, settled_at = ?, settled_by = ? WHERE id = ? AND status = ?")
|
||||
.bind(s.status.as_str())
|
||||
.bind(now)
|
||||
.bind(by)
|
||||
.bind(&t.id)
|
||||
.bind(&t.status)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if claimed.rows_affected() != 1 {
|
||||
return Ok(Err("settled"));
|
||||
}
|
||||
let written = match s.text {
|
||||
TextChange::Write => {
|
||||
write_text(
|
||||
&mut tx,
|
||||
&t.proposal_id,
|
||||
t.base(),
|
||||
(&t.title, &t.body),
|
||||
None,
|
||||
&t.writer_id,
|
||||
Some((&t.id, by)),
|
||||
"typo",
|
||||
now,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TextChange::Restore => {
|
||||
write_text(
|
||||
&mut tx,
|
||||
&t.proposal_id,
|
||||
t.fixed(),
|
||||
(&t.base_title, &t.base_body),
|
||||
None,
|
||||
by,
|
||||
Some((&t.id, by)),
|
||||
"revert",
|
||||
now,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TextChange::Keep => true,
|
||||
};
|
||||
if !written {
|
||||
tx.rollback().await?;
|
||||
return Ok(Err("changed"));
|
||||
}
|
||||
tx.commit().await?;
|
||||
if s.text != TextChange::Keep {
|
||||
republish(state, &t.proposal_id).await?;
|
||||
on_text_changed(state, &t.proposal_id).await?;
|
||||
}
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
async fn answer(state: &AppState, user: &IdentityRow, id: &str, a: Answer) -> AppResult<Json<Value>> {
|
||||
let t = load(state, id).await?;
|
||||
let (p, _) = proposals::guarded(state, user, &t.proposal_id).await?;
|
||||
if !trustin_access::answers_typo(&user.to_identity(), t.answerer_id.as_deref()) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let status = t.status();
|
||||
if status == TypoStatus::Stale {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
let s = correction::answer(status, a, p.status == "in_force").map_err(|_| AppError::Conflict("settled"))?;
|
||||
let now = state.clock.now();
|
||||
match settle(state, &t, s, &user.id, &now).await? {
|
||||
Ok(()) => {}
|
||||
Err("changed") => {
|
||||
// Written over a text that is gone: a pending fix is stale; one already decided stays as it is.
|
||||
if status == TypoStatus::Pending {
|
||||
stale(state, &t, "pending").await?;
|
||||
}
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
Err(_) => return Err(AppError::Conflict("settled")),
|
||||
}
|
||||
if status == TypoStatus::Pending {
|
||||
let outcome = if s.status == TypoStatus::Applied { "applied" } else { "refused" };
|
||||
messages::close(state, Topic::Typo, &t.id, None, outcome).await?;
|
||||
}
|
||||
messages::read_subject(state, &user.id, Topic::Typo, &t.id).await?;
|
||||
tracing::info!(typo = %t.id, status = s.status.as_str(), by = %user.id, "typo fix answered");
|
||||
Ok(Json(json!({ "status": s.status })))
|
||||
}
|
||||
|
||||
/// `POST /api/typos/{id}/validate`
|
||||
pub async fn validate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
answer(&state, &user, &id, Answer::Validate).await
|
||||
}
|
||||
|
||||
/// `POST /api/typos/{id}/refuse` — refuses a pending fix, or takes back an applied
|
||||
/// one while the version is not in force.
|
||||
pub async fn refuse(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
answer(&state, &user, &id, Answer::Refuse).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Due {
|
||||
pub applied: usize,
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// The fixes whose deadline passed apply by themselves — where their text is still there.
|
||||
pub async fn due(state: &AppState) -> AppResult<Due> {
|
||||
let now = state.clock.now();
|
||||
let rows = sqlx::query_as::<_, TypoRow>(&format!(
|
||||
"{SELECT} WHERE status = 'pending' AND due_at <= ? ORDER BY created_at, rowid"
|
||||
))
|
||||
.bind(&now)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Due::default();
|
||||
for t in rows {
|
||||
let Some(s) = correction::tick(t.status(), &t.due_at, &now) else { continue };
|
||||
match settle(state, &t, s, "deadline", &now).await? {
|
||||
Ok(()) => {
|
||||
messages::close(state, Topic::Typo, &t.id, None, "deadline").await?;
|
||||
tracing::info!(typo = %t.id, "typo fix applied at its deadline");
|
||||
out.applied += 1;
|
||||
}
|
||||
Err("changed") => {
|
||||
stale(state, &t, "pending").await?;
|
||||
out.stale += 1;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the screen shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||
let row: Option<(String, bool)> =
|
||||
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||
}
|
||||
|
||||
/// What `viewer` may do with a fix now.
|
||||
fn gestures(viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> Vec<&'static str> {
|
||||
if !trustin_access::answers_typo(&viewer.to_identity(), t.answerer_id.as_deref()) {
|
||||
return vec![];
|
||||
}
|
||||
let in_force = p.status == "in_force";
|
||||
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||
match t.status() {
|
||||
TypoStatus::Pending => vec!["validate", "refuse"],
|
||||
TypoStatus::Applied if open && !in_force && correction::fits(t.fixed(), text_of(p)) => vec!["refuse"],
|
||||
TypoStatus::Refused if open && !in_force && correction::fits(t.base(), text_of(p)) => vec!["validate"],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> AppResult<Value> {
|
||||
let (writer, invited) = name_of(state, &t.writer_id).await?;
|
||||
let settled_by = match t.settled_by.as_deref() {
|
||||
Some(by @ ("deadline" | "changed")) => Some(by.to_owned()),
|
||||
Some(id) => name_of(state, id).await?.0,
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({
|
||||
"id": t.id, "status": t.status, "writer": writer, "writerInvited": invited, "at": t.created_at,
|
||||
"dueAt": t.due_at, "settledAt": t.settled_at, "settledBy": settled_by,
|
||||
"answerer": if t.answerer_id.is_some() { "author" } else { "admin" },
|
||||
"diff": edit::diff(&format!("{}\n{}", t.base_title, t.base_body), &format!("{}\n{}", t.title, t.body)),
|
||||
"gestures": gestures(viewer, t, p),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The fixes written by others on one version, newest first, as `viewer` sees them.
|
||||
pub async fn of_proposal(state: &AppState, viewer: &IdentityRow, p: &ProposalRow) -> AppResult<Vec<Value>> {
|
||||
let rows =
|
||||
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? ORDER BY created_at DESC, rowid DESC"))
|
||||
.bind(&p.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for t in &rows {
|
||||
out.push(view(state, viewer, t, p).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A fix, for a message of its box: the gestures only while it waits.
|
||||
pub async fn for_message(
|
||||
state: &AppState,
|
||||
viewer: &IdentityRow,
|
||||
subject_id: &str,
|
||||
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||
let Ok(t) = load(state, subject_id).await else { return Ok(None) };
|
||||
let p = proposals::load_proposal(state, &t.proposal_id).await?;
|
||||
let mut v = view(state, viewer, &t, &p).await?;
|
||||
let code: Option<String> =
|
||||
sqlx::query_scalar("SELECT code FROM units WHERE id = ?").bind(&p.unit_id).fetch_optional(&state.db).await?;
|
||||
v["version"] = json!({ "id": p.id, "title": p.title, "status": p.status, "unitId": p.unit_id, "code": code });
|
||||
let gestures = if t.status() == TypoStatus::Pending { gestures(viewer, &t, &p) } else { vec![] };
|
||||
v["gestures"] = json!(gestures);
|
||||
Ok(Some((v, gestures)))
|
||||
}
|
||||
@@ -186,20 +186,22 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
#[tokio::test]
|
||||
async fn editing_is_not_voting() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
// An administrator of trustinWallet answers for the starting texts (no author).
|
||||
a.login("Yvv", true).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let pid = e1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may give the text in force its typographic apostrophes, without a vote.
|
||||
// Anyone may give the text in force its typographic apostrophes, without a vote:
|
||||
// since 2026-10-31 it waits for validation (tests/typos.rs).
|
||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
assert!(fixed.contains("m\u{2019}engage"));
|
||||
let (s, v) =
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "typo");
|
||||
assert_eq!(v["edit"], "typo_pending");
|
||||
// A change of meaning in force is refused: it goes through a proposal.
|
||||
let (s, v) = b
|
||||
.call(
|
||||
@@ -259,13 +261,14 @@ async fn editing_is_not_voting() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
||||
// Typo fixes are measured against the voted text: slips never add up.
|
||||
// Typo fixes are measured against the voted text: slips never add up (applied at
|
||||
// once here: the administrator answers for a starting text).
|
||||
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
||||
let (_, v) = b
|
||||
let (_, v) = a
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
||||
.await;
|
||||
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
||||
let (s, v) = b
|
||||
let (s, v) = a
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -273,7 +276,7 @@ async fn editing_is_not_voting() {
|
||||
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
||||
"a second slip on top is not"
|
||||
);
|
||||
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||||
let (_, v) = a.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||||
assert_eq!(v["edit"], "typo", "back to the voted text");
|
||||
// An address is compared exactly.
|
||||
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
||||
@@ -335,8 +338,11 @@ async fn editing_is_not_voting() {
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
/// Replaces « a contradiction signal blocks entering into force » (2026-10-21):
|
||||
/// since 2026-10-31 an objection makes the latest arrival non-votable (ADR 0010;
|
||||
/// the full cycle: tests/objections.rs).
|
||||
#[tokio::test]
|
||||
async fn a_contradiction_signal_blocks_entering_into_force() {
|
||||
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
|
||||
let (mut a, _) = Client::new().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
@@ -345,22 +351,25 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||||
&format!("/api/proposals/{k1}/objections"),
|
||||
Some(json!({ "otherId": e2, "kind": "contredit", "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let sid = v["id"].as_str().expect("sid").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
|
||||
// Only its author lifts it; then the pass lets the text in.
|
||||
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||
assert_eq!(v["heldId"], k1.as_str());
|
||||
let oid = v["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")), "not votable while it stands");
|
||||
// Only its author lifts it (or an administrator); then the second voice lets the text in.
|
||||
let (s, _) = a.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two");
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["code"], "K1");
|
||||
}
|
||||
|
||||
@@ -1063,7 +1072,8 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
||||
.expect("count");
|
||||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||||
|
||||
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
|
||||
// From now on a typo is measured against the new text (written by a member, it
|
||||
// waits for an administrator), and the Préambule is a change of meaning.
|
||||
let body = i1["body"].as_str().expect("body");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
@@ -1072,7 +1082,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
||||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
|
||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo_pending")), "{v}");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
@@ -1113,6 +1123,7 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
Touch::Vote => {
|
||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||
}
|
||||
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
|
||||
Touch::Typo => {
|
||||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||||
@@ -1133,8 +1144,8 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
c.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
|
||||
&format!("/api/proposals/{k1}/objections"),
|
||||
Some(json!({ "otherId": pid, "kind": "contredit", "reason": "Ces règles contredisent le préambule." })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ 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)]
|
||||
@@ -49,21 +55,66 @@ struct Signer<'a> {
|
||||
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 hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
|
||||
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());
|
||||
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
|
||||
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 {
|
||||
@@ -103,6 +154,19 @@ impl Indexer {
|
||||
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> {
|
||||
@@ -138,6 +202,16 @@ impl Client {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -155,8 +229,9 @@ impl Client {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let headers = res.headers().clone();
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
(status, headers, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||
@@ -169,6 +244,10 @@ impl Client {
|
||||
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();
|
||||
@@ -396,17 +475,22 @@ async fn a_wrong_amount_or_recipient_is_refused() {
|
||||
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;
|
||||
// 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"))], None).await;
|
||||
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"))], None).await;
|
||||
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");
|
||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
|
||||
}
|
||||
|
||||
@@ -559,8 +643,10 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
|
||||
// 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");
|
||||
@@ -625,17 +711,15 @@ async fn a_session_sees_its_own_ballots_only() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
||||
async fn fifty_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");
|
||||
// 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() {
|
||||
@@ -646,6 +730,27 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
||||
b["comment"].as_str().expect("c"),
|
||||
Signer { account, identity: Some((index, "Membre", true)) },
|
||||
);
|
||||
if n == 49 {
|
||||
// The last ballot is signed; before the chain confirms it, an objection holds
|
||||
// E5 back against the text in force E1 (ADR 0010): 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.
|
||||
@@ -664,8 +769,396 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
|
||||
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());
|
||||
// A ballot sealed before the objection counts, as a vote already cast: kept, but
|
||||
// the version held back cannot enter into force.
|
||||
assert_eq!(tally(&d, &e5), (50, 0));
|
||||
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
|
||||
assert_eq!(find(&d, &e5)["votable"], false);
|
||||
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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", "inForce");
|
||||
// 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", "inForce");
|
||||
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", "inForce");
|
||||
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", "inForce");
|
||||
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", "inForce");
|
||||
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", "inForce");
|
||||
// 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", "inForce");
|
||||
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");
|
||||
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (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", "inForce");
|
||||
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", "inForce");
|
||||
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", "inForce"), 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", "inForce");
|
||||
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]
|
||||
|
||||
+483
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"data": {
|
||||
"squidStatus": {
|
||||
"height": 3407492
|
||||
},
|
||||
"txComments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "0000196944-60692-000003",
|
||||
@@ -85,6 +92,482 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000821130-de04d-000008",
|
||||
"blockNumber": 821130,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"identity": {
|
||||
"index": 3947,
|
||||
"name": "Jade971",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001281169-c9002-000006",
|
||||
"blockNumber": 1281169,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"identity": {
|
||||
"index": 7210,
|
||||
"name": "Tell",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq",
|
||||
"amount": "18000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001303965-1b924-000006",
|
||||
"blockNumber": 1303965,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"identity": {
|
||||
"index": 13370,
|
||||
"name": "Florence76",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J",
|
||||
"amount": "1500"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810083-37092-000004",
|
||||
"blockNumber": 1810083,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK",
|
||||
"amount": "58900"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810417-7e2d8-000003",
|
||||
"blockNumber": 1810417,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001850707-782ab-000003",
|
||||
"blockNumber": 1850707,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"identity": {
|
||||
"index": 13722,
|
||||
"name": "LibrA",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb",
|
||||
"amount": "70680"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002197191-e9abb-000006",
|
||||
"blockNumber": 2197191,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"identity": {
|
||||
"index": 17203,
|
||||
"name": "Elodietexier",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn",
|
||||
"amount": "3534"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002727091-bd26d-000003",
|
||||
"blockNumber": 2727091,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"identity": {
|
||||
"index": 18509,
|
||||
"name": "RobBiovoix",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002825411-05f96-000003",
|
||||
"blockNumber": 2825411,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"identity": {
|
||||
"index": 18506,
|
||||
"name": "BrunoAlmeida",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002939378-e9fef-000006",
|
||||
"blockNumber": 2939378,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002945277-0f2cb-000003",
|
||||
"blockNumber": 2945277,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003114157-3fc38-000006",
|
||||
"blockNumber": 3114157,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB",
|
||||
"amount": "4000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003124959-a6247-000003",
|
||||
"blockNumber": 3124959,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"identity": {
|
||||
"index": 7377,
|
||||
"name": "Sylvaine",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003128170-4521a-000003",
|
||||
"blockNumber": 3128170,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"identity": {
|
||||
"index": 8912,
|
||||
"name": "Altheaagathe",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK",
|
||||
"amount": "40000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003226425-d15c3-000009",
|
||||
"blockNumber": 3226425,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "14604"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003227886-5270d-000003",
|
||||
"blockNumber": 3227886,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"identity": {
|
||||
"index": 16572,
|
||||
"name": "zola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx",
|
||||
"amount": "6000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003229458-31a61-000006",
|
||||
"blockNumber": 3229458,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "12170"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003297346-087b4-000006",
|
||||
"blockNumber": 3297346,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003360722-61edb-000006",
|
||||
"blockNumber": 3360722,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"identity": {
|
||||
"index": 14694,
|
||||
"name": "Coca-Cola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv",
|
||||
"amount": "6085"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003384655-8fbc0-000003",
|
||||
"blockNumber": 3384655,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"identity": {
|
||||
"index": 4018,
|
||||
"name": "GeraldineGarrigues",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399415-51dbe-000003",
|
||||
"blockNumber": 3399415,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399566-d43e7-000003",
|
||||
"blockNumber": 3399566,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"identity": {
|
||||
"index": 18630,
|
||||
"name": "moidann12",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -377,6 +377,19 @@ async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected()
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
|
||||
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
|
||||
// An objection (« redit » / « contredit », ADR 0010) is between two articles: never a module.
|
||||
let i2 = unit(&doc, "I2")["proposals"][0]["id"].as_str().expect("i2").to_owned();
|
||||
let title_v = doc["modules"]["title"]["inForce"]["id"].as_str().expect("title").to_owned();
|
||||
for (from, other) in [(seed_order, i2.as_str()), (i2.as_str(), seed_order), (i2.as_str(), title_v.as_str())] {
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{from}/objections"),
|
||||
Some(json!({ "otherId": other, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("other")), "{from} → {other}");
|
||||
}
|
||||
// A module's inertia is voted like an article's.
|
||||
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
|
||||
assert_eq!(i["existing"], false);
|
||||
@@ -425,11 +438,21 @@ async fn another_title_enters_into_force_and_its_translations_are_to_review() {
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
|
||||
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
|
||||
// A typo of the title in force is corrected without a vote, and the document follows.
|
||||
// A typo of the title in force is corrected without a vote, by the path of any typo
|
||||
// (ADR 0010): someone else's waits for the title's author, then the document follows.
|
||||
let fixed = "Acte d’engagement de la certification";
|
||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
|
||||
assert_eq!(r["edit"], "typo");
|
||||
assert_eq!((r["edit"].as_str(), r["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], new_title, "nothing written yet");
|
||||
let tid = r["typo"]["id"].as_str().expect("typo").to_owned();
|
||||
assert_eq!(yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
|
||||
// An order is never corrected: neither by its author nor by anyone else.
|
||||
let doc = yvv.doc("essai").await;
|
||||
let order = order_of(&doc, "conclusion")["inForce"]["id"].as_str().expect("order").to_owned();
|
||||
let (s, v) =
|
||||
chiara.call("PATCH", &format!("/api/proposals/{order}"), Some(json!({ "title": "", "body": "x" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
// The test group never publishes; the title of the publication is covered by `render`.
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! Objections « redit » / « contredit » and the messages of trustinWallet
|
||||
//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The
|
||||
//! three days of a deadline pass on the server's clock, never with a sleep.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadlines, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self { router: app(state.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
|
||||
}
|
||||
|
||||
/// The status and the error code of a call expected to fail.
|
||||
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str, admin: bool) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).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
|
||||
}
|
||||
|
||||
async fn propose(&mut self, doc: &Value, body: Value) -> String {
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
self.ok("POST", &format!("/api/docs/{id}/proposals"), Some(body)).await["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
async fn object(&mut self, from: &str, other: &str, kind: &str, reason: &str) -> Value {
|
||||
self.ok(
|
||||
"POST",
|
||||
&format!("/api/proposals/{from}/objections"),
|
||||
Some(json!({ "otherId": other, "kind": kind, "reason": reason })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn messages(&mut self) -> Value {
|
||||
self.ok("GET", "/api/messages", None).await
|
||||
}
|
||||
|
||||
async fn detail(&mut self, id: &str) -> Value {
|
||||
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
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 gestures(m: &Value) -> Vec<&str> {
|
||||
m["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
||||
}
|
||||
|
||||
const REPEATS: &str = "Elle répète l'unicité du compte, déjà en vigueur.";
|
||||
const CONTRADICTS: &str = "Elle dit le contraire de la réciprocité en vigueur.";
|
||||
|
||||
/// C28, by the API: a « redit », the proposal no longer votable, its author changes
|
||||
/// the article it repeats, the objector lifts.
|
||||
#[tokio::test]
|
||||
async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector_lifts() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1");
|
||||
let (e1_unit, e1_id) =
|
||||
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Un seul compte", "body": "Je n'ai qu'un seul compte membre, et je m'y tiens.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let o = yvv.object(&b, &e1_id, "redit", REPEATS).await;
|
||||
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, never the text in force");
|
||||
let oid = o["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
|
||||
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
|
||||
assert_eq!(find(&d, &e1_id)["objections"], 1);
|
||||
|
||||
// Nobody votes on it any more: neither one vote nor a grouped one.
|
||||
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
|
||||
let many = yvv
|
||||
.err(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/votes"),
|
||||
Some(json!({ "lines": [{ "proposalId": b, "choice": "for" }] })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(many, (StatusCode::CONFLICT, "objection".into()));
|
||||
|
||||
// Its author is told, in her messages, what to do; the objector has nothing yet.
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!(m["unread"], 1);
|
||||
let raised = &m["messages"][0];
|
||||
assert_eq!((raised["kind"].as_str(), raised["topic"].as_str()), (Some("objection_raised"), Some("objection")));
|
||||
assert_eq!(gestures(raised), ["respond"]);
|
||||
assert_eq!(raised["objection"]["kind"], "redit");
|
||||
assert_eq!(raised["objection"]["heldVersion"]["id"], b.as_str());
|
||||
assert_eq!(raised["objection"]["other"]["id"], e1_id.as_str());
|
||||
assert_eq!(raised["objection"]["objector"], "Yvv");
|
||||
assert_eq!(raised["doc"]["groupSlug"], "essai");
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||
let det = chiara.detail(&b).await;
|
||||
let ob = &det["objections"][0];
|
||||
assert_eq!(
|
||||
(ob["held"].as_bool(), ob["iAnswer"].as_bool(), ob["phase"].as_str()),
|
||||
(Some(true), Some(true), Some("author"))
|
||||
);
|
||||
|
||||
// Before an answer there is nothing to reiterate; only the objector (or an administrator) lifts.
|
||||
let r = yvv.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||
assert_eq!(r, (StatusCode::CONFLICT, "nothing_to_reiterate".into()));
|
||||
assert_eq!(chiara.err("POST", &format!("/api/objections/{oid}/lift"), None).await.0, StatusCode::FORBIDDEN);
|
||||
|
||||
// A linked proposal must go on the article repeated (or her own): never elsewhere, never by someone else.
|
||||
let e2_unit = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
let elsewhere =
|
||||
json!({ "unitId": e2_unit, "objectionId": oid, "title": "Réciprocité", "body": "Autre.", "submit": true });
|
||||
let (s, _) = chiara.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(elsewhere)).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let by_yvv = json!({ "unitId": e1_unit, "objectionId": oid, "title": "Unicité", "body": "Autre.", "submit": true });
|
||||
assert_eq!(
|
||||
yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(by_yvv)).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
// Chiara changes the article it repeats: a proposal on E1, linked to the objection.
|
||||
let linked = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({
|
||||
"unitId": e1_unit, "objectionId": oid, "title": "Unicité du compte", "submit": true,
|
||||
"body": "Je m'engage sur l'honneur à n'avoir qu'un seul compte membre, et à m'y tenir.",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let det = yvv.detail(&b).await;
|
||||
let ob = &det["objections"][0];
|
||||
assert_eq!((ob["phase"].as_str(), ob["answer"].as_str()), (Some("objector"), Some("proposal")));
|
||||
assert_eq!(ob["answerId"], linked.as_str());
|
||||
assert_eq!((ob["canLift"].as_bool(), ob["canReiterate"].as_bool()), (Some(true), Some(true)));
|
||||
|
||||
// The objector is asked: lift or reiterate, by three days; her message is dealt with.
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!(m["unread"], 1);
|
||||
let asked = &m["messages"][0];
|
||||
assert_eq!(asked["kind"], "objection_answered");
|
||||
assert_eq!(gestures(asked), ["lift", "reiterate"]);
|
||||
assert!(asked["dueAt"].as_str().is_some_and(|d| d > asked["createdAt"].as_str().expect("at")), "{asked}");
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new());
|
||||
assert_eq!(m["messages"][0]["outcome"], "answered");
|
||||
assert_eq!(m["unread"], 0, "answering reads one's message");
|
||||
|
||||
// Yvv lifts from his message: the proposal is votable again, the message offers nothing more.
|
||||
yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!(find(&d, &b)["votable"], true);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it");
|
||||
assert_eq!(m["messages"][0]["outcome"], "lifted");
|
||||
yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
// Lifted is lifted.
|
||||
let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(again, (StatusCode::CONFLICT, "lifted".into()));
|
||||
}
|
||||
|
||||
/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time;
|
||||
/// a reiteration tells the author again; without an answer, lifted at the deadline.
|
||||
#[tokio::test]
|
||||
async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let title = "Certifier largement";
|
||||
let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements.";
|
||||
let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await;
|
||||
let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||
let phase =
|
||||
|d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64());
|
||||
|
||||
// A typo fix answers nothing.
|
||||
let typo = chiara
|
||||
.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{b}"),
|
||||
Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(typo["edit"], "typo");
|
||||
assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1)));
|
||||
|
||||
// Rewording answers: the objector is asked.
|
||||
let reworded =
|
||||
"Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées.";
|
||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await;
|
||||
assert_eq!(r["edit"], "substantive");
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message");
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(phase(&det), (Some("objector".into()), Some(1)));
|
||||
assert_eq!(det["objections"][0]["answer"], "revision");
|
||||
let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned();
|
||||
|
||||
// One cycle at a time: another rewording changes neither the messages nor the deadline.
|
||||
state.clock.advance(3600);
|
||||
let again = format!("{reworded} Et à les revoir.");
|
||||
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await;
|
||||
assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str());
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
||||
|
||||
// Reiterated: the objection stands, the author is told again, a second cycle opens.
|
||||
yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||
assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2)));
|
||||
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false);
|
||||
let m = chiara.messages().await;
|
||||
let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect();
|
||||
assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first");
|
||||
assert_eq!(gestures(&m["messages"][0]), ["respond"]);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0));
|
||||
|
||||
// A new answer, then nobody: three days later it is lifted by itself.
|
||||
let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.";
|
||||
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await;
|
||||
assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2)));
|
||||
state.clock.advance(3 * 86_400 - 60);
|
||||
deadlines::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet");
|
||||
state.clock.advance(120);
|
||||
let swept = deadlines::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(swept.lifted, 1);
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(
|
||||
(det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()),
|
||||
(Some("lifted"), Some("deadline"))
|
||||
);
|
||||
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rewording_of_the_other_side_asks_the_objector_too() {
|
||||
let (mut luna, _) = Client::new().await;
|
||||
luna.login("Luna", false).await;
|
||||
let mut chiara = luna.other();
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = luna.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = luna.doc("essai").await;
|
||||
let a = luna
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await;
|
||||
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from");
|
||||
// Luna rewords the earlier one: the pair changed, the objector judges again.
|
||||
luna.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{a}"),
|
||||
Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })),
|
||||
)
|
||||
.await;
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(
|
||||
(det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()),
|
||||
(Some("objector"), Some("other"))
|
||||
);
|
||||
assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut admin = chiara.other();
|
||||
admin.login("Admin", true).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
chiara
|
||||
.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{b}"),
|
||||
Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })),
|
||||
)
|
||||
.await;
|
||||
// An administrator never speaks for the objector...
|
||||
assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN);
|
||||
// ...but may lift.
|
||||
admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
let det = admin.detail(&b).await;
|
||||
assert_eq!(det["objections"][0]["lift"], "admin");
|
||||
assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin");
|
||||
|
||||
// Withdrawing either version ends a standing objection.
|
||||
let c = chiara
|
||||
.propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||
chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await;
|
||||
let lifted: (String, Option<String>) = {
|
||||
let det = yvv.detail(&c).await;
|
||||
let ob = det["objections"].as_array().expect("o").iter().find(|o| o["id"] == oid2.as_str()).expect("o").clone();
|
||||
(ob["phase"].as_str().expect("p").to_owned(), ob["lift"].as_str().map(str::to_owned))
|
||||
};
|
||||
assert_eq!(lifted, ("lifted".into(), Some("withdrawn".into())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_an_objection_may_be_about() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let e3 = unit(&doc, "E3");
|
||||
let (v1, v2) = (e3["proposals"][0]["id"].as_str().expect("v1"), e3["proposals"][1]["id"].as_str().expect("v2"));
|
||||
let post = |from: &str, other: &str, kind: &str, reason: &str| {
|
||||
(format!("/api/proposals/{from}/objections"), json!({ "otherId": other, "kind": kind, "reason": reason }))
|
||||
};
|
||||
let cases = [
|
||||
(post(&e1, &e2, "contredit", CONTRADICTS), "both_in_force"),
|
||||
(post(v1, v2, "redit", REPEATS), "same_unit"),
|
||||
(post(&e1, &e1, "redit", REPEATS), "other"),
|
||||
(post(v1, &e1, "signal", REPEATS), "kind"),
|
||||
(post(v1, &e1, "redit", "court"), "reason"),
|
||||
];
|
||||
for ((uri, body), code) in cases {
|
||||
let (s, e) = yvv.err("POST", &uri, Some(body)).await;
|
||||
assert_eq!((s, e.as_str()), (StatusCode::BAD_REQUEST, code));
|
||||
}
|
||||
// A draft is nobody's business yet.
|
||||
let draft = chiara
|
||||
.propose(&doc, json!({ "title": "Brouillon", "body": "Pas encore soumis au vote.", "submit": false }))
|
||||
.await;
|
||||
let (s, _) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{v1}/objections"),
|
||||
Some(json!({ "otherId": draft, "kind": "redit", "reason": REPEATS })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
// One standing objection per person and pair.
|
||||
yvv.object(v1, &e1, "redit", REPEATS).await;
|
||||
let twice = yvv
|
||||
.err(
|
||||
"POST",
|
||||
&format!("/api/proposals/{e1}/objections"),
|
||||
Some(json!({ "otherId": v1, "kind": "contredit", "reason": CONTRADICTS })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(twice, (StatusCode::CONFLICT, "objection_exists".into()));
|
||||
// Someone else may object to the same pair.
|
||||
chiara.object(v1, &e1, "contredit", CONTRADICTS).await;
|
||||
}
|
||||
|
||||
/// Votes cast before the objection stay counted; the version cannot enter into
|
||||
/// force while it is held, and enters once the objection is lifted.
|
||||
#[tokio::test]
|
||||
async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
// Every member of the group for it (written as they would have been before the objection).
|
||||
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("members");
|
||||
for m in &members {
|
||||
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
|
||||
.bind(&b)
|
||||
.bind(m)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
}
|
||||
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!(find(&d, &b)["tally"]["for"].as_u64(), Some(members.len() as u64), "the votes stay");
|
||||
assert!(find(&d, &b)["evaluation"]["reached"].as_bool().expect("reached"));
|
||||
let lifted = yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_message_is_read_by_its_recipient_only() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", true).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
yvv.object(&b, &e1, "redit", REPEATS).await;
|
||||
let m = chiara.messages().await;
|
||||
let mid = m["messages"][0]["id"].as_str().expect("id").to_owned();
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
// Not even an administrator reads or marks someone else's message.
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||
assert_eq!(yvv.call("POST", &format!("/api/messages/{mid}/read"), None).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
let mut nobody = chiara.other();
|
||||
assert_eq!(nobody.call("GET", "/api/messages", None).await.0, StatusCode::UNAUTHORIZED);
|
||||
// Marked as read, it stays in the box.
|
||||
assert_eq!(chiara.ok("POST", &format!("/api/messages/{mid}/read"), None).await["unread"], 0);
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!((m["messages"][0]["read"].as_bool(), gestures(&m["messages"][0])), (Some(true), vec!["respond"]));
|
||||
// All at once.
|
||||
let c = chiara
|
||||
.propose(&doc, json!({ "title": "Deux", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||
.await;
|
||||
yvv.object(&c, &e1, "contredit", CONTRADICTS).await;
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
assert_eq!(chiara.ok("POST", "/api/messages/read", None).await["unread"], 0);
|
||||
}
|
||||
|
||||
/// A deadline passed while the server was stopped is dealt with at the next start.
|
||||
#[tokio::test]
|
||||
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
|
||||
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let url = format!("sqlite://{}", path.display());
|
||||
let (b, oid) = {
|
||||
let db = open_db(&url).await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let mut chiara = Client { router: app(state.clone()), cookie: None };
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
let reworded = "Un seul compte membre par être humain pour la certifcation.";
|
||||
chiara
|
||||
.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": reworded })))
|
||||
.await;
|
||||
// And a typo fix of Yvv's on it, waiting for Chiara.
|
||||
let fixed = reworded.replace("certifcation", "certification");
|
||||
let fix =
|
||||
yvv.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": fixed }))).await;
|
||||
assert_eq!(fix["edit"], "typo_pending");
|
||||
state.db.close().await;
|
||||
(b, oid)
|
||||
};
|
||||
// Four days later, the server starts again.
|
||||
let db = open_db(&url).await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
state.clock.advance(4 * 86_400);
|
||||
let swept = deadlines::sweep(&state).await.expect("start");
|
||||
assert_eq!((swept.lifted, swept.applied), (1, 1));
|
||||
let (phase, lift): (String, Option<String>) = sqlx::query_as("SELECT phase, lift FROM signals WHERE id = ?")
|
||||
.bind(&oid)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("o");
|
||||
assert_eq!((phase.as_str(), lift.as_deref()), ("lifted", Some("deadline")));
|
||||
let body: String =
|
||||
sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
|
||||
assert_eq!(body, "Un seul compte membre par être humain pour la certification.");
|
||||
state.db.close().await;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The contradiction signals of before become objections « contredit », without
|
||||
/// loss, on a base migrated up to the version before (0001 to 0007).
|
||||
#[tokio::test]
|
||||
async fn contradiction_signals_of_before_become_contredits() {
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::str::FromStr;
|
||||
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
|
||||
let mut before = sqlx::migrate!("./migrations");
|
||||
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
|
||||
before.run(&pool).await.expect("up to 0007");
|
||||
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
|
||||
// The documents as the image of before wrote them (its amendments read tables of now).
|
||||
seed::documents(&state).await.expect("seed");
|
||||
let id = |code: &'static str, which: &'static str| {
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
let sql = if which == "in_force" {
|
||||
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'in_force'"
|
||||
} else {
|
||||
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'open' ORDER BY p.rowid LIMIT 1"
|
||||
};
|
||||
sqlx::query_scalar::<_, String>(sql).bind(code).fetch_one(&pool).await.expect("id")
|
||||
}
|
||||
};
|
||||
let (e2, k1, e4, e5) =
|
||||
(id("E2", "in_force").await, id("K1", "open").await, id("E4", "open").await, id("E5", "open").await);
|
||||
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-30T00:00:00Z')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("identity");
|
||||
let signal = |sid: &'static str, from: String, to: String, lifted: Option<&'static str>| {
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by)
|
||||
VALUES (?, ?, ?, 'Ces deux textes se contredisent.', 'dev:chiara', '2026-10-30T00:00:00Z', ?, ?)",
|
||||
)
|
||||
.bind(sid)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.bind(lifted.map(|_| "2026-10-30T01:00:00Z"))
|
||||
.bind(lifted)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("signal");
|
||||
}
|
||||
};
|
||||
signal("s1", k1.clone(), e2.clone(), None).await;
|
||||
signal("s2", e4.clone(), e5.clone(), None).await;
|
||||
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
|
||||
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
|
||||
// The image of now starts on it.
|
||||
let mut all = sqlx::migrate!("./migrations");
|
||||
all.set_ignore_missing(true);
|
||||
all.run(&pool).await.expect("0008");
|
||||
type Row = (String, String, String, Option<String>, Option<String>);
|
||||
let rows: Vec<Row> = sqlx::query_as("SELECT id, kind, phase, held_id, lift FROM signals ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("rows");
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("s1".into(), "contredit".into(), "author".into(), Some(k1.clone()), None),
|
||||
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
|
||||
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
|
||||
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
|
||||
],
|
||||
"K1 against the text in force; E5 written after E4 in the same second"
|
||||
);
|
||||
// Nothing lost: reasons, authors and dates as they were.
|
||||
let kept: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM signals WHERE reason = 'Ces deux textes se contredisent.' AND author_id = 'dev:chiara'
|
||||
AND created_at = '2026-10-30T00:00:00Z'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(kept, 4);
|
||||
// And the server reads them as objections: K1 and E5 are no longer votable.
|
||||
let st = ld_server::docs::load_state(
|
||||
&state,
|
||||
&sqlx::query_scalar::<_, String>("SELECT id FROM documents WHERE group_slug = 'essai'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("doc"),
|
||||
)
|
||||
.await
|
||||
.expect("state");
|
||||
assert!(
|
||||
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,34 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
|
||||
let mut yvv = Client::new().await;
|
||||
yvv.login("Yvv", true).await;
|
||||
let doc = the_doc(&mut yvv, "essai").await;
|
||||
// Two articles of a single open version each: one of them is held (ADR 0010).
|
||||
let (i2, k1) = (version(&doc, "I2", "0"), version(&doc, "K1", "0"));
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{i2}/objections"),
|
||||
Some(json!({ "otherId": k1, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
// Nobody can vote on it while it is held: one module fewer to vote, for everyone.
|
||||
let d = decisions(&mut yvv, "essai").await;
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
||||
// The list of the group still counts it among the proposals in progress: it is open.
|
||||
let g = yvv.get("/api/groups/essai").await;
|
||||
let open = doc["units"]
|
||||
.as_array()
|
||||
.expect("units")
|
||||
.iter()
|
||||
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|p| p["status"] == "open").count() as u64);
|
||||
assert_eq!(g["docs"][0]["open"].as_u64(), Some(open.sum::<u64>()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
||||
let mut admin = Client::new().await;
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010), end to end on an
|
||||
//! in-memory database. What a typo is does not change (`ld_engine::edit`); who
|
||||
//! applies it, and when, does.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, deadlines, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self { router: app(state.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 err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str, admin: bool) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).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
|
||||
}
|
||||
|
||||
async fn propose(&mut self, doc: &Value, title: &str, body: &str) -> String {
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
let v = self
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{id}/proposals"),
|
||||
Some(json!({ "title": title, "body": body, "submit": true })),
|
||||
)
|
||||
.await;
|
||||
v["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
async fn fix(&mut self, id: &str, title: &str, body: &str) -> Value {
|
||||
self.ok("PATCH", &format!("/api/proposals/{id}"), Some(json!({ "title": title, "body": body }))).await
|
||||
}
|
||||
|
||||
async fn detail(&mut self, id: &str) -> Value {
|
||||
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
||||
}
|
||||
|
||||
async fn messages(&mut self) -> Value {
|
||||
self.ok("GET", "/api/messages", None).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
fn gestures(v: &Value) -> Vec<&str> {
|
||||
v["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
||||
}
|
||||
|
||||
fn last(history: &Value) -> &Value {
|
||||
history.as_array().expect("history").last().expect("entry")
|
||||
}
|
||||
|
||||
const TITLE: &str = "Réciprocité";
|
||||
const VOTED: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagemnts.";
|
||||
const FIX_1: &str = "Je m'engage à ne certifier que des personnes qui respectent ces engagemnts.";
|
||||
const FIX_2: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagements.";
|
||||
|
||||
/// C30, by the API: someone else's typo on a proposal waits for its author, who
|
||||
/// validates it from her messages — and keeps the hand while it is not in force.
|
||||
#[tokio::test]
|
||||
async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
|
||||
let w = yvv.fix(&p, TITLE, FIX_1).await;
|
||||
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
||||
assert!(w["typo"]["dueAt"].is_string());
|
||||
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
||||
// Nothing written yet: the text stays, and the pending fix shows with its diff.
|
||||
let d = yvv.detail(&p).await;
|
||||
assert_eq!(d["proposal"]["body"], VOTED);
|
||||
let t = &d["typos"][0];
|
||||
assert_eq!((t["status"].as_str(), t["writer"].as_str()), (Some("pending"), Some("Yvv")));
|
||||
assert!(t["diff"].as_array().is_some_and(|x| !x.is_empty()));
|
||||
assert_eq!(gestures(t), Vec::<&str>::new(), "the writer does not answer");
|
||||
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), ["validate", "refuse"]);
|
||||
assert_eq!(yvv.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
||||
|
||||
// Its author is told, by a message with the gestures.
|
||||
let m = chiara.messages().await;
|
||||
let msg = &m["messages"][0];
|
||||
assert_eq!((msg["kind"].as_str(), msg["topic"].as_str()), (Some("typo_pending"), Some("typo")));
|
||||
assert_eq!(gestures(msg), ["validate", "refuse"]);
|
||||
assert_eq!(msg["typo"]["writer"], "Yvv");
|
||||
assert_eq!(msg["typo"]["version"]["id"], p.as_str());
|
||||
|
||||
// Validated: the fix is in the text, the vote stays.
|
||||
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
||||
let d = chiara.detail(&p).await;
|
||||
assert_eq!(d["proposal"]["body"], FIX_1);
|
||||
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
||||
let h = last(&d["history"]);
|
||||
assert_eq!(
|
||||
(h["kind"].as_str(), h["author"].as_str(), h["typo"]["answeredBy"].as_str()),
|
||||
(Some("typo"), Some("Yvv"), Some("Chiara"))
|
||||
);
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!(
|
||||
(gestures(&m["messages"][0]).len(), m["messages"][0]["outcome"].as_str(), m["unread"].as_i64()),
|
||||
(0, Some("applied"), Some(0))
|
||||
);
|
||||
|
||||
// The author keeps the hand while it is not in force: she takes it back, then validates it again.
|
||||
assert_eq!(gestures(&d["typos"][0]), ["refuse"]);
|
||||
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
||||
let d = chiara.detail(&p).await;
|
||||
assert_eq!(d["proposal"]["body"], VOTED);
|
||||
assert_eq!(last(&d["history"])["kind"], "revert");
|
||||
assert_eq!(gestures(&d["typos"][0]), ["validate"]);
|
||||
chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
||||
|
||||
// Her own fixes apply at once, as before.
|
||||
let own = chiara.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await;
|
||||
assert_eq!(own["edit"], "typo");
|
||||
// A change of meaning stays refused to anyone else.
|
||||
let s = yvv
|
||||
.err("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": TITLE, "body": "Tout autre chose." })))
|
||||
.await;
|
||||
assert_eq!(s, (StatusCode::CONFLICT, "substantive_not_author".into()));
|
||||
}
|
||||
|
||||
/// C31, by the API: on a text in force, only a validated fix corrects it — by its
|
||||
/// author, or an administrator for a starting text; else by itself after three days.
|
||||
#[tokio::test]
|
||||
async fn on_a_text_in_force_only_a_validated_typo_corrects_it() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", true).await;
|
||||
let doc = chiara.doc("monnaie-libre").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let corrections = || {
|
||||
let db = state.db.clone();
|
||||
let doc_id = doc_id.clone();
|
||||
async move {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM outbox WHERE doc_id = ? AND reason = 'correction typographique'",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("outbox")
|
||||
}
|
||||
};
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let e1_id = e1["id"].as_str().expect("id").to_owned();
|
||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
let w = chiara.fix(&e1_id, e1["title"].as_str().expect("t"), &fixed).await;
|
||||
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("admin")));
|
||||
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
||||
assert_ne!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str(), "not in force before it is validated");
|
||||
assert_eq!(corrections().await, 0, "nothing republished");
|
||||
// A starting text has no author: an administrator answers, from his messages.
|
||||
assert_eq!(gestures(&yvv.messages().await["messages"][0]), ["validate", "refuse"]);
|
||||
assert_eq!(chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
||||
yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||
assert_eq!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str());
|
||||
assert_eq!(corrections().await, 1, "republished once validated");
|
||||
// In force, an answer is final.
|
||||
assert_eq!(
|
||||
yvv.err("POST", &format!("/api/typos/{tid}/refuse"), None).await,
|
||||
(StatusCode::CONFLICT, "settled".into())
|
||||
);
|
||||
|
||||
// Nobody answers: three days later it applies by itself.
|
||||
let e2 = unit(&doc, "E2")["inForce"].clone();
|
||||
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
||||
let e2_fixed = e2["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
chiara.fix(&e2_id, e2["title"].as_str().expect("t"), &e2_fixed).await;
|
||||
state.clock.advance(3 * 86_400 - 60);
|
||||
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
||||
state.clock.advance(120);
|
||||
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 1);
|
||||
let d = chiara.detail(&e2_id).await;
|
||||
assert_eq!(d["proposal"]["body"], e2_fixed.as_str());
|
||||
assert_eq!(last(&d["history"])["typo"]["answeredBy"], Value::Null);
|
||||
assert_eq!(last(&d["history"])["typo"]["deadline"], true);
|
||||
assert_eq!(corrections().await, 2);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
||||
|
||||
// An administrator's own fix on a starting text applies at once.
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let i1_id = i1["id"].as_str().expect("id");
|
||||
let body = i1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
assert_eq!(yvv.fix(i1_id, i1["title"].as_str().expect("t"), &body).await["edit"], "typo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_a_text_in_force_by_an_author_she_answers_and_a_refusal_is_final() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
// W = 2 in the test group: two voices for make it pass.
|
||||
for c in [&mut chiara, &mut yvv] {
|
||||
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
}
|
||||
assert_eq!(chiara.detail(&p).await["proposal"]["status"], "in_force");
|
||||
let tid = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
||||
assert_eq!(chiara.detail(&p).await["proposal"]["body"], VOTED);
|
||||
// In force: no going back on the answer.
|
||||
let back = chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
||||
assert_eq!(back, (StatusCode::CONFLICT, "settled".into()));
|
||||
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
/// Two fixes waiting on one text: each is judged against the voted text; once one
|
||||
/// applies, the other is written over a text that is gone — stale, « recharge ».
|
||||
#[tokio::test]
|
||||
async fn two_pending_typos_on_one_text_the_second_goes_stale() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut luna = chiara.other();
|
||||
luna.login("Luna", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
let t1 = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||
let w2 = luna.fix(&p, TITLE, FIX_2).await;
|
||||
assert_eq!(w2["edit"], "typo_pending", "each one slip from the voted text");
|
||||
let t2 = w2["typo"]["id"].as_str().expect("id").to_owned();
|
||||
chiara.ok("POST", &format!("/api/typos/{t1}/validate"), None).await;
|
||||
let d = chiara.detail(&p).await;
|
||||
let second = d["typos"].as_array().expect("t").iter().find(|t| t["id"] == t2.as_str()).expect("t2").clone();
|
||||
assert_eq!((second["status"].as_str(), gestures(&second).len()), (Some("stale"), 0));
|
||||
let late = chiara.err("POST", &format!("/api/typos/{t2}/validate"), None).await;
|
||||
assert_eq!(late, (StatusCode::CONFLICT, "changed".into()));
|
||||
let m = chiara.messages().await;
|
||||
let msg =
|
||||
m["messages"].as_array().expect("m").iter().find(|x| x["typo"]["id"] == t2.as_str()).expect("msg").clone();
|
||||
assert_eq!((msg["outcome"].as_str(), gestures(&msg).len()), (Some("stale"), 0));
|
||||
// The deadline never applies it.
|
||||
state.clock.advance(4 * 86_400);
|
||||
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
||||
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
||||
// Written again over the text of now, it waits as any other.
|
||||
assert_eq!(luna.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await["edit"], "typo_pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_change_of_substance_or_a_withdrawal_leaves_pending_typos_stale() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
let t = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
||||
chiara
|
||||
.fix(&p, TITLE, "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.")
|
||||
.await;
|
||||
assert_eq!(chiara.detail(&p).await["typos"][0]["status"], "stale");
|
||||
assert_eq!(
|
||||
chiara.err("POST", &format!("/api/typos/{t}/validate"), None).await,
|
||||
(StatusCode::CONFLICT, "changed".into())
|
||||
);
|
||||
let q = chiara.propose(&doc, "Autre", VOTED).await;
|
||||
yvv.fix(&q, "Autre", FIX_1).await;
|
||||
chiara.ok("POST", &format!("/api/proposals/{q}/withdraw"), None).await;
|
||||
let stale: i64 = {
|
||||
let d = chiara.detail(&q).await;
|
||||
d["typos"].as_array().expect("t").iter().filter(|t| t["status"] == "stale").count() as i64
|
||||
};
|
||||
assert_eq!(stale, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn translations_stay_editorial() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
yvv.ok(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{p}/translations/en"),
|
||||
Some(json!({ "title": "Reciprocity", "body": "I only certify people who keep these commitments." })),
|
||||
)
|
||||
.await;
|
||||
let d = chiara.detail(&p).await;
|
||||
assert_eq!(d["proposal"]["translations"]["en"]["title"], "Reciprocity", "without any validation");
|
||||
assert_eq!(d["typos"].as_array().expect("t").len(), 0);
|
||||
assert_eq!(chiara.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||
}
|
||||
|
||||
/// A text without author: only the administrators who open its group are told,
|
||||
/// and a message shows only while its recipient opens the group.
|
||||
#[tokio::test]
|
||||
async fn only_administrators_who_open_the_group_are_told() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", true).await;
|
||||
let mut luna = chiara.other();
|
||||
luna.login("Luna", true).await;
|
||||
let leave = |who: &'static str| {
|
||||
let db = state.db.clone();
|
||||
async move {
|
||||
sqlx::query("DELETE FROM memberships WHERE identity_id = ? AND group_slug = 'essai'")
|
||||
.bind(who)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("leave");
|
||||
}
|
||||
};
|
||||
leave("dev:luna").await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let body = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
let w = chiara.fix(e1["id"].as_str().expect("id"), e1["title"].as_str().expect("t"), &body).await;
|
||||
assert_eq!(w["typo"]["answerer"], "admin");
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
||||
assert_eq!(luna.messages().await["messages"].as_array().expect("m").len(), 0, "Luna does not open the test group");
|
||||
leave("dev:yvv").await;
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0, "no longer shown");
|
||||
}
|
||||
Reference in New Issue
Block a user