forked from yvv/decision
552 lines
21 KiB
Rust
552 lines
21 KiB
Rust
//! 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 (?8 IS NULL OR status = ?8)",
|
|
)
|
|
.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"));
|
|
}
|
|
// The author's change of mind lands only on a version still open (relecture of cd7e683).
|
|
let only = s.while_open.then_some("open");
|
|
let written = match s.text {
|
|
TextChange::Write => {
|
|
write_text(
|
|
&mut tx,
|
|
&t.proposal_id,
|
|
t.base(),
|
|
(&t.title, &t.body),
|
|
only,
|
|
&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),
|
|
only,
|
|
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)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Config, open_db, seed};
|
|
|
|
/// Relecture of cd7e683 (M1): the author takes back a fix applied, read while her
|
|
/// version was open; a vote puts it in force in between. The text in force stays.
|
|
#[tokio::test]
|
|
async fn a_change_of_mind_never_writes_over_a_text_just_in_force() {
|
|
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");
|
|
let now = "2026-10-31T00:00:00Z";
|
|
// A seed proposal (ADR 0011) the vote has just put in force.
|
|
let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as(
|
|
"SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id
|
|
WHERE p.status = 'open' AND p.seed_featured = 1 AND p.kind = 'text' LIMIT 1",
|
|
)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("a seed proposal");
|
|
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ? WHERE id = ?")
|
|
.bind(now)
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("in force");
|
|
sqlx::query("UPDATE units SET in_force_id = ?1 WHERE id = (SELECT unit_id FROM proposals WHERE id = ?1)")
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("unit");
|
|
sqlx::query(
|
|
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)",
|
|
)
|
|
.bind(now)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("identity");
|
|
// Its text is the one a fix wrote over « Un texte d'avant ».
|
|
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 ('t1', ?, ?, 'dev:aude', 'dev:aude', ?, ?, 'Un texte d''avant', ?, ?, 'applied', ?, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(&doc)
|
|
.bind(revision)
|
|
.bind(&title)
|
|
.bind(&title)
|
|
.bind(&body)
|
|
.bind(now)
|
|
.bind(now)
|
|
.execute(&state.db)
|
|
.await
|
|
.expect("typo");
|
|
let t = load(&state, "t1").await.expect("load");
|
|
// What `answer` decided when it read the version, still open.
|
|
let s = correction::answer(TypoStatus::Applied, Answer::Refuse, false).expect("refuse");
|
|
assert_eq!(settle(&state, &t, s, "dev:aude", now).await.expect("settle"), Err("changed"));
|
|
let kept: String = sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?")
|
|
.bind(&id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.expect("body");
|
|
assert_eq!(kept, body, "the text in force stays");
|
|
let status: String =
|
|
sqlx::query_scalar("SELECT status FROM typos WHERE id = 't1'").fetch_one(&state.db).await.expect("status");
|
|
assert_eq!(status, "applied", "nothing settled");
|
|
}
|
|
}
|