//! Per-case marker store for native clients. //! //! Each known case (either created locally by this client or seen in a //! server snapshot) has one JSON file: `{dir}/{case_id}.json`. The store //! keeps an in-memory copy and broadcasts a sorted snapshot on every //! write via [`tokio::sync::watch`]. //! //! Two mutation paths treat the server snapshot asymmetrically: //! //! - [`CaseStore::merge_server_snapshot`] **adds or updates** markers — //! never deletes. `/api/oneliners` is a sliding-window view, so //! "missing from snapshot" on its own is ambiguous (could be deleted //! server-side, could simply have fallen out of the window). //! - [`CaseStore::reconcile_with_server_snapshot`] **removes** synced //! markers the server doesn't know about **anymore**, scoped to the //! window reported by `OnelinersResponse.as_of - window_hours`. Pending //! markers (`synced_to_server = false`) and markers older than the //! window are preserved — their absence from the snapshot carries no //! information about deletion. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use doctate_common::oneliners::OnelinersResponse; use serde::{Deserialize, Serialize}; use thiserror::Error; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; use tokio::sync::{watch, Mutex}; use tracing::{debug, info, warn}; use uuid::Uuid; /// On-disk representation of a single case known to this client. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CaseMarker { pub case_id: Uuid, /// RFC3339 UTC; set once at creation or on first server-snapshot merge. pub created_at: String, /// RFC3339 UTC; bumped on every new recording + on server-snapshot /// updates. Drives the UI sort order (newest activity first). pub last_activity_at: String, /// `true` once this case has been seen in any `/api/oneliners` /// response or after a successful upload ACK. Drives two clean-up /// paths: [`CaseStore::reconcile_with_server_snapshot`] only removes /// synced markers the server no longer lists, and /// [`CaseStore::cleanup_stale`] only ages out synced markers. Pending /// (`= false`) markers guard unsent uploads and are never touched by /// either — losing one would silently erase a patient recording. pub synced_to_server: bool, /// `None` while transcription / oneliner generation is pending; set /// from the server snapshot when available. pub oneliner: Option, } #[derive(Debug, Error)] pub enum CaseStoreError { #[error("I/O error at {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("serialize marker: {0}")] Serialize(#[from] serde_json::Error), #[error("format timestamp: {0}")] Format(#[from] time::error::Format), #[error("parse timestamp: {0}")] Parse(#[from] time::error::Parse), } type Snapshot = Arc>; /// Shared case store. Cheap to `clone()`; clones share the same state. #[derive(Clone)] pub struct CaseStore { dir: PathBuf, state: Arc>>, tx: watch::Sender, } impl CaseStore { /// Open or create the store at `dir`. Reads every `*.json` file in /// the directory as an initial marker. Returns the store plus a /// receiver that yields sorted snapshots (newest `last_activity_at` /// first) on every mutation. pub async fn open(dir: PathBuf) -> Result<(Self, watch::Receiver), CaseStoreError> { tokio::fs::create_dir_all(&dir) .await .map_err(|e| CaseStoreError::Io { path: dir.clone(), source: e, })?; let initial = read_all_markers(&dir).await?; let map: HashMap = initial.into_iter().map(|m| (m.case_id, m)).collect(); let snapshot = sorted_snapshot(&map); let (tx, rx) = watch::channel(snapshot); let store = Self { dir, state: Arc::new(Mutex::new(map)), tx, }; Ok((store, rx)) } /// Append a new case the client itself just started (no server /// knowledge yet). Writes the marker atomically and broadcasts the /// new snapshot. If a marker with the same `case_id` already exists, /// it is overwritten — callers must check before if that matters. pub async fn create_local( &self, case_id: Uuid, at: OffsetDateTime, ) -> Result { let ts = format_utc(at)?; let marker = CaseMarker { case_id, created_at: ts.clone(), last_activity_at: ts, synced_to_server: false, oneliner: None, }; let mut state = self.state.lock().await; write_marker_file(&self.dir, &marker).await?; state.insert(case_id, marker.clone()); self.broadcast(&state); Ok(marker) } /// Record new activity on an existing case (e.g. a follow-up /// recording). Creates the marker if missing — callers may invoke /// this after an upload without caring whether `create_local` ran /// first. `synced_to_server` is preserved. pub async fn mark_activity( &self, case_id: Uuid, at: OffsetDateTime, ) -> Result<(), CaseStoreError> { let ts = format_utc(at)?; let mut state = self.state.lock().await; let marker = state.entry(case_id).or_insert_with(|| CaseMarker { case_id, created_at: ts.clone(), last_activity_at: ts.clone(), synced_to_server: false, oneliner: None, }); marker.last_activity_at = ts; write_marker_file(&self.dir, marker).await?; self.broadcast(&state); Ok(()) } /// Mark an existing case as confirmed-by-server. Called by the /// `ServerSync` worker immediately after a successful upload ACK — so /// the `synced_to_server` flag reflects reality without waiting for /// the next `/api/oneliners` poll to round-trip. /// /// No-op if the marker does not exist locally (e.g. `clear_all` was /// called between `submit` and `ACK`). `last_activity_at` is **not** /// touched: its semantics are "last user activity" (recording end), /// not "last server confirmation". pub async fn mark_synced(&self, case_id: Uuid) -> Result<(), CaseStoreError> { let mut state = self.state.lock().await; let Some(marker) = state.get_mut(&case_id) else { return Ok(()); }; if marker.synced_to_server { return Ok(()); // already true, skip the disk write } marker.synced_to_server = true; let marker_clone = marker.clone(); write_marker_file(&self.dir, &marker_clone).await?; self.broadcast(&state); Ok(()) } /// Apply a server `/api/oneliners` response. For every entry in the /// snapshot: insert (with `synced_to_server = true`) or update the /// local marker (oneliner + last_activity_at from server timestamps, /// taking the newer of local vs. server). Never deletes. /// /// The `last_activity_at` seed from the server prefers /// `last_recording_at` (most recent m4a mtime — the authoritative /// "when was this case last worked on?") over `updated_at` (oneliner /// regeneration time, which collapses to the recovery-scan moment /// after a restart). Falls back to `created_at` if neither is set. pub async fn merge_server_snapshot( &self, snapshot: &OnelinersResponse, ) -> Result<(), CaseStoreError> { let mut state = self.state.lock().await; for entry in &snapshot.oneliners { let Ok(case_id) = Uuid::parse_str(&entry.case_id) else { warn!(case_id = %entry.case_id, "server snapshot entry with invalid case_id, skipped"); continue; }; let server_activity = entry .last_recording_at .clone() .or_else(|| entry.updated_at.clone()) .unwrap_or_else(|| entry.created_at.clone()); let updated_marker = match state.get(&case_id) { Some(existing) => { let mut m = existing.clone(); m.synced_to_server = true; m.oneliner = entry.oneliner.clone(); // Keep whichever `last_activity_at` is newer — the // client may have recorded an addendum the server // hasn't processed yet. if server_activity > m.last_activity_at { m.last_activity_at = server_activity; } m } None => CaseMarker { case_id, created_at: entry.created_at.clone(), last_activity_at: server_activity, synced_to_server: true, oneliner: entry.oneliner.clone(), }, }; write_marker_file(&self.dir, &updated_marker).await?; state.insert(case_id, updated_marker); } self.broadcast(&state); Ok(()) } /// Reconcile local markers against an authoritative server snapshot. /// /// Removes exactly those markers that are all of: /// 1. `synced_to_server == true` (server has seen this case before), /// 2. `last_activity_at >= as_of - window_hours` (inside the window /// the response is authoritative for), and /// 3. `case_id ∉ response.oneliners` (server no longer reports it). /// /// In that combination the server's silence is unambiguous: the /// case was deleted (web UI soft-delete, or future hard-delete by a /// retention sweep). Pending markers (`synced_to_server = false`) /// guard unsent uploads and are left alone. Markers older than the /// window are also preserved — their absence from the snapshot is /// expected and says nothing about deletion. /// /// Uses the server-reported `as_of` (not the client clock) as the /// window anchor, so clock skew between client and server cannot /// cause spurious deletions. pub async fn reconcile_with_server_snapshot( &self, response: &OnelinersResponse, ) -> Result { let as_of = OffsetDateTime::parse(&response.as_of, &Rfc3339)?; let window_start = as_of - Duration::hours(response.window_hours as i64); let window_start_str = format_utc(window_start)?; let server_ids: HashSet = response .oneliners .iter() .filter_map(|e| Uuid::parse_str(&e.case_id).ok()) .collect(); let mut state = self.state.lock().await; let to_remove: Vec = state .iter() .filter(|(id, m)| { m.synced_to_server && m.last_activity_at >= window_start_str && !server_ids.contains(id) }) .map(|(id, _)| *id) .collect(); let count = to_remove.len(); for id in &to_remove { let path = self.dir.join(format!("{id}.json")); match tokio::fs::remove_file(&path).await { Ok(_) => info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)"), Err(e) => warn!(case_id = %id, error = %e, "failed to remove reconciled marker"), } state.remove(id); } if count > 0 { self.broadcast(&state); } Ok(count) } /// Drop markers whose last activity is older than `cutoff` **and** /// that the server has already confirmed (`synced_to_server = true`). /// Pending/unsynced markers are preserved — they guard uploads that /// have not yet reached the server, and their loss would silently /// erase patient recordings from the client's awareness. /// /// Returns the number of markers removed. Intended as an /// app-startup pass so the data-minimization rule holds: metadata /// ages out after a few days even without an explicit UI action. pub async fn cleanup_stale( &self, cutoff: OffsetDateTime, ) -> Result { let cutoff_str = format_utc(cutoff)?; let mut state = self.state.lock().await; let to_remove: Vec = state .iter() .filter(|(_, m)| m.synced_to_server && m.last_activity_at < cutoff_str) .map(|(id, _)| *id) .collect(); let count = to_remove.len(); for id in &to_remove { let path = self.dir.join(format!("{id}.json")); match tokio::fs::remove_file(&path).await { Ok(_) => tracing::info!(case_id = %id, path = %path.display(), "stale marker removed"), Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"), } state.remove(id); } if count > 0 { self.broadcast(&state); } Ok(count) } /// Remove every marker from memory and disk. Used on config change /// (new API key = potentially different user). pub async fn clear_all(&self) -> Result<(), CaseStoreError> { let mut state = self.state.lock().await; let mut entries = tokio::fs::read_dir(&self.dir) .await .map_err(|e| CaseStoreError::Io { path: self.dir.clone(), source: e, })?; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } if let Err(e) = tokio::fs::remove_file(&path).await { warn!(path = %path.display(), error = %e, "failed to remove marker during clear"); } } state.clear(); self.broadcast(&state); Ok(()) } /// Current in-memory list, for tests and inspection. pub async fn list(&self) -> Vec { let state = self.state.lock().await; sorted_vec(&state) } fn broadcast(&self, state: &HashMap) { let snapshot = sorted_snapshot(state); // receiver-count=0 is fine (nobody listening); ignore. let _ = self.tx.send(snapshot); } } fn format_utc(at: OffsetDateTime) -> Result { at.to_offset(time::UtcOffset::UTC).format(&Rfc3339) } fn sorted_snapshot(state: &HashMap) -> Snapshot { Arc::new(sorted_vec(state)) } fn sorted_vec(state: &HashMap) -> Vec { let mut v: Vec = state.values().cloned().collect(); v.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); v } async fn read_all_markers(dir: &Path) -> Result, CaseStoreError> { let mut out = Vec::new(); let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| CaseStoreError::Io { path: dir.to_owned(), source: e, })?; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } match read_marker_file(&path).await { Ok(m) => out.push(m), Err(e) => { // A single unreadable file should not poison the store; // log and skip so recovery of the rest proceeds. warn!(path = %path.display(), error = %e, "skipping unreadable marker"); } } } Ok(out) } async fn read_marker_file(path: &Path) -> Result { let bytes = tokio::fs::read(path).await.map_err(|e| CaseStoreError::Io { path: path.to_owned(), source: e, })?; Ok(serde_json::from_slice(&bytes)?) } /// Write a marker atomically: temp-file + rename. The rename on the same /// filesystem is atomic on POSIX and NTFS, so readers never see a /// partially-written file. async fn write_marker_file(dir: &Path, marker: &CaseMarker) -> Result<(), CaseStoreError> { let bytes = serde_json::to_vec_pretty(marker)?; let final_path = dir.join(format!("{}.json", marker.case_id)); let tmp_path = dir.join(format!("{}.json.tmp", marker.case_id)); tokio::fs::write(&tmp_path, &bytes) .await .map_err(|e| CaseStoreError::Io { path: tmp_path.clone(), source: e, })?; tokio::fs::rename(&tmp_path, &final_path) .await .map_err(|e| CaseStoreError::Io { path: final_path.clone(), source: e, })?; debug!(path = %final_path.display(), "marker written"); Ok(()) } #[cfg(test)] mod tests { use super::*; use doctate_common::oneliners::OnelinerEntry; use tempfile::TempDir; fn t(s: &str) -> OffsetDateTime { OffsetDateTime::parse(s, &Rfc3339).unwrap() } fn server_entry(case_id: Uuid, oneliner: Option<&str>, created: &str) -> OnelinerEntry { OnelinerEntry { case_id: case_id.to_string(), oneliner: oneliner.map(str::to_owned), created_at: created.to_owned(), last_recording_at: Some(created.to_owned()), updated_at: Some(created.to_owned()), } } fn server_snapshot(entries: Vec) -> OnelinersResponse { OnelinersResponse { as_of: "2026-04-18T10:00:00Z".into(), window_hours: 72, oneliners: entries, } } #[tokio::test] async fn create_local_persists_and_reloads() { let tmp = TempDir::new().unwrap(); let (store, mut rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); assert!(rx.borrow().is_empty()); let id = Uuid::new_v4(); let marker = store.create_local(id, t("2026-04-18T10:32:00Z")).await.unwrap(); assert_eq!(marker.case_id, id); assert!(!marker.synced_to_server); assert!(marker.oneliner.is_none()); rx.changed().await.unwrap(); let snap = rx.borrow().clone(); assert_eq!(snap.len(), 1); assert_eq!(snap[0].case_id, id); // Reopen: state must survive. drop(store); let (store2, _rx2) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); assert_eq!(store2.list().await.len(), 1); } #[tokio::test] async fn mark_activity_updates_timestamp() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); store.mark_activity(id, t("2026-04-18T10:45:00Z")).await.unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); assert_eq!(list[0].last_activity_at, "2026-04-18T10:45:00Z"); // `created_at` must not change. assert_eq!(list[0].created_at, "2026-04-18T10:00:00Z"); } #[tokio::test] async fn mark_activity_creates_if_missing() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.mark_activity(id, t("2026-04-18T10:00:00Z")).await.unwrap(); assert_eq!(store.list().await.len(), 1); } #[tokio::test] async fn merge_adds_unknown_server_case() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); let snap = server_snapshot(vec![server_entry( id, Some("Kniegelenk re."), "2026-04-18T09:30:00Z", )]); store.merge_server_snapshot(&snap).await.unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); assert_eq!(list[0].case_id, id); assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk re.")); assert!(list[0].synced_to_server); } #[tokio::test] async fn merge_updates_existing_local_marker() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); let snap = server_snapshot(vec![server_entry( id, Some("Hypertonie"), "2026-04-18T10:00:00Z", )]); store.merge_server_snapshot(&snap).await.unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); assert!(list[0].synced_to_server); assert_eq!(list[0].oneliner.as_deref(), Some("Hypertonie")); } #[tokio::test] async fn merge_keeps_local_when_missing_from_server() { // User recorded locally, upload not yet through. Server snapshot // is empty — marker must survive. let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); let snap = server_snapshot(vec![]); store.merge_server_snapshot(&snap).await.unwrap(); assert_eq!(store.list().await.len(), 1); } #[tokio::test] async fn merge_keeps_synced_marker_when_missing_from_server() { // Case was synced earlier, now out of the server's time window. // Must not be deleted. let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); let synced_snap = server_snapshot(vec![server_entry( id, Some("old"), "2026-04-18T10:00:00Z", )]); store.merge_server_snapshot(&synced_snap).await.unwrap(); // Next poll: server window moved, case fell out. store.merge_server_snapshot(&server_snapshot(vec![])).await.unwrap(); let list = store.list().await; assert_eq!(list.len(), 1); assert!(list[0].synced_to_server); assert_eq!(list[0].oneliner.as_deref(), Some("old")); } #[tokio::test] async fn merge_prefers_newer_last_activity() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); store.mark_activity(id, t("2026-04-18T11:00:00Z")).await.unwrap(); // Server says last activity was 10:00 (stale) — local 11:00 must win. let snap = server_snapshot(vec![server_entry( id, Some("x"), "2026-04-18T10:00:00Z", )]); store.merge_server_snapshot(&snap).await.unwrap(); let list = store.list().await; assert_eq!(list[0].last_activity_at, "2026-04-18T11:00:00Z"); } #[tokio::test] async fn clear_all_removes_everything() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap(); store.create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z")).await.unwrap(); assert_eq!(store.list().await.len(), 2); store.clear_all().await.unwrap(); assert!(store.list().await.is_empty()); // Marker files on disk are gone. let mut count = 0; let mut entries = tokio::fs::read_dir(tmp.path()).await.unwrap(); while let Ok(Some(e)) = entries.next_entry().await { if e.path().extension().and_then(|s| s.to_str()) == Some("json") { count += 1; } } assert_eq!(count, 0); } /// The client must seed `last_activity_at` from `last_recording_at` /// (the authoritative dictation-activity timestamp), not from /// `updated_at` (oneliner regeneration time, which collapses to a /// narrow window after a startup recovery scan and breaks ordering). #[tokio::test] async fn merge_prefers_last_recording_over_updated_at() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); // Server: case created yesterday, last dictation 30 min ago, // oneliner regenerated just now in a recovery scan. let entry = OnelinerEntry { case_id: id.to_string(), oneliner: Some("x".into()), created_at: "2026-04-17T09:00:00Z".into(), last_recording_at: Some("2026-04-18T09:30:00Z".into()), updated_at: Some("2026-04-18T10:00:00Z".into()), }; store .merge_server_snapshot(&server_snapshot(vec![entry])) .await .unwrap(); let list = store.list().await; assert_eq!(list[0].last_activity_at, "2026-04-18T09:30:00Z"); } /// Backward-compat: if the server response omits `last_recording_at` /// (older server or future schema shift), the client falls back to /// `updated_at` then `created_at`. #[tokio::test] async fn merge_falls_back_to_updated_at_when_last_recording_missing() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); let entry = OnelinerEntry { case_id: id.to_string(), oneliner: Some("x".into()), created_at: "2026-04-17T09:00:00Z".into(), last_recording_at: None, updated_at: Some("2026-04-18T10:00:00Z".into()), }; store .merge_server_snapshot(&server_snapshot(vec![entry])) .await .unwrap(); let list = store.list().await; assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z"); } #[tokio::test] async fn cleanup_stale_removes_old_synced_markers() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let old_synced = Uuid::new_v4(); let young_synced = Uuid::new_v4(); let old_unsynced = Uuid::new_v4(); // old_synced: created 4 days ago, then merged in → synced=true, old store.create_local(old_synced, t("2026-04-14T10:00:00Z")).await.unwrap(); store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: old_synced.to_string(), oneliner: Some("old".into()), created_at: "2026-04-14T10:00:00Z".into(), last_recording_at: Some("2026-04-14T10:00:00Z".into()), updated_at: Some("2026-04-14T10:00:00Z".into()), }])) .await .unwrap(); // young_synced: created today, synced store.create_local(young_synced, t("2026-04-18T10:00:00Z")).await.unwrap(); store .merge_server_snapshot(&server_snapshot(vec![OnelinerEntry { case_id: young_synced.to_string(), oneliner: Some("young".into()), created_at: "2026-04-18T10:00:00Z".into(), last_recording_at: Some("2026-04-18T10:00:00Z".into()), updated_at: Some("2026-04-18T10:00:00Z".into()), }])) .await .unwrap(); // old_unsynced: created 4 days ago, NEVER merged in → pending upload store.create_local(old_unsynced, t("2026-04-14T10:00:00Z")).await.unwrap(); // Cutoff: 72h before the "today" markers, i.e. 2026-04-15. let cutoff = t("2026-04-15T10:00:00Z"); let removed = store.cleanup_stale(cutoff).await.unwrap(); assert_eq!(removed, 1, "only old+synced must be removed"); let list = store.list().await; let ids: Vec = list.iter().map(|m| m.case_id).collect(); assert!(ids.contains(&young_synced), "young synced must stay"); assert!( ids.contains(&old_unsynced), "old unsynced must stay (pending upload)" ); assert!(!ids.contains(&old_synced), "old synced must be gone"); // Disk: old_synced's file deleted. let old_path = tmp.path().join(format!("{old_synced}.json")); assert!(!old_path.exists(), "marker file for old_synced must be deleted"); } #[tokio::test] async fn cleanup_stale_is_noop_when_nothing_old() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap(); let removed = store.cleanup_stale(t("2026-01-01T00:00:00Z")).await.unwrap(); assert_eq!(removed, 0); assert_eq!(store.list().await.len(), 1); } /// Helper: build a snapshot with an explicit `as_of` anchor. fn snapshot_as_of(as_of: &str, entries: Vec) -> OnelinersResponse { OnelinersResponse { as_of: as_of.into(), window_hours: 72, oneliners: entries, } } /// Core case: a synced marker whose `last_activity_at` sits inside the /// server's window, but whose `case_id` is missing from the snapshot → /// that can only mean server-side deletion, so the marker goes. #[tokio::test] async fn reconcile_removes_synced_marker_missing_from_snapshot() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); // Seed a synced marker with recent activity. store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); store.mark_synced(id).await.unwrap(); // Snapshot authoritative for [2026-04-15T10Z, 2026-04-18T10Z], // but the case is not listed — server deleted it. let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap(); assert_eq!(removed, 1); assert!(store.list().await.is_empty()); let path = tmp.path().join(format!("{id}.json")); assert!(!path.exists(), "marker file must be deleted"); } /// Pending markers guard unsent uploads — they must never be touched /// by reconciliation, even if the server knows nothing about them. #[tokio::test] async fn reconcile_keeps_pending_marker_missing_from_snapshot() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); // Pending: synced_to_server = false (default after create_local). store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap(); assert_eq!(removed, 0); assert_eq!(store.list().await.len(), 1); } /// A synced marker older than the window: server not listing it is /// expected (fell out of the sliding window), not a deletion signal. /// Must survive reconciliation. #[tokio::test] async fn reconcile_keeps_old_synced_marker_outside_window() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); // last_activity_at = 2026-04-10 (8 days ago); window = 72h anchor 2026-04-18T10Z // → window_start = 2026-04-15T10Z. Marker is outside. store.create_local(id, t("2026-04-10T10:00:00Z")).await.unwrap(); store.mark_synced(id).await.unwrap(); let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap(); assert_eq!(removed, 0); assert_eq!(store.list().await.len(), 1); } /// When every local marker is present in the snapshot, reconciliation /// is a pure no-op. Important negative test — prevents accidental /// over-deletion from a buggy predicate. #[tokio::test] async fn reconcile_is_noop_when_all_markers_present() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id_a = Uuid::new_v4(); let id_b = Uuid::new_v4(); store.create_local(id_a, t("2026-04-18T09:00:00Z")).await.unwrap(); store.create_local(id_b, t("2026-04-18T09:30:00Z")).await.unwrap(); store.mark_synced(id_a).await.unwrap(); store.mark_synced(id_b).await.unwrap(); let snap = snapshot_as_of( "2026-04-18T10:00:00Z", vec![ server_entry(id_a, Some("a"), "2026-04-18T09:00:00Z"), server_entry(id_b, Some("b"), "2026-04-18T09:30:00Z"), ], ); let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap(); assert_eq!(removed, 0); assert_eq!(store.list().await.len(), 2); } /// The window anchor is the server's `as_of`, not the local clock. /// A marker that would look "old" against `now` but sits inside the /// server's authoritative window must be reconciled correctly based /// on the server timestamp. #[tokio::test] async fn reconcile_uses_server_as_of_not_client_clock() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); // Marker last_activity_at 2026-04-18T09:00Z, synced. store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap(); store.mark_synced(id).await.unwrap(); // Server reports as_of 2026-04-18T10:00Z (1h after marker, // clearly inside 72h window) and does NOT list the case. let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]); assert_eq!( store.reconcile_with_server_snapshot(&snap).await.unwrap(), 1, "inside server window → must be removed" ); // Seed a second case for the symmetric assertion. let id2 = Uuid::new_v4(); store.create_local(id2, t("2026-04-18T09:00:00Z")).await.unwrap(); store.mark_synced(id2).await.unwrap(); // Now the server reports a much later as_of. The marker's // last_activity_at is further than 72h before as_of, so it's // *outside* the authoritative window → keep. let snap2 = snapshot_as_of("2026-04-30T10:00:00Z", vec![]); assert_eq!( store.reconcile_with_server_snapshot(&snap2).await.unwrap(), 0, "outside server window → must survive" ); } #[tokio::test] async fn mark_synced_flips_flag_without_touching_activity() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); assert!(!store.list().await[0].synced_to_server); store.mark_synced(id).await.unwrap(); let list = store.list().await; assert!(list[0].synced_to_server); assert_eq!(list[0].last_activity_at, "2026-04-18T10:00:00Z"); } #[tokio::test] async fn mark_synced_is_noop_when_marker_missing() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); // No marker at all — must not fail, must not create one. store.mark_synced(Uuid::new_v4()).await.unwrap(); assert!(store.list().await.is_empty()); } #[tokio::test] async fn mark_synced_is_idempotent() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let id = Uuid::new_v4(); store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap(); store.mark_synced(id).await.unwrap(); store.mark_synced(id).await.unwrap(); // second call: no disk churn assert!(store.list().await[0].synced_to_server); } #[tokio::test] async fn sorted_newest_first() { let tmp = TempDir::new().unwrap(); let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); let older = Uuid::new_v4(); let newer = Uuid::new_v4(); store.create_local(older, t("2026-04-18T08:00:00Z")).await.unwrap(); store.create_local(newer, t("2026-04-18T10:00:00Z")).await.unwrap(); let list = store.list().await; assert_eq!(list[0].case_id, newer); assert_eq!(list[1].case_id, older); } }