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
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
//! The real audit writer (SPEC §14.4.7, FR-12) through M3's recurring generation and through sync: source, actor,
|
||||
//! The real audit writer (SPEC §14.4.7, FR-12) through the recurring generation and through sync: source, actor,
|
||||
//! times and append-only entries.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_writes_recurring_audit_with_server_now_and_no_device() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
const T0: &str = "2026-10-17T10:00:00.000Z";
|
||||
|
||||
@@ -24,7 +24,7 @@ fn delete(id: &str, at: &str) -> serde_json::Value {
|
||||
#[tokio::test]
|
||||
async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_order() {
|
||||
for later_arrives_first in [true, false] {
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
let anna = exp(edit(&id, "2026-10-17T11:00:00.000Z", "Anna's"));
|
||||
@@ -50,7 +50,7 @@ async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_ord
|
||||
#[tokio::test]
|
||||
async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timestamps() {
|
||||
// Delete first, then a newer edit: rejected deleted.
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
accepted(
|
||||
@@ -69,7 +69,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
|
||||
.await;
|
||||
rejected(&r, "deleted");
|
||||
// Edit first, then an older delete: applied.
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
accepted(
|
||||
@@ -94,7 +94,7 @@ async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timesta
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_user_on_two_devices_is_lww_per_row() {
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
// A second, non-revoked device of the admin.
|
||||
let second = token('S');
|
||||
app.insert_device(
|
||||
@@ -129,7 +129,7 @@ async fn same_user_on_two_devices_is_lww_per_row() {
|
||||
#[tokio::test]
|
||||
async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archived_category_accepted()
|
||||
{
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
tombstone(&app, "accounts", ACCOUNT_1).await;
|
||||
rejected(
|
||||
&push_one(&app, &admin_token(), exp(expense(&new_id(), T0))).await,
|
||||
@@ -150,7 +150,7 @@ async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archi
|
||||
async fn epoch_recovery_re_push_of_identical_rows_comes_back_stale_with_equal_content() {
|
||||
// §14.4.8: after a restore the phone re-pushes everything; identical rows are stale with current equal to the
|
||||
// pushed client-writable fields (the phone treats that as accepted), missing rows are inserted.
|
||||
let app = crate::m4::app().await;
|
||||
let app = crate::sync_fixtures::app().await;
|
||||
let kept = new_id();
|
||||
let entity = expense(&kept, T0);
|
||||
push_one(&app, &admin_token(), exp(entity.clone())).await;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use kvitt_server::push::{PushHub, PushType};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_on_device_a_pull_on_device_b_row_audit_and_one_push() {
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
//! 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<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();
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
//! M4 sync integration tests: every rule of SPEC §14.4.2 (pull) and §14.4.4 (push steps 1 to 12), each rejection
|
||||
//! Sync integration tests: every rule of SPEC §14.4.2 (pull) and §14.4.4 (push steps 1 to 12), each rejection
|
||||
//! reason of §14.4.6, the §9.10 conflict cases, batch limits, `cursor_ahead`, the epoch, the 14-day offline scenario
|
||||
//! (AC-15.6), the audit writer (§14.4.7), `rotate-epoch` (§10.4), the end-to-end path device A push → device B pull,
|
||||
//! and the orchestrator-assigned `serve` fixes (ConnectInfo for the rate limiter, `/i/` HTML 404).
|
||||
//! and `serve` itself (ConnectInfo for the rate limiter, `/i/` HTML 404, the shutdown drain).
|
||||
//!
|
||||
//! Reuses M3's harness (`tests/core_api/common.rs`) with the real `SqlAuditWriter` swapped in (`m4.rs`).
|
||||
//! The harness is `tests/common/`, with the real `SqlAuditWriter` swapped in by `sync_fixtures`.
|
||||
|
||||
#[path = "../core_api/common.rs"]
|
||||
#[path = "../common/mod.rs"]
|
||||
pub mod common;
|
||||
pub mod m4;
|
||||
#[path = "../common/sync_fixtures.rs"]
|
||||
pub mod sync_fixtures;
|
||||
|
||||
mod audit;
|
||||
mod conflicts;
|
||||
@@ -16,9 +17,3 @@ mod offline;
|
||||
mod pull;
|
||||
mod push_rules;
|
||||
mod serve;
|
||||
|
||||
impl Default for common::TempDir {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use kvitt_server::time::{format_ts, parse_ts};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fourteen_days_offline_200_changes_all_sync() {
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::http::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
const T1: &str = "2026-10-17T11:00:00.000Z";
|
||||
const ARRAYS: [&str; 7] = [
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::http::Method;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
use crate::sync_fixtures::*;
|
||||
|
||||
/// A change applied to a pushed entity.
|
||||
type Mutator = Box<dyn Fn(&mut Value)>;
|
||||
|
||||
Reference in New Issue
Block a user