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
+32 -27
View File
@@ -6,7 +6,7 @@ The Rust backend of Kvitt (SPEC §6.3): one Cargo workspace with three crates.
|---|---|---| |---|---|---|
| `crates/kvitt-domain` | library, no I/O | Split, balance, allocation and settlement rules (SPEC §9.1, §9.3, §9.4, §14.5) and `next_occurrence_date` with the day clamping of §9.5. Passes every vector in `contracts/test-vectors/`. | | `crates/kvitt-domain` | library, no I/O | Split, balance, allocation and settlement rules (SPEC §9.1, §9.3, §9.4, §14.5) and `next_occurrence_date` with the day clamping of §9.5. Passes every vector in `contracts/test-vectors/`. |
| `crates/kvitt-server` | binary `kvitt-server` plus `lib.rs` | Config, SQLite pool and migrations, HTTP API, auth, CLI. | | `crates/kvitt-server` | binary `kvitt-server` plus `lib.rs` | Config, SQLite pool and migrations, HTTP API, auth, CLI. |
| `crates/kvitt-import` | binary `kvitt-import` plus `lib.rs` | Splitwise importer, implemented per SPEC §10.8 (M9, D-044 to D-046): one CSV export per run into one account, in one transaction that commits only when every row is valid and the account balance reconciles with the file's totals rows. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch. Category table in `crates/kvitt-import/data/splitwise_categories.csv`. | | `crates/kvitt-import` | binary `kvitt-import` plus `lib.rs` | Splitwise importer, implemented per SPEC §10.8 (D-044 to D-046): one CSV export per run into one account, in one transaction that commits only when every row is valid and the account balance reconciles with the file's totals rows. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch. Category table in `crates/kvitt-import/data/splitwise_categories.csv`. |
The Rust toolchain is pinned in `rust-toolchain.toml` (rustup installs it on first use); `Dockerfile` uses the same The Rust toolchain is pinned in `rust-toolchain.toml` (rustup installs it on first use); `Dockerfile` uses the same
version. version.
@@ -46,20 +46,20 @@ database is then `$KVITT_DATA_DIR/kvitt.sqlite`. Every connection uses WAL, `for
`kvitt-server <command>` (SPEC §10.4, exhaustive): `kvitt-server <command>` (SPEC §10.4, exhaustive):
| Command | State in M2a | Exit codes | | Command | What it does | Exit codes |
|---|---|---| |---|---|---|
| `serve` | Works: migrations, `tasks::start` (push sender, scheduler, pruning), HTTP server with the client address in `ConnectInfo` (rate limiter); SIGTERM drains in-flight requests for up to 10 s. | 0 on clean shutdown | | `serve` | Migrations, `tasks::start` (push sender, scheduler, pruning), HTTP server with the client address in `ConnectInfo` (rate limiter); SIGTERM drains in-flight requests for up to 10 s. | 0 on clean shutdown |
| `migrate` | Works: migrations only. | 0 | | `migrate` | Migrations only. | 0 |
| `healthcheck` | Works: `GET http://127.0.0.1:<port of KVITT_BIND>/health`, used by the compose healthcheck (no curl in the image). | 0 if 200, else 1 | | `healthcheck` | `GET http://127.0.0.1:<port of KVITT_BIND>/health`, used by the compose healthcheck (no curl in the image). | 0 if 200, else 1 |
| `bootstrap` | Works (M3): cancels a pending `admin_setup` invite, prints a new code, link and expiry; `already initialized` on stderr when users exist. | 0 ok, 2 already initialized | | `bootstrap` | Cancels a pending `admin_setup` invite, prints a new code, link and expiry; `already initialized` on stderr when users exist. | 0 ok, 2 already initialized |
| `pair-device --user <user_id>` | Works (M3): prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user | | `pair-device --user <user_id>` | Prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user |
| `invite-partner` | Works (D-113): what E-12 does for the admin on the server host: cancels a pending `partner` invite, prints a new code, a link naming the admin (`?from=`, D-087) and the expiry; `household full` or `no admin, run bootstrap first` on stderr otherwise. | 0 ok, 2 household full, 3 no admin | | `invite-partner` | What E-12 does for the admin on the server host: cancels a pending `partner` invite, prints a new code, a link naming the admin (`?from=`, D-087) and the expiry; `household full` or `no admin, run bootstrap first` on stderr otherwise. | 0 ok, 2 household full, 3 no admin |
| `rotate-epoch` | Works (M4): sets `meta.sync_epoch` to a new random value and prints it (SPEC §10.9 restore step 5). | 0 ok, 1 database error | | `rotate-epoch` | Sets `meta.sync_epoch` to a new random value and prints it (SPEC §10.9 restore step 5). | 0 ok, 1 database error |
## HTTP API ## HTTP API
Every route of SPEC §7 is registered in `src/router.rs`. `GET /health` (E-1) and `GET /setup/status` (E-2) are Every route of SPEC §7 is registered in `src/router.rs`, with its handler in `src/api/` (E-31 and E-32 in
implemented by M2a, E-3 to E-30 and E-33 by M3, E-31 and E-32 by M4 (`src/api/sync.rs`, protocol in `src/sync/`). `src/api/sync.rs`, the protocol in `src/sync/`).
`GET /i/` with an empty code answers the landing page's HTML 404. The rules of SPEC §14.3.1 are layers in `router.rs`: `GET /i/` with an empty code answers the landing page's HTML 404. The rules of SPEC §14.3.1 are layers in `router.rs`:
`X-Kvitt-Protocol: 1` on authenticated routes (426 otherwise, checked before the token), `X-Kvitt-Server-Version` `X-Kvitt-Protocol: 1` on authenticated routes (426 otherwise, checked before the token), `X-Kvitt-Server-Version`
on every response, body limits 1 MiB / 5 MiB for `PUT /me/picture`, JSON 404 for unknown paths. on every response, body limits 1 MiB / 5 MiB for `PUT /me/picture`, JSON 404 for unknown paths.
@@ -72,6 +72,9 @@ Building blocks for handler authors (`src/api/mod.rs`, `src/auth.rs`, `src/error
* `AppError` with every code of SPEC §14.3.2 and its status; `?` on `sqlx::Error` gives `unavailable` for a busy * `AppError` with every code of SPEC §14.3.2 and its status; `?` on `sqlx::Error` gives `unavailable` for a busy
database and `internal` (logged with a request id) otherwise. database and `internal` (logged with a request id) otherwise.
* `db::begin_immediate` for every write transaction and `db::next_server_version` inside it (SPEC §14.1). * `db::begin_immediate` for every write transaction and `db::next_server_version` inside it (SPEC §14.1).
* `api::rows`: every stored row read into its wire form, and the expense and settlement inserts shared by sync push,
recurring generation and the importer (`rows::Origin` carries the server-owned columns); `api::validate` for the
input rules of SPEC §9.6.
* `AppState.clock` (`time::Clock`; tests use `FixedClock`), `time::format_ts` for the fixed RFC 3339 form, * `AppState.clock` (`time::Clock`; tests use `FixedClock`), `time::format_ts` for the fixed RFC 3339 form,
`time::STOCKHOLM`. `time::STOCKHOLM`.
@@ -79,19 +82,19 @@ The OpenAPI document is generated with utoipa from the `#[utoipa::path]` annotat
`src/wire/`; `src/openapi.rs` lists the handlers and applies the document-wide rules. A handler change that alters `src/wire/`; `src/openapi.rs` lists the handlers and applies the document-wide rules. A handler change that alters
the wire contract makes `tests/openapi_contract.rs` fail against `contracts/openapi.yaml`. the wire contract makes `tests/openapi_contract.rs` fail against `contracts/openapi.yaml`.
## Seams for M3, M4 and M9 (SPEC §6.3, D-015) ## Seams (SPEC §6.3, D-015)
* **Rate limit (M3):** `src/api/rate_limit.rs` returns the layer that `router.rs` attaches on the line marked * **Rate limit:** `src/api/rate_limit.rs` returns the layer that `router.rs` attaches (SPEC §8.6, AC-2.12; per-IP
`// M3: the rate limiter …` (SPEC §8.6, AC-2.12; per-IP limits need `ConnectInfo<SocketAddr>` from `serve`). limits need `ConnectInfo<SocketAddr>` from `serve`).
* **Recurring generation (M3, for M4's scheduler):** `api::recurring::generate_due_periods(&AppState, template_id)` * **Recurring generation:** `api::recurring::generate_due_periods(&AppState, template_id)`
generates the due periods of one template (SPEC §9.5); `api::recurring::generate_all_due(&AppState)` runs it for generates the due periods of one template (SPEC §9.5); `api::recurring::generate_all_due(&AppState)` runs it for
every non-deleted, non-paused template. every non-deleted, non-paused template.
* **Background tasks (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result<AppState, AppError>`, * **Background tasks:** `src/tasks/mod.rs` has `pub fn start(state: AppState) -> AppState`,
called once by `serve` after migrations; it installs the push queue in the returned state (the router is built from called once by `serve` after migrations; it installs the push queue in the returned state (the router is built from
it) and spawns the FCM sender, the recurring scheduler (at start and daily 06:00 Europe/Stockholm, through it) and spawns the FCM sender, the recurring scheduler (at start and daily 06:00 Europe/Stockholm, through
`generate_all_due`) and the `sync_mutations` pruning (daily 04:00). The scheduler loop (`tasks/scheduler.rs`) reads `generate_all_due`) and the `sync_mutations` pruning (daily 04:00). The scheduler loop (`tasks/scheduler.rs`) reads
the injectable `AppState.clock` and re-checks it at least every 30 s, so clock jumps are handled. the injectable `AppState.clock` and re-checks it at least every 30 s, so clock jumps are handled.
* **Audit (`src/audit/mod.rs`, M4 implements, M3 and M9 call):** * **Audit (`src/audit/mod.rs`):**
```rust ```rust
pub trait AuditWriter: Send + Sync + 'static { pub trait AuditWriter: Send + Sync + 'static {
@@ -103,17 +106,17 @@ the wire contract makes `tests/openapi_contract.rs` fail against `contracts/open
`AuditEvent` carries entity type and id, action, source, actor user and device, `client_at`, `server_at`, and `AuditEvent` carries entity type and id, action, source, actor user and device, `client_at`, `server_at`, and
the wire object before (`None` for create) and after the change as JSON; the writer derives `changes` per SPEC the wire object before (`None` for create) and after the change as JSON; the writer derives `changes` per SPEC
§14.4.7 and inserts the row with a `server_version` from the caller's `BEGIN IMMEDIATE` transaction (`conn`). §14.4.7 and inserts the row with a `server_version` from the caller's `BEGIN IMMEDIATE` transaction (`conn`).
`AppState.audit` holds `SqlAuditWriter` (M4; the diff is `audit::diff::changes`). `AppState.audit` holds `SqlAuditWriter` (the diff is `audit::diff::changes`).
* **Push (`src/push/mod.rs`, M4 implements, M3 calls):** `PushQueue::enqueue(&self, message: PushMessage)` with * **Push (`src/push/mod.rs`):** `PushQueue::enqueue(&self, message: PushMessage)` with
`PushMessage { push_type: PushType, entity_id: Option<Uuid>, actor_user_id: Uuid, count: u32 }` and the eight `PushMessage { push_type: PushType, entity_id: Option<Uuid>, actor_user_id: Uuid, count: u32 }` and the eight
types of SPEC §14.6. It never blocks or fails; call it after commit. `AppState::new` holds `NoopPushQueue` (push types of SPEC §14.6. It never blocks or fails; call it after commit. `AppState::new` holds `NoopPushQueue` (push
disabled); `tasks::start` replaces it with `PushHub` (bounded queue of 100) drained by `push::Sender` (FCM HTTP v1, disabled); `tasks::start` replaces it with `PushHub` (bounded queue of 100) drained by `push::Sender` (FCM HTTP v1,
OAuth2 service-account JWT, retries 1 s / 5 s / 25 s) when `KVITT_FCM_CREDENTIALS` exists. OAuth2 service-account JWT, retries 1 s / 5 s / 25 s) when `KVITT_FCM_CREDENTIALS` exists.
* **Recurring dates (`kvitt-domain`, for M3's generation function and M4's scheduler):** * **Recurring dates (`kvitt-domain`, for the generation function and the scheduler):**
`Period` (`YYYY-MM`, `FromStr`/`Display`, `next()`, `days()`, `clamped_date(day_of_month)`), `Period` (`YYYY-MM`, `FromStr`/`Display`, `next()`, `days()`, `clamped_date(day_of_month)`),
`next_candidate_period(start_period, end_period, last_generated_period) -> Result<Option<Period>, RecurrenceError>` `next_candidate_period(start_period, end_period, last_generated_period) -> Result<Option<Period>, RecurrenceError>`
and `next_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, RecurrenceError>`. and `next_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, RecurrenceError>`.
* **Importer (M9):** `kvitt-import` depends on `kvitt-domain` and `kvitt-server` (the library); it reuses the server's validation, row helpers and `SqlAuditWriter`, and never opens a database it would have to create or migrate (SPEC §10.8). * **Importer:** `kvitt-import` depends on `kvitt-domain` and `kvitt-server` (the library); it reuses the server's validation, row helpers and `SqlAuditWriter`, and never opens a database it would have to create or migrate (SPEC §10.8).
## Tests and the gate ## Tests and the gate
@@ -127,20 +130,22 @@ From `server/`: `cargo fmt --check && cargo clippy --all-targets -- -D warnings
protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in
`contracts/examples/`. `contracts/examples/`.
* `crates/kvitt-server/tests/core_api/` (M3): E-2 to E-30, E-33, generation, rate limiting, pictures, CLI. * `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33, generation, rate limiting, pictures, CLI.
* `crates/kvitt-server/tests/sync_api/` (M4): every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons, * `crates/kvitt-server/tests/sync_api/`: every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons,
§9.10 conflicts, 14-day offline scenario, the push examples), audit writer, `rotate-epoch`, end to end push → pull, §9.10 conflicts, 14-day offline scenario, the push examples), audit writer, `rotate-epoch`, end to end push → pull,
`ConnectInfo` rate-limit buckets over real TCP. `ConnectInfo` rate-limit buckets over real TCP.
* `crates/kvitt-server/tests/push_fcm/` (M4): payloads against `contracts/examples/push/`, recipients, OAuth2 flow * `crates/kvitt-server/tests/push_fcm/`: payloads against `contracts/examples/push/`, recipients, OAuth2 flow
and token cache, retries, token clearing, queue overflow, push disabled, against a local stub of Google's endpoints and token cache, retries, token clearing, queue overflow, push disabled, against a local stub of Google's endpoints
(an RSA key is generated per test; none is committed). (an RSA key is generated per test; none is committed).
* `crates/kvitt-server/tests/scheduler_daily/` (M4): 06:00 and 04:00 across both DST changes, run at start, clock * `crates/kvitt-server/tests/scheduler_daily/`: 06:00 and 04:00 across both DST changes, run at start, clock
jumps, catch-up through `generate_all_due`, paused and deleted templates skipped, 90-day pruning. jumps, catch-up through `generate_all_due`, paused and deleted templates skipped, 90-day pruning.
* `crates/kvitt-import/tests/import.rs` (M9): both header variants, split and settlement rules, `--fx`, the import key and * `crates/kvitt-import/tests/import.rs`: both header variants, split and settlement rules, `--fx`, the import key and
idempotent re-runs, reconciliation and its exit codes, dry run, the CLI, and the §10.8 category table against idempotent re-runs, reconciliation and its exit codes, dry run, the CLI, and the §10.8 category table against
`data/splitwise_categories.csv`; synthetic fixtures in `crates/kvitt-import/tests/fixtures/` only. `data/splitwise_categories.csv`; synthetic fixtures in `crates/kvitt-import/tests/fixtures/` only.
The M4 binaries reuse M3's harness (`tests/core_api/common.rs`) and `tests/sync_api/m4.rs` through `#[path]`. No `unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy). Every `kvitt-server` test binary includes the shared harness `tests/common/mod.rs` through `#[path]` (not a test
target itself); the sync, push and scheduler binaries also include `tests/common/sync_fixtures.rs`. No
`unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy).
## Docker image ## Docker image
@@ -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 //! `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. //! 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') 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)] #[derive(Default)]
pub struct RecordingAudit(pub Mutex<Vec<AuditEvent>>); 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 { impl Drop for TempDir {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0); 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( pub async fn insert_expense(
&self, &self,
account: &str, 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 //! Sync additions to the harness of `common`: an app with the real audit writer, entity and mutation builders, push
//! helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries. //! and pull helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries.
use std::sync::Arc; 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 //! 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,
//! recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page //! 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). //! (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 accounts;
mod apk; mod apk;
mod categories; mod categories;
mod cli; mod cli;
mod common;
mod devices; mod devices;
mod generation; mod generation;
mod invites; mod invites;
@@ -16,8 +16,8 @@ use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
use crate::common::*; use crate::common::*;
use crate::m4;
use crate::stub::*; use crate::stub::*;
use crate::sync_fixtures;
const EXPENSE: &str = "01929c5e-1111-7aaa-8bbb-000000000001"; const EXPENSE: &str = "01929c5e-1111-7aaa-8bbb-000000000001";
const FAST: [u64; 3] = [10, 20, 40]; const FAST: [u64; 3] = [10, 20, 40];
@@ -31,7 +31,7 @@ struct Setup {
impl Setup { impl Setup {
async fn new() -> Self { async fn new() -> Self {
let app = m4::app().await; let app = sync_fixtures::app().await;
for (device, tok) in [ for (device, tok) in [
(ADMIN_DEVICE_ID, "tok-admin"), (ADMIN_DEVICE_ID, "tok-admin"),
(MEMBER_DEVICE_ID, "tok-member"), (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)], delays: vec![Duration::from_millis(10)],
}, },
); );
m4::with_push(&mut s.app, queue); sync_fixtures::with_push(&mut s.app, queue);
let id = m4::new_id(); let id = sync_fixtures::new_id();
m4::push_one( sync_fixtures::push_one(
&s.app, &s.app,
&member_token(), &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; .await;
let deadline = Instant::now() + Duration::from_secs(5); 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, //! 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. //! 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; pub mod common;
#[path = "../sync_api/m4.rs"]
pub mod m4;
pub mod stub; pub mod stub;
#[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod fcm; 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 //! 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; pub mod common;
#[path = "../sync_api/m4.rs"] #[path = "../common/sync_fixtures.rs"]
pub mod m4; pub mod sync_fixtures;
mod scheduler; 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 tokio::sync::mpsc;
use crate::common::*; use crate::common::*;
use crate::m4; use crate::sync_fixtures;
fn ts(text: &str) -> chrono::DateTime<chrono::Utc> { fn ts(text: &str) -> chrono::DateTime<chrono::Utc> {
parse_ts(text).unwrap() parse_ts(text).unwrap()
@@ -175,10 +175,10 @@ async fn periods(app: &TestApp, template: &str) -> Vec<String> {
#[tokio::test] #[tokio::test]
async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_deleted() { async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_deleted() {
let mut app = m4::app().await; let mut app = sync_fixtures::app().await;
let active = m4::new_id(); let active = sync_fixtures::new_id();
let paused = m4::new_id(); let paused = sync_fixtures::new_id();
let deleted = m4::new_id(); let deleted = sync_fixtures::new_id();
// Last generated July; the server was down since. Now is 17 October 14:00. // 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, &active, 15, "2026-06", Some("2026-07"), false, false).await;
insert_template(&app, &paused, 1, "2026-06", None, true, 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] #[tokio::test]
async fn the_06_00_tick_generates_the_period_due_that_morning() { async fn the_06_00_tick_generates_the_period_due_that_morning() {
let app = m4::app().await; let app = sync_fixtures::app().await;
let id = m4::new_id(); let id = sync_fixtures::new_id();
insert_template(&app, &id, 15, "2026-11", None, false, false).await; insert_template(&app, &id, 15, "2026-11", None, false, false).await;
// 15 November 2026, 05:59 CET: not yet. // 15 November 2026, 05:59 CET: not yet.
app.set_now("2026-11-15T04:59:00.000Z"); 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 { async fn insert_mutation(app: &TestApp, received_at: &str) -> String {
let id = m4::new_id(); let id = sync_fixtures::new_id();
sqlx::query( sqlx::query(
"INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \ "INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \
VALUES (?, ?, 'expense', ?, '{}', ?)", VALUES (?, ?, 'expense', ?, '{}', ?)",
) )
.bind(&id) .bind(&id)
.bind(ADMIN_DEVICE_ID) .bind(ADMIN_DEVICE_ID)
.bind(m4::new_id()) .bind(sync_fixtures::new_id())
.bind(received_at) .bind(received_at)
.execute(&app.state.db) .execute(&app.state.db)
.await .await
@@ -256,7 +256,7 @@ async fn insert_mutation(app: &TestApp, received_at: &str) -> String {
#[tokio::test] #[tokio::test]
async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() { 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); let now = ts(NOW);
insert_mutation( insert_mutation(
&app, &app,
@@ -277,7 +277,7 @@ async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() {
#[tokio::test] #[tokio::test]
async fn pruning_runs_at_04_00_local() { 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. // 03:59 CEST on 18 October.
app.set_now("2026-10-18T01:59:00.000Z"); app.set_now("2026-10-18T01:59:00.000Z");
insert_mutation(&app, "2026-07-01T00:00:00.000Z").await; insert_mutation(&app, "2026-07-01T00:00:00.000Z").await;
@@ -290,11 +290,13 @@ async fn pruning_runs_at_04_00_local() {
.await; .await;
}); });
tokio::time::sleep(Duration::from_millis(30)).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"); app.set_now("2026-10-18T02:00:00.000Z");
let deadline = std::time::Instant::now() + Duration::from_secs(5); 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; 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 //! Base server tests: migration identity and pragmas, routes, authentication and the protocol rule, error shapes,
//! protocol rule, error shapes, and the wire types against `contracts/examples/`. //! and the wire types against `contracts/examples/`.
#[path = "../common/mod.rs"]
pub mod common;
mod errors; mod errors;
mod migration; mod migration;
@@ -80,7 +80,7 @@ async fn every_route_is_registered_and_implemented() {
33, 33,
"SPEC §7 has 33 operations" "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 { for (method, path, _) in routes {
let reply = app let reply = app
.send(authed(method.clone(), &path, &admin_token())) .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 //! Helpers of the base tests: a database migrated to a chosen schema version, seeded users and devices (the
//! clock and a one-shot request helper. //! 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 std::sync::Arc;
use axum::body::Body; use axum::body::Body;
@@ -12,45 +12,14 @@ use kvitt_server::time::{FixedClock, parse_ts};
use kvitt_server::{AppState, auth, db, router}; use kvitt_server::{AppState, auth, db, router};
use serde_json::Value; use serde_json::Value;
use tower::ServiceExt; use tower::ServiceExt;
use uuid::Uuid;
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001"; pub use crate::common::{
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002"; ADMIN_DEVICE_ID, ADMIN_ID, MEMBER_DEVICE_ID, MEMBER_ID, NOW, TempDir, admin_token,
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001"; member_token, old_device_token as revoked_token, token,
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 fn token(tag: char) -> String { /// The member's revoked device (the id of the shared harness's old admin device).
format!("kvt_{}", tag.to_string().repeat(43)) pub const REVOKED_DEVICE_ID: &str = crate::common::ADMIN_OLD_DEVICE_ID;
}
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);
}
}
pub struct TestApp { pub struct TestApp {
/// Kept alive so the directory is removed when the app is dropped. /// 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); round_trip::<ErrorBody>(&name, &example);
checked += 1; checked += 1;
} else if dir_name == "push" { } 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; continue;
} else if check(&name, &example) { } else if check(&name, &example) {
checked += 1; 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. //! times and append-only entries.
use serde_json::json; use serde_json::json;
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
#[tokio::test] #[tokio::test]
async fn generation_writes_recurring_audit_with_server_now_and_no_device() { async fn generation_writes_recurring_audit_with_server_now_and_no_device() {
@@ -3,7 +3,7 @@
use serde_json::json; use serde_json::json;
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
const T0: &str = "2026-10-17T10:00:00.000Z"; const T0: &str = "2026-10-17T10:00:00.000Z";
@@ -24,7 +24,7 @@ fn delete(id: &str, at: &str) -> serde_json::Value {
#[tokio::test] #[tokio::test]
async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_order() { async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_order() {
for later_arrives_first in [true, false] { for later_arrives_first in [true, false] {
let app = crate::m4::app().await; let app = crate::sync_fixtures::app().await;
let id = new_id(); let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await; push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
let anna = exp(edit(&id, "2026-10-17T11:00:00.000Z", "Anna's")); 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] #[tokio::test]
async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timestamps() { async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timestamps() {
// Delete first, then a newer edit: rejected deleted. // 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(); let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await; push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
accepted( accepted(
@@ -69,7 +69,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
.await; .await;
rejected(&r, "deleted"); rejected(&r, "deleted");
// Edit first, then an older delete: applied. // Edit first, then an older delete: applied.
let app = crate::m4::app().await; let app = crate::sync_fixtures::app().await;
let id = new_id(); let id = new_id();
push_one(&app, &admin_token(), exp(expense(&id, T0))).await; push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
accepted( accepted(
@@ -94,7 +94,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
#[tokio::test] #[tokio::test]
async fn same_user_on_two_devices_is_lww_per_row() { 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. // A second, non-revoked device of the admin.
let second = token('S'); let second = token('S');
app.insert_device( app.insert_device(
@@ -129,7 +129,7 @@ async fn same_user_on_two_devices_is_lww_per_row() {
#[tokio::test] #[tokio::test]
async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archived_category_accepted() 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; tombstone(&app, "accounts", ACCOUNT_1).await;
rejected( rejected(
&push_one(&app, &admin_token(), exp(expense(&new_id(), T0))).await, &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() { 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 // §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. // 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 kept = new_id();
let entity = expense(&kept, T0); let entity = expense(&kept, T0);
push_one(&app, &admin_token(), exp(entity.clone())).await; push_one(&app, &admin_token(), exp(entity.clone())).await;
@@ -6,7 +6,7 @@ use std::sync::Arc;
use kvitt_server::push::{PushHub, PushType}; use kvitt_server::push::{PushHub, PushType};
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
#[tokio::test] #[tokio::test]
async fn create_on_device_a_pull_on_device_b_row_audit_and_one_push() { 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 //! 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, //! (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 common;
pub mod m4; #[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod audit; mod audit;
mod conflicts; mod conflicts;
@@ -16,9 +17,3 @@ mod offline;
mod pull; mod pull;
mod push_rules; mod push_rules;
mod serve; 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 serde_json::{Value, json};
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
#[tokio::test] #[tokio::test]
async fn fourteen_days_offline_200_changes_all_sync() { async fn fourteen_days_offline_200_changes_all_sync() {
@@ -4,7 +4,7 @@ use axum::http::Method;
use serde_json::Value; use serde_json::Value;
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
const T1: &str = "2026-10-17T11:00:00.000Z"; const T1: &str = "2026-10-17T11:00:00.000Z";
const ARRAYS: [&str; 7] = [ const ARRAYS: [&str; 7] = [
@@ -5,7 +5,7 @@ use axum::http::Method;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::common::*; use crate::common::*;
use crate::m4::*; use crate::sync_fixtures::*;
/// A change applied to a pushed entity. /// A change applied to a pushed entity.
type Mutator = Box<dyn Fn(&mut Value)>; type Mutator = Box<dyn Fn(&mut Value)>;