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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user