//! 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::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; 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, }; /// 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. 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 } /// A database migrated to schema 2 only (before 0003). pub async fn at_v2() -> Self { Self::migrated_to(Some(2)).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")) }