Files
kvitt/server/crates/kvitt-server/tests/common/sync_fixtures.rs
T
jacobh2 8cb42f85a9 Server overhaul: one test harness in tests/common for every test binary
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
2026-10-18 22:43:54 +02:00

226 lines
7.1 KiB
Rust

//! Sync additions to the harness of `common`: an app with the real audit writer, entity and mutation builders, push
//! and pull helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries.
use std::sync::Arc;
use axum::http::Method;
use kvitt_server::audit::SqlAuditWriter;
use kvitt_server::push::PushQueue;
use kvitt_server::router;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::common::*;
/// The household of `TestApp::household` with accounts ACCOUNT_1 (50/50) and ACCOUNT_2 (50/50) and the real audit
/// writer; pushes go to the recording queue.
pub async fn app() -> TestApp {
let mut app = TestApp::household().await;
app.insert_account(ACCOUNT_1, "Vardag", "2026-10-01T00:00:00.000Z", 5000)
.await;
app.insert_account(ACCOUNT_2, "Resor", "2026-10-02T00:00:00.000Z", 5000)
.await;
app.state.audit = Arc::new(SqlAuditWriter);
app.router = router::router(app.state.clone());
app
}
/// Replaces the push queue (and rebuilds the router).
pub fn with_push(app: &mut TestApp, push: Arc<dyn PushQueue>) {
app.state.push = push;
app.router = router::router(app.state.clone());
}
pub fn new_id() -> String {
Uuid::now_v7().to_string()
}
/// A pushed expense: 412,00 kr ICA Maxi in ACCOUNT_1, groceries, paid by the admin, mode default 50/50.
pub fn expense(id: &str, updated_at: &str) -> Value {
json!({
"id": id, "account_id": ACCOUNT_1, "category_id": GROCERIES, "amount_ore": 41200,
"description": "ICA Maxi", "notes": "", "expense_date": "2026-10-16", "payer_user_id": ADMIN_ID,
"split_mode": "default",
"splits": [
{"user_id": ADMIN_ID, "share_ore": 20600, "share_bp": 5000},
{"user_id": MEMBER_ID, "share_ore": 20600, "share_bp": 5000}
],
"created_at": updated_at, "updated_at": updated_at, "deleted_at": null
})
}
/// A pushed settlement: the member pays the admin 500,00 kr, 300 to ACCOUNT_1 and 200 to ACCOUNT_2.
pub fn settlement(id: &str, updated_at: &str) -> Value {
json!({
"id": id, "from_user_id": MEMBER_ID, "to_user_id": ADMIN_ID, "amount_ore": 50000,
"settlement_date": "2026-10-16", "note": "",
"allocations": [
{"account_id": ACCOUNT_1, "amount_ore": 30000}, {"account_id": ACCOUNT_2, "amount_ore": 20000}
],
"created_at": updated_at, "updated_at": updated_at, "deleted_at": null
})
}
/// One mutation with a new mutation id.
pub fn mutation(entity_type: &str, entity: Value) -> Value {
json!({"mutation_id": new_id(), "entity_type": entity_type, "entity": entity})
}
pub fn exp(entity: Value) -> Value {
mutation("expense", entity)
}
pub fn set(entity: Value) -> Value {
mutation("settlement", entity)
}
/// `POST /sync` with these mutations.
pub async fn push(app: &TestApp, tok: &str, mutations: Vec<Value>) -> Reply {
app.call(
Method::POST,
"/sync",
tok,
Some(json!({ "mutations": mutations })),
)
.await
}
/// `POST /sync` with one mutation; asserts 200 and returns its result.
pub async fn push_one(app: &TestApp, tok: &str, mutation: Value) -> Value {
let reply = push(app, tok, vec![mutation]).await;
assert_eq!(reply.status, 200, "{}", reply.text());
reply.json["results"][0].clone()
}
/// `GET /sync`.
pub async fn pull(app: &TestApp, tok: &str, since: i64, limit: Option<i64>) -> Reply {
let path = match limit {
Some(l) => format!("/sync?since={since}&limit={l}"),
None => format!("/sync?since={since}"),
};
app.call(Method::GET, &path, tok, None).await
}
/// Asserts an accepted result for `entity_id` and returns its entity.
#[track_caller]
pub fn accepted(result: &Value) -> Value {
assert_eq!(result["status"], "accepted", "{result:#}");
assert_eq!(result["server_version"], result["entity"]["server_version"]);
result["entity"].clone()
}
/// Asserts a rejected result with `reason`.
#[track_caller]
pub fn rejected(result: &Value, reason: &str) {
assert_eq!(result["status"], "rejected", "{result:#}");
assert_eq!(result["reason"], reason, "{result:#}");
assert!(result["message"].is_string());
let keys: Vec<_> = result.as_object().unwrap().keys().cloned().collect();
assert_eq!(
keys,
[
"current",
"entity_id",
"entity_type",
"message",
"mutation_id",
"reason",
"status"
]
);
}
/// One audit row as stored.
#[derive(Debug, Clone, PartialEq)]
pub struct AuditRow {
pub action: String,
pub source: String,
pub actor_user_id: String,
pub actor_device_id: Option<String>,
pub client_at: String,
pub server_at: String,
pub changes: Value,
pub server_version: i64,
}
#[derive(sqlx::FromRow)]
struct RawAudit {
action: String,
source: String,
actor_user_id: String,
actor_device_id: Option<String>,
client_at: String,
server_at: String,
changes: String,
server_version: i64,
}
/// The audit rows of an entity, oldest first.
pub async fn audit_of(app: &TestApp, entity_id: &str) -> Vec<AuditRow> {
let rows: Vec<RawAudit> = sqlx::query_as(
"SELECT action, source, actor_user_id, actor_device_id, client_at, server_at, changes, server_version \
FROM audit_log WHERE entity_id = ? ORDER BY server_version",
)
.bind(entity_id)
.fetch_all(&app.state.db)
.await
.unwrap();
rows.into_iter()
.map(|r| AuditRow {
action: r.action,
source: r.source,
actor_user_id: r.actor_user_id,
actor_device_id: r.actor_device_id,
client_at: r.client_at,
server_at: r.server_at,
changes: serde_json::from_str(&r.changes).unwrap(),
server_version: r.server_version,
})
.collect()
}
/// The pushes enqueued so far, as `(type, entity id, actor, count)`.
pub fn pushes(app: &TestApp) -> Vec<(String, Option<String>, String, u32)> {
app.push
.0
.lock()
.unwrap()
.iter()
.map(|m| {
(
m.push_type.as_str().to_owned(),
m.entity_id.map(|id| id.to_string()),
m.actor_user_id.to_string(),
m.count,
)
})
.collect()
}
/// `count(*)` of a table.
pub async fn count(app: &TestApp, table: &str) -> i64 {
sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!("SELECT count(*) FROM {table}")))
.fetch_one(&app.state.db)
.await
.unwrap()
}
/// The current `sync_sequence` value.
pub async fn sequence(app: &TestApp) -> i64 {
app.scalar_i64("SELECT value FROM sync_sequence").await
}
/// Marks a row deleted directly (an account or category deleted through REST meanwhile).
pub async fn tombstone(app: &TestApp, table: &str, id: &str) {
let version = app.next_version().await;
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {table} SET deleted_at = ?, server_version = ? WHERE id = ?"
)))
.bind(NOW)
.bind(version)
.bind(id)
.execute(&app.state.db)
.await
.unwrap();
}