//! M4 additions to M3's harness: 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) { 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) -> 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) -> 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, 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, 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 { let rows: Vec = 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, 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(); }