Files
kvitt/server/crates/kvitt-server/tests/skeleton/support.rs
T
jacobh2 6eaccec7ac M12b: category contract v2 on the server (schema 2, fourteen defaults, finer import mapping)
- migrations/0002_categories_v2.sql, byte-identical to contracts/schema/migrations/0002_categories_v2.sql
  (SPEC §14.1.1), with an identity test; SCHEMA_VERSION = 2 (E-1 reports it, the importer's check follows).
- Seed test on a fresh database (the fourteen rows of AC-7.2, server_version 11 to 14) and an upgrade test of a
  v1 database in use (0001 applied through the migrator, then users, a custom, a renamed and an archived default,
  an account, a recurring template, expenses): every earlier row unchanged, the four rows above the old
  sequence, foreign_key_check empty, schema_version 2, a pull from the old cursor returns exactly the four rows.
- DefaultCategoryKey with the fourteen keys (wire enum and contracts/openapi.yaml); icons 33 and colours 14
  (§9.7) in validation.
- The two category examples M12a held back (E-26 response-default, sync/pull-page-categories-v2) and its
  contracts-check patch for them and the enum.
- Importer: splitwise_categories.csv per the §10.8 v0.8 table (D-053), DEFAULT_KEYS of fourteen, synthetic
  fixtures for the finer mapping (Insurance/Försäkringar to insurance among them), a schema-1 refusal test.
- Test harnesses seed users at server_version 15/16 (after the fourteen seeds); pull counts 10 -> 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 08:25:22 +02:00

196 lines
6.0 KiB
Rust

//! Shared helpers of the skeleton tests: a migrated temporary database, seeded users and devices, a fixed
//! clock and a one-shot request helper.
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use kvitt_server::config::Config;
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 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);
}
}
pub struct TestApp {
/// Kept alive so the directory is removed when the app is dropped.
pub _dir: TempDir,
pub state: AppState,
pub clock: FixedClock,
}
impl TestApp {
/// A migrated, empty database (no users).
pub async fn empty() -> Self {
Self::migrated_to(None).await
}
/// An empty database at schema version 1: only `0001_init.sql` applied, through the server's own migrator, so a
/// later `db::migrate` upgrades it the way a deployed v1 database is upgraded.
pub async fn at_v1() -> Self {
Self::migrated_to(Some(1)).await
}
async fn migrated_to(target: Option<i64>) -> Self {
let dir = TempDir::new();
let config = Config::from_lookup(|k| {
(k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned())
})
.unwrap();
let pool = db::connect(&config.database_path()).await.unwrap();
match target {
Some(version) => db::MIGRATOR.run_to(version, &pool).await.unwrap(),
None => db::migrate(&pool).await.unwrap(),
}
let clock = FixedClock::new(parse_ts(NOW).unwrap());
let state = AppState::new(config, pool).with_clock(Arc::new(clock.clone()));
Self {
_dir: dir,
state,
clock,
}
}
/// Admin and member users, one device each, plus a revoked device of the member.
pub async fn household() -> Self {
let app = Self::empty().await;
app.seed_household().await;
app
}
/// Inserts the users and devices of [`TestApp::household`] (users at `server_version` 15 and 16, after the
/// fourteen seeded categories; the sequence is not advanced).
pub async fn seed_household(&self) {
let pool = &self.state.db;
for (id, role, name, version) in [
(ADMIN_ID, "admin", "Jacob", 15),
(MEMBER_ID, "member", "Partner", 16),
] {
sqlx::query(
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
VALUES (?, ?, ?, NULL, ?, ?, ?)",
)
.bind(id)
.bind(role)
.bind(name)
.bind("2026-10-01T00:00:00.000Z")
.bind("2026-10-01T00:00:00.000Z")
.bind(version)
.execute(pool)
.await
.unwrap();
}
for (id, user, tok, revoked) in [
(ADMIN_DEVICE_ID, ADMIN_ID, admin_token(), None),
(MEMBER_DEVICE_ID, MEMBER_ID, member_token(), None),
(
REVOKED_DEVICE_ID,
MEMBER_ID,
revoked_token(),
Some("2026-10-10T00:00:00.000Z"),
),
] {
sqlx::query(
"INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at, revoked_at) \
VALUES (?, ?, 'Phone', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z', ?)",
)
.bind(id)
.bind(user)
.bind(auth::sha256_hex(&tok))
.bind(revoked)
.execute(pool)
.await
.unwrap();
}
}
pub async fn send(&self, request: Request<Body>) -> Reply {
let response = router::router(self.state.clone())
.oneshot(request)
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
Reply {
status,
headers,
json,
}
}
}
pub struct Reply {
pub status: StatusCode,
pub headers: axum::http::HeaderMap,
pub json: Value,
}
impl Reply {
pub fn code(&self) -> &str {
self.json["code"].as_str().unwrap_or("")
}
}
/// A request builder with the app's usual headers.
pub fn request(
method: Method,
path: &str,
token: Option<&str>,
protocol: Option<&str>,
) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
if let Some(protocol) = protocol {
builder = builder.header("x-kvitt-protocol", protocol);
}
builder.body(Body::empty()).unwrap()
}
pub fn authed(method: Method, path: &str, token: &str) -> Request<Body> {
request(method, path, Some(token), Some("1"))
}