8cb42f85a9
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
170 lines
5.5 KiB
Rust
170 lines
5.5 KiB
Rust
//! 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<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"))
|
|
}
|