//! 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) -> 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) -> 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 { 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 { request(method, path, Some(token), Some("1")) }