M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//! 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 {
|
||||
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();
|
||||
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;
|
||||
let pool = &app.state.db;
|
||||
for (id, role, name, version) in [
|
||||
(ADMIN_ID, "admin", "Jacob", 11),
|
||||
(MEMBER_ID, "member", "Partner", 12),
|
||||
] {
|
||||
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();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user