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-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 <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 |
| `migrate` | Works: 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 |
| `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 <user_id>` | 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:<port of KVITT_BIND>/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 <user_id>` | 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<SocketAddr>` 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<SocketAddr>` 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<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
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<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
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<Option<Period>, 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
@@ -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