Files
kvitt/server/crates/kvitt-server/tests/sync_api/offline.rs
T
jacobh2 15056dc7df 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
2026-10-17 22:27:30 +02:00

73 lines
2.9 KiB
Rust

//! 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);
}