diff --git a/server/README.md b/server/README.md index 76b4a49..e18fc92 100644 --- a/server/README.md +++ b/server/README.md @@ -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-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 version. @@ -46,20 +46,20 @@ database is then `$KVITT_DATA_DIR/kvitt.sqlite`. Every connection uses WAL, `for `kvitt-server ` (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 | -| `migrate` | Works: migrations only. | 0 | -| `healthcheck` | Works: `GET http://127.0.0.1:/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 | -| `pair-device --user ` | Works (M3): 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 | -| `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 | +| `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` | Migrations only. | 0 | +| `healthcheck` | `GET http://127.0.0.1:/health`, used by the compose healthcheck (no curl in the image). | 0 if 200, else 1 | +| `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 ` | Prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user | +| `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` | 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 -Every route of SPEC §7 is registered in `src/router.rs`. `GET /health` (E-1) and `GET /setup/status` (E-2) are -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/`). +Every route of SPEC §7 is registered in `src/router.rs`, with its handler in `src/api/` (E-31 and E-32 in +`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`: `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. @@ -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 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). +* `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, `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 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 - `// M3: the rate limiter …` (SPEC §8.6, AC-2.12; per-IP limits need `ConnectInfo` from `serve`). -* **Recurring generation (M3, for M4's scheduler):** `api::recurring::generate_due_periods(&AppState, template_id)` +* **Rate limit:** `src/api/rate_limit.rs` returns the layer that `router.rs` attaches (SPEC §8.6, AC-2.12; per-IP + limits need `ConnectInfo` from `serve`). +* **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 every non-deleted, non-paused template. -* **Background tasks (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result`, +* **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 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 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 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 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`). - `AppState.audit` holds `SqlAuditWriter` (M4; the diff is `audit::diff::changes`). -* **Push (`src/push/mod.rs`, M4 implements, M3 calls):** `PushQueue::enqueue(&self, message: PushMessage)` with + `AppState.audit` holds `SqlAuditWriter` (the diff is `audit::diff::changes`). +* **Push (`src/push/mod.rs`):** `PushQueue::enqueue(&self, message: PushMessage)` with `PushMessage { push_type: PushType, entity_id: Option, 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 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. -* **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)`), `next_candidate_period(start_period, end_period, last_generated_period) -> Result, RecurrenceError>` and `next_occurrence_date(&RecurrenceSchedule) -> Result, 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 @@ -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 `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/sync_api/` (M4): every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons, +* `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33, generation, rate limiting, pictures, CLI. +* `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, `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 (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. -* `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 `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 diff --git a/server/crates/kvitt-server/tests/core_api/common.rs b/server/crates/kvitt-server/tests/common/mod.rs similarity index 97% rename from server/crates/kvitt-server/tests/core_api/common.rs rename to server/crates/kvitt-server/tests/common/mod.rs index 2155795..cccf0f6 100644 --- a/server/crates/kvitt-server/tests/core_api/common.rs +++ b/server/crates/kvitt-server/tests/common/mod.rs @@ -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>); @@ -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, diff --git a/server/crates/kvitt-server/tests/sync_api/m4.rs b/server/crates/kvitt-server/tests/common/sync_fixtures.rs similarity index 96% rename from server/crates/kvitt-server/tests/sync_api/m4.rs rename to server/crates/kvitt-server/tests/common/sync_fixtures.rs index 3055c1c..47987df 100644 --- a/server/crates/kvitt-server/tests/sync_api/m4.rs +++ b/server/crates/kvitt-server/tests/common/sync_fixtures.rs @@ -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; diff --git a/server/crates/kvitt-server/tests/core_api/main.rs b/server/crates/kvitt-server/tests/core_api/main.rs index ba67d04..3a47c83 100644 --- a/server/crates/kvitt-server/tests/core_api/main.rs +++ b/server/crates/kvitt-server/tests/core_api/main.rs @@ -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; diff --git a/server/crates/kvitt-server/tests/push_fcm/fcm.rs b/server/crates/kvitt-server/tests/push_fcm/fcm.rs index 20a2db9..8a0631a 100644 --- a/server/crates/kvitt-server/tests/push_fcm/fcm.rs +++ b/server/crates/kvitt-server/tests/push_fcm/fcm.rs @@ -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); diff --git a/server/crates/kvitt-server/tests/push_fcm/main.rs b/server/crates/kvitt-server/tests/push_fcm/main.rs index 82d80ff..8861e8e 100644 --- a/server/crates/kvitt-server/tests/push_fcm/main.rs +++ b/server/crates/kvitt-server/tests/push_fcm/main.rs @@ -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() - } -} diff --git a/server/crates/kvitt-server/tests/scheduler_daily/main.rs b/server/crates/kvitt-server/tests/scheduler_daily/main.rs index 87e0f06..f9395fb 100644 --- a/server/crates/kvitt-server/tests/scheduler_daily/main.rs +++ b/server/crates/kvitt-server/tests/scheduler_daily/main.rs @@ -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() - } -} diff --git a/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs b/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs index 2a0a520..1104934 100644 --- a/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs +++ b/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs @@ -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 { parse_ts(text).unwrap() @@ -175,10 +175,10 @@ async fn periods(app: &TestApp, template: &str) -> Vec { #[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); } diff --git a/server/crates/kvitt-server/tests/skeleton/main.rs b/server/crates/kvitt-server/tests/skeleton/main.rs index e074347..fb241f0 100644 --- a/server/crates/kvitt-server/tests/skeleton/main.rs +++ b/server/crates/kvitt-server/tests/skeleton/main.rs @@ -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; diff --git a/server/crates/kvitt-server/tests/skeleton/routes.rs b/server/crates/kvitt-server/tests/skeleton/routes.rs index 73044ea..07348cc 100644 --- a/server/crates/kvitt-server/tests/skeleton/routes.rs +++ b/server/crates/kvitt-server/tests/skeleton/routes.rs @@ -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())) diff --git a/server/crates/kvitt-server/tests/skeleton/support.rs b/server/crates/kvitt-server/tests/skeleton/support.rs index 9b6d2a7..6e4b43a 100644 --- a/server/crates/kvitt-server/tests/skeleton/support.rs +++ b/server/crates/kvitt-server/tests/skeleton/support.rs @@ -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. diff --git a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs index 81ba13c..69b70f9 100644 --- a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs +++ b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs @@ -103,7 +103,7 @@ fn every_example_round_trips() { round_trip::(&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; diff --git a/server/crates/kvitt-server/tests/sync_api/audit.rs b/server/crates/kvitt-server/tests/sync_api/audit.rs index 9d06f0b..7585b91 100644 --- a/server/crates/kvitt-server/tests/sync_api/audit.rs +++ b/server/crates/kvitt-server/tests/sync_api/audit.rs @@ -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() { diff --git a/server/crates/kvitt-server/tests/sync_api/conflicts.rs b/server/crates/kvitt-server/tests/sync_api/conflicts.rs index 51f4ffa..88ee17e 100644 --- a/server/crates/kvitt-server/tests/sync_api/conflicts.rs +++ b/server/crates/kvitt-server/tests/sync_api/conflicts.rs @@ -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; diff --git a/server/crates/kvitt-server/tests/sync_api/e2e.rs b/server/crates/kvitt-server/tests/sync_api/e2e.rs index 1488277..01c028a 100644 --- a/server/crates/kvitt-server/tests/sync_api/e2e.rs +++ b/server/crates/kvitt-server/tests/sync_api/e2e.rs @@ -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() { diff --git a/server/crates/kvitt-server/tests/sync_api/main.rs b/server/crates/kvitt-server/tests/sync_api/main.rs index 4d39a6e..977b38b 100644 --- a/server/crates/kvitt-server/tests/sync_api/main.rs +++ b/server/crates/kvitt-server/tests/sync_api/main.rs @@ -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() - } -} diff --git a/server/crates/kvitt-server/tests/sync_api/offline.rs b/server/crates/kvitt-server/tests/sync_api/offline.rs index 0c85082..8bcdbe7 100644 --- a/server/crates/kvitt-server/tests/sync_api/offline.rs +++ b/server/crates/kvitt-server/tests/sync_api/offline.rs @@ -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() { diff --git a/server/crates/kvitt-server/tests/sync_api/pull.rs b/server/crates/kvitt-server/tests/sync_api/pull.rs index 0d67188..b7960d1 100644 --- a/server/crates/kvitt-server/tests/sync_api/pull.rs +++ b/server/crates/kvitt-server/tests/sync_api/pull.rs @@ -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] = [ diff --git a/server/crates/kvitt-server/tests/sync_api/push_rules.rs b/server/crates/kvitt-server/tests/sync_api/push_rules.rs index 03d640e..f06660a 100644 --- a/server/crates/kvitt-server/tests/sync_api/push_rules.rs +++ b/server/crates/kvitt-server/tests/sync_api/push_rules.rs @@ -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;