Server overhaul: one test harness in tests/common for every test binary

core_api/common.rs becomes tests/common/mod.rs and sync_api/m4.rs tests/common/sync_fixtures.rs; each binary
includes them through #[path] (the binaries of SPEC §14.7 stay as they are). TempDir's Default moves into the
harness instead of three copies; the skeleton helpers take ids, tokens and TempDir from it. Milestone wording in
test docs and the server README replaced by what the code does; README seams and test layout updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:31:04 +02:00
parent 6c405e142d
commit 8cb42f85a9
19 changed files with 118 additions and 146 deletions
@@ -1,4 +1,5 @@
//! The M3 test harness: a migrated temporary database, a fixed clock, recording fakes of `AuditWriter` and
//! The integration-test harness shared by every test binary of this crate (included with `#[path]`, so it is not a
//! test target itself): a migrated temporary database, a fixed clock, recording fakes of `AuditWriter` and
//! `PushQueue`, a seeded household with the ids of `contracts/examples/`, one router per app (so the rate limiter
//! keeps its state across requests) and helpers to compare responses with the contract examples.
@@ -49,7 +50,7 @@ pub fn old_device_token() -> String {
token('R')
}
/// Records every audit event (M4 replaces the real writer; M3 only calls it).
/// Records every audit event (`sync_fixtures::app` installs the real writer instead).
#[derive(Default)]
pub struct RecordingAudit(pub Mutex<Vec<AuditEvent>>);
@@ -85,6 +86,12 @@ impl TempDir {
}
}
impl Default for TempDir {
fn default() -> Self {
Self::new()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
@@ -291,7 +298,7 @@ impl TestApp {
}
}
/// An expense inserted directly (sync is M4's): `shares` are (admin öre, member öre).
/// An expense inserted directly (not through sync): `shares` are (admin öre, member öre).
pub async fn insert_expense(
&self,
account: &str,
@@ -1,5 +1,5 @@
//! M4 additions to M3's harness: an app with the real audit writer, entity and mutation builders, push and pull
//! helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries.
//! Sync additions to the harness of `common`: an app with the real audit writer, entity and mutation builders, push
//! and pull helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries.
use std::sync::Arc;
@@ -1,14 +1,17 @@
//! M3 integration tests: every endpoint of E-2 to E-30 and E-33 with each error code SPEC §14.3.4 lists for it, the
//! recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page
//! (AC-2.5), APK serving (§10.6), the `bootstrap` and `pair-device` subcommands (FR-1) and the logging rule (§8.7).
//! REST integration tests: every endpoint of E-2 to E-30 and E-33 with each error code SPEC §14.3.4 lists for it,
//! the recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page
//! (AC-2.5), APK serving (§10.6), the `bootstrap`, `pair-device` and `invite-partner` subcommands (FR-1, D-113) and
//! the logging rule (§8.7).
//!
//! One test binary (faster to build than one per file); `common` is the harness.
//! One test binary (faster to build than one per file); the harness is `tests/common/`.
#[path = "../common/mod.rs"]
pub mod common;
mod accounts;
mod apk;
mod categories;
mod cli;
mod common;
mod devices;
mod generation;
mod invites;
@@ -16,8 +16,8 @@ use serde_json::{Value, json};
use uuid::Uuid;
use crate::common::*;
use crate::m4;
use crate::stub::*;
use crate::sync_fixtures;
const EXPENSE: &str = "01929c5e-1111-7aaa-8bbb-000000000001";
const FAST: [u64; 3] = [10, 20, 40];
@@ -31,7 +31,7 @@ struct Setup {
impl Setup {
async fn new() -> Self {
let app = m4::app().await;
let app = sync_fixtures::app().await;
for (device, tok) in [
(ADMIN_DEVICE_ID, "tok-admin"),
(MEMBER_DEVICE_ID, "tok-member"),
@@ -445,12 +445,12 @@ async fn sync_push_reaches_fcm_through_the_queue_and_the_sender_task() {
delays: vec![Duration::from_millis(10)],
},
);
m4::with_push(&mut s.app, queue);
let id = m4::new_id();
m4::push_one(
sync_fixtures::with_push(&mut s.app, queue);
let id = sync_fixtures::new_id();
sync_fixtures::push_one(
&s.app,
&member_token(),
m4::exp(m4::expense(&id, "2026-10-17T11:00:00.000Z")),
sync_fixtures::exp(sync_fixtures::expense(&id, "2026-10-17T11:00:00.000Z")),
)
.await;
let deadline = Instant::now() + Duration::from_secs(5);
@@ -1,17 +1,11 @@
//! M4 push tests (SPEC §14.6, FR-13): payloads of every type against `contracts/examples/push/`, recipients, the
//! Push tests (SPEC §14.6, FR-13): payloads of every type against `contracts/examples/push/`, recipients, the
//! OAuth2 service-account flow and token cache, retries and their delays, token clearing on `UNREGISTERED`/404,
//! queue overflow, push disabled without credentials, all against a local stub of Google's two endpoints.
#[path = "../core_api/common.rs"]
#[path = "../common/mod.rs"]
pub mod common;
#[path = "../sync_api/m4.rs"]
pub mod m4;
pub mod stub;
#[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod fcm;
impl Default for common::TempDir {
fn default() -> Self {
Self::new()
}
}
@@ -1,16 +1,10 @@
//! M4 scheduler tests (SPEC §9.5 scheduler cases, §14.1 pruning, D-021): the daily run planned at local 06:00 and
//! Scheduler tests (SPEC §9.5 scheduler cases, §14.1 pruning, D-021): the daily run planned at local 06:00 and
//! 04:00 across both DST changes, the run at start, clock jumps with an injected clock, catch-up after downtime
//! through M3's generation function, paused and deleted templates skipped, and the 90-day pruning.
//! through the recurring generation, paused and deleted templates skipped, and the 90-day pruning.
#[path = "../core_api/common.rs"]
#[path = "../common/mod.rs"]
pub mod common;
#[path = "../sync_api/m4.rs"]
pub mod m4;
#[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod scheduler;
impl Default for common::TempDir {
fn default() -> Self {
Self::new()
}
}
@@ -9,7 +9,7 @@ use kvitt_server::time::{FixedClock, format_ts, parse_ts};
use tokio::sync::mpsc;
use crate::common::*;
use crate::m4;
use crate::sync_fixtures;
fn ts(text: &str) -> chrono::DateTime<chrono::Utc> {
parse_ts(text).unwrap()
@@ -175,10 +175,10 @@ async fn periods(app: &TestApp, template: &str) -> Vec<String> {
#[tokio::test]
async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_deleted() {
let mut app = m4::app().await;
let active = m4::new_id();
let paused = m4::new_id();
let deleted = m4::new_id();
let mut app = sync_fixtures::app().await;
let active = sync_fixtures::new_id();
let paused = sync_fixtures::new_id();
let deleted = sync_fixtures::new_id();
// Last generated July; the server was down since. Now is 17 October 14:00.
insert_template(&app, &active, 15, "2026-06", Some("2026-07"), false, false).await;
insert_template(&app, &paused, 1, "2026-06", None, true, false).await;
@@ -204,8 +204,8 @@ async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_d
#[tokio::test]
async fn the_06_00_tick_generates_the_period_due_that_morning() {
let app = m4::app().await;
let id = m4::new_id();
let app = sync_fixtures::app().await;
let id = sync_fixtures::new_id();
insert_template(&app, &id, 15, "2026-11", None, false, false).await;
// 15 November 2026, 05:59 CET: not yet.
app.set_now("2026-11-15T04:59:00.000Z");
@@ -239,14 +239,14 @@ async fn the_06_00_tick_generates_the_period_due_that_morning() {
}
async fn insert_mutation(app: &TestApp, received_at: &str) -> String {
let id = m4::new_id();
let id = sync_fixtures::new_id();
sqlx::query(
"INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \
VALUES (?, ?, 'expense', ?, '{}', ?)",
)
.bind(&id)
.bind(ADMIN_DEVICE_ID)
.bind(m4::new_id())
.bind(sync_fixtures::new_id())
.bind(received_at)
.execute(&app.state.db)
.await
@@ -256,7 +256,7 @@ async fn insert_mutation(app: &TestApp, received_at: &str) -> String {
#[tokio::test]
async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() {
let app = m4::app().await;
let app = sync_fixtures::app().await;
let now = ts(NOW);
insert_mutation(
&app,
@@ -277,7 +277,7 @@ async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() {
#[tokio::test]
async fn pruning_runs_at_04_00_local() {
let app = m4::app().await;
let app = sync_fixtures::app().await;
// 03:59 CEST on 18 October.
app.set_now("2026-10-18T01:59:00.000Z");
insert_mutation(&app, "2026-07-01T00:00:00.000Z").await;
@@ -290,11 +290,13 @@ async fn pruning_runs_at_04_00_local() {
.await;
});
tokio::time::sleep(Duration::from_millis(30)).await;
assert_eq!(m4::count(&app, "sync_mutations").await, 1);
assert_eq!(sync_fixtures::count(&app, "sync_mutations").await, 1);
app.set_now("2026-10-18T02:00:00.000Z");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while m4::count(&app, "sync_mutations").await > 0 && std::time::Instant::now() < deadline {
while sync_fixtures::count(&app, "sync_mutations").await > 0
&& std::time::Instant::now() < deadline
{
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(m4::count(&app, "sync_mutations").await, 0);
assert_eq!(sync_fixtures::count(&app, "sync_mutations").await, 0);
}
@@ -1,5 +1,8 @@
//! Integration tests of the M2a server skeleton: migration identity and pragmas, routes, authentication and the
//! protocol rule, error shapes, and the wire types against `contracts/examples/`.
//! Base server tests: migration identity and pragmas, routes, authentication and the protocol rule, error shapes,
//! and the wire types against `contracts/examples/`.
#[path = "../common/mod.rs"]
pub mod common;
mod errors;
mod migration;
@@ -80,7 +80,7 @@ async fn every_route_is_registered_and_implemented() {
33,
"SPEC §7 has 33 operations"
);
// M3 implemented E-3 to E-30 and E-33 (tests/core_api), M4 the sync routes (tests/sync_api): nothing is 501.
// Every route has its handler (behaviour in tests/core_api and tests/sync_api): nothing is 501.
for (method, path, _) in routes {
let reply = app
.send(authed(method.clone(), &path, &admin_token()))
@@ -1,7 +1,7 @@
//! Shared helpers of the skeleton tests: a migrated temporary database, seeded users and devices, a fixed
//! clock and a one-shot request helper.
//! Helpers of the base tests: a database migrated to a chosen schema version, seeded users and devices (the
//! revoked device here is the member's), a fixed clock and a one-shot request helper. Ids, tokens and the
//! temporary directory come from the shared harness (`tests/common/`).
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
@@ -12,45 +12,14 @@ use kvitt_server::time::{FixedClock, parse_ts};
use kvitt_server::{AppState, auth, db, router};
use serde_json::Value;
use tower::ServiceExt;
use uuid::Uuid;
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001";
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002";
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001";
pub const MEMBER_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000002";
pub const REVOKED_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000003";
pub const NOW: &str = "2026-10-17T12:00:00.000Z";
pub use crate::common::{
ADMIN_DEVICE_ID, ADMIN_ID, MEMBER_DEVICE_ID, MEMBER_ID, NOW, TempDir, admin_token,
member_token, old_device_token as revoked_token, token,
};
pub fn token(tag: char) -> String {
format!("kvt_{}", tag.to_string().repeat(43))
}
pub fn admin_token() -> String {
token('A')
}
pub fn member_token() -> String {
token('M')
}
pub fn revoked_token() -> String {
token('R')
}
/// A data directory removed on drop.
pub struct TempDir(pub PathBuf);
impl TempDir {
pub fn new() -> Self {
let dir = std::env::temp_dir().join(format!("kvitt-test-{}", Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The member's revoked device (the id of the shared harness's old admin device).
pub const REVOKED_DEVICE_ID: &str = crate::common::ADMIN_OLD_DEVICE_ID;
pub struct TestApp {
/// Kept alive so the directory is removed when the app is dropped.
@@ -103,7 +103,7 @@ fn every_example_round_trips() {
round_trip::<ErrorBody>(&name, &example);
checked += 1;
} else if dir_name == "push" {
// FCM payloads (SPEC §14.6) are built by M4's sender, not by a wire type of this crate.
// FCM payloads (SPEC §14.6) are built by the push sender, not by a wire type of this crate.
continue;
} else if check(&name, &example) {
checked += 1;
@@ -1,10 +1,10 @@
//! The real audit writer (SPEC §14.4.7, FR-12) through M3's recurring generation and through sync: source, actor,
//! The real audit writer (SPEC §14.4.7, FR-12) through the recurring generation and through sync: source, actor,
//! times and append-only entries.
use serde_json::json;
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
#[tokio::test]
async fn generation_writes_recurring_audit_with_server_now_and_no_device() {
@@ -3,7 +3,7 @@
use serde_json::json;
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
const T0: &str = "2026-10-17T10:00:00.000Z";
@@ -24,7 +24,7 @@ fn delete(id: &str, at: &str) -> serde_json::Value {
#[tokio::test]
async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_order() {
for later_arrives_first in [true, false] {
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
let anna = exp(edit(&id, "2026-10-17T11:00:00.000Z", "Anna's"));
@@ -50,7 +50,7 @@ async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_ord
#[tokio::test]
async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timestamps() {
// Delete first, then a newer edit: rejected deleted.
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
accepted(
@@ -69,7 +69,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
.await;
rejected(&r, "deleted");
// Edit first, then an older delete: applied.
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
accepted(
@@ -94,7 +94,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
#[tokio::test]
async fn same_user_on_two_devices_is_lww_per_row() {
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
// A second, non-revoked device of the admin.
let second = token('S');
app.insert_device(
@@ -129,7 +129,7 @@ async fn same_user_on_two_devices_is_lww_per_row() {
#[tokio::test]
async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archived_category_accepted()
{
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
tombstone(&app, "accounts", ACCOUNT_1).await;
rejected(
&push_one(&app, &admin_token(), exp(expense(&new_id(), T0))).await,
@@ -150,7 +150,7 @@ async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archi
async fn epoch_recovery_re_push_of_identical_rows_comes_back_stale_with_equal_content() {
// §14.4.8: after a restore the phone re-pushes everything; identical rows are stale with current equal to the
// pushed client-writable fields (the phone treats that as accepted), missing rows are inserted.
let app = crate::m4::app().await;
let app = crate::sync_fixtures::app().await;
let kept = new_id();
let entity = expense(&kept, T0);
push_one(&app, &admin_token(), exp(entity.clone())).await;
@@ -6,7 +6,7 @@ use std::sync::Arc;
use kvitt_server::push::{PushHub, PushType};
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
#[tokio::test]
async fn create_on_device_a_pull_on_device_b_row_audit_and_one_push() {
@@ -1,13 +1,14 @@
//! M4 sync integration tests: every rule of SPEC §14.4.2 (pull) and §14.4.4 (push steps 1 to 12), each rejection
//! Sync integration tests: every rule of SPEC §14.4.2 (pull) and §14.4.4 (push steps 1 to 12), each rejection
//! reason of §14.4.6, the §9.10 conflict cases, batch limits, `cursor_ahead`, the epoch, the 14-day offline scenario
//! (AC-15.6), the audit writer (§14.4.7), `rotate-epoch` (§10.4), the end-to-end path device A push → device B pull,
//! and the orchestrator-assigned `serve` fixes (ConnectInfo for the rate limiter, `/i/` HTML 404).
//! and `serve` itself (ConnectInfo for the rate limiter, `/i/` HTML 404, the shutdown drain).
//!
//! Reuses M3's harness (`tests/core_api/common.rs`) with the real `SqlAuditWriter` swapped in (`m4.rs`).
//! The harness is `tests/common/`, with the real `SqlAuditWriter` swapped in by `sync_fixtures`.
#[path = "../core_api/common.rs"]
#[path = "../common/mod.rs"]
pub mod common;
pub mod m4;
#[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod audit;
mod conflicts;
@@ -16,9 +17,3 @@ mod offline;
mod pull;
mod push_rules;
mod serve;
impl Default for common::TempDir {
fn default() -> Self {
Self::new()
}
}
@@ -6,7 +6,7 @@ use kvitt_server::time::{format_ts, parse_ts};
use serde_json::{Value, json};
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
#[tokio::test]
async fn fourteen_days_offline_200_changes_all_sync() {
@@ -4,7 +4,7 @@ use axum::http::Method;
use serde_json::Value;
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
const T1: &str = "2026-10-17T11:00:00.000Z";
const ARRAYS: [&str; 7] = [
@@ -5,7 +5,7 @@ use axum::http::Method;
use serde_json::{Value, json};
use crate::common::*;
use crate::m4::*;
use crate::sync_fixtures::*;
/// A change applied to a pushed entity.
type Mutator = Box<dyn Fn(&mut Value)>;