15056dc7df
- 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
73 lines
2.9 KiB
Rust
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);
|
|
}
|