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
226 lines
7.1 KiB
Rust
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();
|
|
}
|