M4: sync pull and push (E-31, E-32), audit writer and diff, FCM sender, push batching, scheduler, pruning, rotate-epoch
- src/sync/: pull with UNION ALL paging and cursor_ahead; push processing of SPEC §14.4.4 steps 1 to 12 in one BEGIN IMMEDIATE with a savepoint per mutation, LWW with delete-wins, tombstones, clock_skew, replay from sync_mutations, sync_batch for more than 3 notifiable changes; 90-day pruning. - src/audit/: SqlAuditWriter and the §14.4.7 diff (now the default in AppState::new). - src/push/: bounded PushHub (100, drop oldest), FCM HTTP v1 client with service-account JWT (RS256, jsonwebtoken on aws-lc-rs) and token cache, sender with 1/5/25 s retries and token clearing; disabled without credentials. - src/tasks/: start installs push and spawns the 06:00 generation (and at start) and 04:00 pruning loops on the injectable clock. - cli: rotate-epoch; serve uses into_make_service_with_connect_info; stale doc comments updated; /i/ empty code gets the HTML 404. - tests: sync_api, push_fcm (local stub of Google's endpoints), scheduler_daily; skeleton routes test now asserts no route is 501. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
//! The real audit writer (SPEC §14.4.7, FR-12) through M3's recurring generation and through sync: source, actor,
|
||||
//! times and append-only entries.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_writes_recurring_audit_with_server_now_and_no_device() {
|
||||
let app = app().await;
|
||||
app.set_now("2026-10-17T12:00:00.000Z");
|
||||
let template = new_id();
|
||||
sqlx::query(
|
||||
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \
|
||||
split_mode, day_of_month, start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, \
|
||||
server_version) VALUES (?, ?, ?, 1250000, 'Hyra', '', ?, 'default', 1, '2026-10', ?, ?, ?, ?, 900000)",
|
||||
)
|
||||
.bind(&template)
|
||||
.bind(ACCOUNT_1)
|
||||
.bind(HOME)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(MEMBER_ID)
|
||||
.bind(MEMBER_ID)
|
||||
.bind(NOW)
|
||||
.bind(NOW)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let generated =
|
||||
kvitt_server::api::recurring::generate_due_periods(&app.state, template.parse().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(generated.len(), 1);
|
||||
let audit = audit_of(&app, &generated[0].expense_id.to_string()).await;
|
||||
assert_eq!(audit.len(), 1);
|
||||
let a = &audit[0];
|
||||
assert_eq!(
|
||||
(a.action.as_str(), a.source.as_str()),
|
||||
("create", "recurring")
|
||||
);
|
||||
assert_eq!(a.actor_user_id, MEMBER_ID);
|
||||
assert_eq!(a.actor_device_id, None);
|
||||
assert_eq!((a.client_at.as_str(), a.server_at.as_str()), (NOW, NOW));
|
||||
assert_eq!(
|
||||
a.changes[3],
|
||||
json!({"field": "amount_ore", "old": null, "new": 1250000})
|
||||
);
|
||||
// The entry reaches the phones through pull.
|
||||
let page = pull(&app, &admin_token(), 0, None).await.json;
|
||||
assert!(
|
||||
page["audit_entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["source"] == "recurring")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_are_append_only_create_update_delete() {
|
||||
let app = app().await;
|
||||
let id = new_id();
|
||||
push_one(
|
||||
&app,
|
||||
&admin_token(),
|
||||
exp(expense(&id, "2026-10-17T10:00:00.000Z")),
|
||||
)
|
||||
.await;
|
||||
let first = audit_of(&app, &id).await;
|
||||
let mut e = expense(&id, "2026-10-17T11:00:00.000Z");
|
||||
e["description"] = json!("Coop");
|
||||
push_one(&app, &member_token(), exp(e)).await;
|
||||
let mut d = expense(&id, "2026-10-17T11:30:00.000Z");
|
||||
d["deleted_at"] = json!("2026-10-17T11:30:00.000Z");
|
||||
push_one(&app, &admin_token(), exp(d)).await;
|
||||
let all = audit_of(&app, &id).await;
|
||||
let actions: Vec<_> = all.iter().map(|a| a.action.as_str()).collect();
|
||||
assert_eq!(actions, ["create", "update", "delete"]);
|
||||
assert_eq!(all[0], first[0], "earlier entries are never updated");
|
||||
assert_eq!(
|
||||
all[1].changes,
|
||||
json!([{"field": "description", "old": "ICA Maxi", "new": "Coop"}])
|
||||
);
|
||||
assert_eq!(all[1].actor_user_id, MEMBER_ID);
|
||||
assert!(
|
||||
all.windows(2)
|
||||
.all(|w| w[0].server_version < w[1].server_version)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! The §9.10 conflict cases, end to end through E-32 and E-31.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
|
||||
const T0: &str = "2026-10-17T10:00:00.000Z";
|
||||
|
||||
fn edit(id: &str, at: &str, description: &str) -> serde_json::Value {
|
||||
let mut e = expense(id, at);
|
||||
e["created_at"] = json!(T0);
|
||||
e["description"] = json!(description);
|
||||
e
|
||||
}
|
||||
|
||||
fn delete(id: &str, at: &str) -> serde_json::Value {
|
||||
let mut e = expense(id, at);
|
||||
e["created_at"] = json!(T0);
|
||||
e["deleted_at"] = json!(at);
|
||||
e
|
||||
}
|
||||
|
||||
#[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 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"));
|
||||
let jacob = exp(edit(&id, "2026-10-17T11:05:00.000Z", "Jacob's"));
|
||||
if later_arrives_first {
|
||||
accepted(&push_one(&app, &admin_token(), jacob).await);
|
||||
let r = push_one(&app, &member_token(), anna).await;
|
||||
rejected(&r, "stale");
|
||||
assert_eq!(r["current"]["description"], "Jacob's");
|
||||
} else {
|
||||
accepted(&push_one(&app, &member_token(), anna).await);
|
||||
accepted(&push_one(&app, &admin_token(), jacob).await);
|
||||
}
|
||||
let page = pull(&app, &member_token(), 0, None).await.json;
|
||||
assert_eq!(page["expenses"][0]["description"], "Jacob's");
|
||||
// Only accepted edits are logged; the winning edit is the last one.
|
||||
let audit = audit_of(&app, &id).await;
|
||||
assert_eq!(audit.last().unwrap().changes[0]["new"], "Jacob's");
|
||||
assert_eq!(audit.len(), if later_arrives_first { 2 } else { 3 });
|
||||
}
|
||||
}
|
||||
|
||||
#[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 id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
accepted(
|
||||
&push_one(
|
||||
&app,
|
||||
&admin_token(),
|
||||
exp(delete(&id, "2026-10-17T11:00:00.000Z")),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
let r = push_one(
|
||||
&app,
|
||||
&member_token(),
|
||||
exp(edit(&id, "2026-10-17T11:30:00.000Z", "edit")),
|
||||
)
|
||||
.await;
|
||||
rejected(&r, "deleted");
|
||||
// Edit first, then an older delete: applied.
|
||||
let app = crate::m4::app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
accepted(
|
||||
&push_one(
|
||||
&app,
|
||||
&member_token(),
|
||||
exp(edit(&id, "2026-10-17T11:30:00.000Z", "edit")),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
let entity = accepted(
|
||||
&push_one(
|
||||
&app,
|
||||
&admin_token(),
|
||||
exp(delete(&id, "2026-10-17T11:00:00.000Z")),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(entity["deleted_at"], "2026-10-17T11:00:00.000Z");
|
||||
assert_eq!(entity["description"], "edit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_user_on_two_devices_is_lww_per_row() {
|
||||
let app = crate::m4::app().await;
|
||||
// A second, non-revoked device of the admin.
|
||||
let second = token('S');
|
||||
app.insert_device(
|
||||
"0192a001-0000-7000-8000-000000000009",
|
||||
ADMIN_ID,
|
||||
"Tablet",
|
||||
&second,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T0))).await;
|
||||
accepted(
|
||||
&push_one(
|
||||
&app,
|
||||
&second,
|
||||
exp(edit(&id, "2026-10-17T11:05:00.000Z", "tablet")),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
rejected(
|
||||
&push_one(
|
||||
&app,
|
||||
&admin_token(),
|
||||
exp(edit(&id, "2026-10-17T11:00:00.000Z", "phone")),
|
||||
)
|
||||
.await,
|
||||
"stale",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archived_category_accepted()
|
||||
{
|
||||
let app = crate::m4::app().await;
|
||||
tombstone(&app, "accounts", ACCOUNT_1).await;
|
||||
rejected(
|
||||
&push_one(&app, &admin_token(), exp(expense(&new_id(), T0))).await,
|
||||
"reference_not_found",
|
||||
);
|
||||
sqlx::query("UPDATE categories SET archived_at = ? WHERE id = ?")
|
||||
.bind(NOW)
|
||||
.bind(GROCERIES)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut e = expense(&new_id(), T0);
|
||||
e["account_id"] = json!(ACCOUNT_2);
|
||||
accepted(&push_one(&app, &admin_token(), exp(e)).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 kept = new_id();
|
||||
let entity = expense(&kept, T0);
|
||||
push_one(&app, &admin_token(), exp(entity.clone())).await;
|
||||
kvitt_server::cli::rotate_epoch::rotate(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let lost = new_id();
|
||||
let reply = push(
|
||||
&app,
|
||||
&admin_token(),
|
||||
vec![exp(entity.clone()), exp(expense(&lost, T0))],
|
||||
)
|
||||
.await;
|
||||
let results = reply.json["results"].as_array().unwrap();
|
||||
rejected(&results[0], "stale");
|
||||
for (k, v) in entity.as_object().unwrap() {
|
||||
assert_eq!(&results[0]["current"][k], v, "{k}");
|
||||
}
|
||||
accepted(&results[1]);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! End to end: device A creates through E-32, device B pulls through E-31 and sees the row and its audit entry
|
||||
//! (AC-12.3); exactly one push is queued on the real queue, for the other user's devices.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kvitt_server::push::{PushHub, PushType};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_on_device_a_pull_on_device_b_row_audit_and_one_push() {
|
||||
let mut app = app().await;
|
||||
let hub = Arc::new(PushHub::new());
|
||||
with_push(&mut app, hub.clone());
|
||||
let id = new_id();
|
||||
let cursor_b = pull(&app, &member_token(), 0, Some(1000)).await.json["next_since"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let result = push_one(
|
||||
&app,
|
||||
&admin_token(),
|
||||
exp(expense(&id, "2026-10-17T11:59:00.000Z")),
|
||||
)
|
||||
.await;
|
||||
let created = accepted(&result);
|
||||
|
||||
let page = pull(&app, &member_token(), cursor_b, None).await.json;
|
||||
assert_eq!(page["expenses"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(page["expenses"][0], created);
|
||||
let audit = page["audit_entries"].as_array().unwrap();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0]["entity_id"], id);
|
||||
assert_eq!(audit[0]["action"], "create");
|
||||
assert_eq!(audit[0]["source"], "app");
|
||||
assert_eq!(audit[0]["actor_user_id"], ADMIN_ID);
|
||||
assert_eq!(audit[0]["client_at"], "2026-10-17T11:59:00.000Z");
|
||||
assert_eq!(audit[0]["changes"].as_array().unwrap().len(), 9);
|
||||
assert!(audit[0]["server_version"].as_i64() > created["server_version"].as_i64());
|
||||
assert_eq!(page["next_since"], audit[0]["server_version"]);
|
||||
|
||||
assert_eq!(hub.len(), 1);
|
||||
let message = hub.pop().unwrap();
|
||||
assert_eq!(message.push_type, PushType::ExpenseCreated);
|
||||
assert_eq!(message.entity_id.unwrap().to_string(), id);
|
||||
assert_eq!(message.actor_user_id.to_string(), ADMIN_ID);
|
||||
assert_eq!(message.count, 1);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! M4 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).
|
||||
//!
|
||||
//! Reuses M3's harness (`tests/core_api/common.rs`) with the real `SqlAuditWriter` swapped in (`m4.rs`).
|
||||
|
||||
#[path = "../core_api/common.rs"]
|
||||
pub mod common;
|
||||
pub mod m4;
|
||||
|
||||
mod audit;
|
||||
mod conflicts;
|
||||
mod e2e;
|
||||
mod offline;
|
||||
mod pull;
|
||||
mod push_rules;
|
||||
mod serve;
|
||||
|
||||
impl Default for common::TempDir {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! AC-15.6, server side: a phone offline for 14 days with 200 local changes syncs all of them on reconnect (scripted
|
||||
//! clock: the changes carry the times they were made, the push happens 14 days later).
|
||||
|
||||
use chrono::Duration;
|
||||
use kvitt_server::time::{format_ts, parse_ts};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fourteen_days_offline_200_changes_all_sync() {
|
||||
let app = app().await;
|
||||
// Anna's tablet-free phone: 150 creates, 30 edits of earlier ones, 20 deletes, spread over 14 days.
|
||||
let start = parse_ts(NOW).unwrap();
|
||||
let at = |i: i64| format_ts(start + Duration::minutes(i * 100));
|
||||
let mut latest: Vec<Value> = Vec::new();
|
||||
let mut changes: Vec<Value> = Vec::new();
|
||||
for i in 0..150 {
|
||||
let mut e = expense(&new_id(), &at(i));
|
||||
e["description"] = json!(format!("Köp {i}"));
|
||||
latest.push(e.clone());
|
||||
changes.push(e);
|
||||
}
|
||||
for (i, row) in latest.iter_mut().enumerate().take(30) {
|
||||
row["updated_at"] = json!(at(150 + i as i64));
|
||||
row["notes"] = json!("ändrad");
|
||||
changes.push(row.clone());
|
||||
}
|
||||
for (i, row) in latest.iter_mut().enumerate().skip(30).take(20) {
|
||||
row["updated_at"] = json!(at(180 + i as i64 - 30));
|
||||
row["deleted_at"] = row["updated_at"].clone();
|
||||
changes.push(row.clone());
|
||||
}
|
||||
assert_eq!(changes.len(), 200);
|
||||
assert!(
|
||||
parse_ts(changes[199]["updated_at"].as_str().unwrap()).unwrap()
|
||||
< start + Duration::days(14)
|
||||
);
|
||||
// Meanwhile Jacob kept syncing; the server clock is 14 days on.
|
||||
app.set_now(&format_ts(start + Duration::days(14)));
|
||||
|
||||
// The phone pushes its pending rows (the latest state of each, as §14.4.5 reads them) in batches of 100.
|
||||
let pending: Vec<Value> = latest.iter().map(|e| exp(e.clone())).collect();
|
||||
for batch in pending.chunks(100) {
|
||||
let reply = push(&app, &member_token(), batch.to_vec()).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
for r in reply.json["results"].as_array().unwrap() {
|
||||
accepted(r);
|
||||
}
|
||||
}
|
||||
// And Jacob's phone pulls everything (cursor from before the outage stays valid).
|
||||
let mut since = 0;
|
||||
let mut expenses = Vec::new();
|
||||
loop {
|
||||
let page = pull(&app, &admin_token(), since, Some(1000)).await.json;
|
||||
expenses.extend(page["expenses"].as_array().unwrap().clone());
|
||||
since = page["next_since"].as_i64().unwrap();
|
||||
if page["has_more"] == false {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(expenses.len(), 150);
|
||||
let edited = expenses.iter().filter(|e| e["notes"] == "ändrad").count();
|
||||
let deleted = expenses
|
||||
.iter()
|
||||
.filter(|e| !e["deleted_at"].is_null())
|
||||
.count();
|
||||
assert_eq!((edited, deleted), (30, 20));
|
||||
// Every create of a row that was later deleted offline was never seen by the server: tombstone, no audit.
|
||||
assert_eq!(count(&app, "audit_log").await, 130);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! E-31 pull (SPEC §14.4.2): cursor rules, paging, ordering, tombstones, children with their parent, the epoch.
|
||||
|
||||
use axum::http::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::common::*;
|
||||
use crate::m4::*;
|
||||
|
||||
const T1: &str = "2026-10-17T11:00:00.000Z";
|
||||
const ARRAYS: [&str; 7] = [
|
||||
"users",
|
||||
"categories",
|
||||
"accounts",
|
||||
"recurring_templates",
|
||||
"expenses",
|
||||
"settlements",
|
||||
"audit_entries",
|
||||
];
|
||||
|
||||
fn versions(page: &Value) -> Vec<i64> {
|
||||
let mut all: Vec<i64> = ARRAYS
|
||||
.iter()
|
||||
.flat_map(|a| {
|
||||
page[*a]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["server_version"].as_i64().unwrap())
|
||||
})
|
||||
.collect();
|
||||
all.sort();
|
||||
all
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn since_greater_than_the_sequence_is_cursor_ahead_equal_is_an_empty_page() {
|
||||
let app = app().await;
|
||||
let current = sequence(&app).await;
|
||||
let reply = pull(&app, &admin_token(), current + 1, None).await;
|
||||
assert_error(&reply, 409, "cursor_ahead");
|
||||
let reply = pull(&app, &admin_token(), current, None).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
assert_like_example(
|
||||
&reply.json,
|
||||
"sync/pull-empty-page.json",
|
||||
&["epoch", "since", "next_since", "server_time"],
|
||||
);
|
||||
assert_eq!(reply.json["next_since"], current);
|
||||
assert_eq!(reply.json["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_parameters_are_checked() {
|
||||
let app = app().await;
|
||||
for path in [
|
||||
"/sync",
|
||||
"/sync?since=abc",
|
||||
"/sync?since=-1",
|
||||
"/sync?since=0&limit=0",
|
||||
"/sync?since=0&limit=1001",
|
||||
"/sync?since=0&limit=x",
|
||||
] {
|
||||
let reply = app.call(Method::GET, path, &admin_token(), None).await;
|
||||
assert_error(&reply, 400, "invalid_request");
|
||||
}
|
||||
assert_eq!(pull(&app, &admin_token(), 0, Some(1000)).await.status, 200);
|
||||
assert_eq!(pull(&app, &admin_token(), 0, Some(1)).await.status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_first_page_has_everything_in_version_order_with_the_epoch_and_server_time() {
|
||||
let app = app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T1))).await;
|
||||
push_one(&app, &admin_token(), set(settlement(&new_id(), T1))).await;
|
||||
let reply = pull(&app, &member_token(), 0, None).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
let page = &reply.json;
|
||||
let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
||||
.fetch_one(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page["epoch"], epoch);
|
||||
assert_eq!(page["since"], 0);
|
||||
assert_eq!(page["server_time"], NOW);
|
||||
assert_eq!(page["has_more"], false);
|
||||
assert_eq!(page["next_since"], sequence(&app).await);
|
||||
let counts: Vec<usize> = ARRAYS
|
||||
.iter()
|
||||
.map(|a| page[*a].as_array().unwrap().len())
|
||||
.collect();
|
||||
// 2 users, 10 default categories, 2 accounts, no templates, 1 expense, 1 settlement, 2 audit entries.
|
||||
assert_eq!(counts, [2, 10, 2, 0, 1, 1, 2]);
|
||||
for a in ARRAYS {
|
||||
let v: Vec<i64> = page[a]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["server_version"].as_i64().unwrap())
|
||||
.collect();
|
||||
let mut sorted = v.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(v, sorted, "{a} sorted by server_version");
|
||||
}
|
||||
// Children come with the parent.
|
||||
assert_eq!(page["expenses"][0]["splits"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
page["accounts"][0]["default_shares"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(page["audit_entries"][0]["entity_id"], id);
|
||||
let keys: Vec<_> = page["audit_entries"][0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"action",
|
||||
"actor_user_id",
|
||||
"changes",
|
||||
"client_at",
|
||||
"entity_id",
|
||||
"entity_type",
|
||||
"id",
|
||||
"server_at",
|
||||
"server_version",
|
||||
"source"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paging_walks_every_row_once_parents_with_children_count_as_one() {
|
||||
let app = app().await;
|
||||
for _ in 0..9 {
|
||||
push_one(&app, &admin_token(), exp(expense(&new_id(), T1))).await;
|
||||
}
|
||||
// 2 users + 10 categories + 2 accounts + 9 expenses + 9 audit entries = 32 rows.
|
||||
let mut since = 0;
|
||||
let mut seen = Vec::new();
|
||||
let mut pages = 0;
|
||||
loop {
|
||||
let reply = pull(&app, &admin_token(), since, Some(5)).await;
|
||||
let page = &reply.json;
|
||||
let v = versions(page);
|
||||
assert!(v.len() <= 5);
|
||||
assert_eq!(page["since"], since);
|
||||
assert_eq!(page["next_since"], v.last().copied().unwrap_or(since));
|
||||
assert!(v.iter().all(|x| *x > since));
|
||||
seen.extend(v);
|
||||
since = page["next_since"].as_i64().unwrap();
|
||||
pages += 1;
|
||||
if page["has_more"] == false {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(pages, 7);
|
||||
assert_eq!(seen.len(), 32);
|
||||
let mut dedup = seen.clone();
|
||||
dedup.dedup();
|
||||
assert_eq!(dedup, seen);
|
||||
// The final empty page.
|
||||
let reply = pull(&app, &admin_token(), since, Some(5)).await;
|
||||
assert_eq!(reply.json["next_since"], since);
|
||||
assert_eq!(reply.json["has_more"], false);
|
||||
// has_more is false when the page ends exactly on the last row.
|
||||
let reply = pull(&app, &admin_token(), 0, Some(32)).await;
|
||||
assert_eq!(reply.json["has_more"], false);
|
||||
let reply = pull(&app, &admin_token(), 0, Some(31)).await;
|
||||
assert_eq!(reply.json["has_more"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_default_limit_is_500() {
|
||||
let app = app().await;
|
||||
for _ in 0..500 {
|
||||
app.insert_expense(ACCOUNT_1, GROCERIES, 100, ADMIN_ID, (50, 50), false)
|
||||
.await;
|
||||
}
|
||||
let reply = pull(&app, &admin_token(), 0, None).await;
|
||||
assert_eq!(versions(&reply.json).len(), 500);
|
||||
assert_eq!(reply.json["has_more"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tombstones_are_pulled_and_updated_rows_move_to_their_new_version() {
|
||||
let app = app().await;
|
||||
let id = new_id();
|
||||
push_one(&app, &admin_token(), exp(expense(&id, T1))).await;
|
||||
let cursor = sequence(&app).await;
|
||||
let mut del = expense(&id, "2026-10-17T11:30:00.000Z");
|
||||
del["deleted_at"] = serde_json::json!("2026-10-17T11:30:00.000Z");
|
||||
push_one(&app, &admin_token(), exp(del)).await;
|
||||
let page = pull(&app, &member_token(), cursor, None).await.json;
|
||||
assert_eq!(page["expenses"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
page["expenses"][0]["deleted_at"],
|
||||
"2026-10-17T11:30:00.000Z"
|
||||
);
|
||||
assert_eq!(page["audit_entries"][0]["action"], "delete");
|
||||
// A full pull holds the row once, at its new version.
|
||||
let page = pull(&app, &member_token(), 0, None).await.json;
|
||||
assert_eq!(page["expenses"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_epoch_change_is_visible_in_pull() {
|
||||
let app = app().await;
|
||||
let before = pull(&app, &admin_token(), 0, Some(1)).await.json["epoch"].clone();
|
||||
let new = kvitt_server::cli::rotate_epoch::rotate(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = pull(&app, &admin_token(), 0, Some(1)).await.json["epoch"].clone();
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(after, new);
|
||||
assert_eq!(new.len(), 32);
|
||||
assert!(
|
||||
new.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
//! `serve` as deployed: the router with `ConnectInfo<SocketAddr>` (so the rate limiter sees each client's address,
|
||||
//! SPEC §8.6), `/i/` with an empty code as the HTML 404 page, and `rotate-epoch` as the real binary (SPEC §10.4).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::process::Command;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request};
|
||||
use kvitt_server::cli::make_service;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_client_addresses_get_separate_rate_limit_buckets_end_to_end() {
|
||||
let app = TestApp::empty().await;
|
||||
// One service, so one rate limiter, reached from two client addresses: 127.0.0.1 and ::1.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let v4 = listener.local_addr().unwrap();
|
||||
let listener6 = TcpListener::bind("[::1]:0").await.unwrap();
|
||||
let v6 = listener6.local_addr().unwrap();
|
||||
let service = make_service(app.state.clone());
|
||||
let service6 = service.clone();
|
||||
tokio::spawn(async move { axum::serve(listener, service).await.unwrap() });
|
||||
tokio::spawn(async move { axum::serve(listener6, service6).await.unwrap() });
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let get = |addr: SocketAddr| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.get(format!("http://{addr}/health"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
};
|
||||
for _ in 0..60 {
|
||||
assert_eq!(get(v4).await, 200);
|
||||
}
|
||||
assert_eq!(
|
||||
get(v4).await,
|
||||
429,
|
||||
"the 61st request from 127.0.0.1 in the minute"
|
||||
);
|
||||
assert_eq!(get(v6).await, 200, "::1 has its own bucket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_invite_code_is_the_html_404_page() {
|
||||
let app = TestApp::empty().await;
|
||||
let reply = app
|
||||
.send(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/i/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply.status, 404);
|
||||
assert_eq!(reply.headers["content-type"], "text/html; charset=utf-8");
|
||||
assert!(reply.text().contains("<p>404</p>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() {
|
||||
let app = TestApp::empty().await;
|
||||
let epoch = || async {
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
||||
.fetch_one(&app.state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let before = epoch().await;
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
|
||||
.arg("rotate-epoch")
|
||||
.env("KVITT_DATA_DIR", app.data_dir())
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{output:?}");
|
||||
let after = epoch().await;
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(after.len(), 32);
|
||||
assert!(after.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains(&after));
|
||||
}
|
||||
Reference in New Issue
Block a user