Files
doctate/doctate-client-core/src/case_store.rs
T
Brummel a1ff410d1b Refactor ETag generation for oneliners API
Remove the per-user oneliner watermark, which was previously used to
generate ETags for the `/api/oneliners` endpoint. The watermark was
intended to track the last successful oneliner write for each user.

The ETag generation has been refactored to use a deterministic FNV-1a
hash. This hash is computed over a sorted list of tuples containing
`(case_id, last_recording_at_ns, oneliner_mtime_ns)` for all visible
cases. This approach ensures that the ETag changes whenever any
listen-relevant file system modification occurs, such as a new
recording, oneliner regeneration, soft-delete, undo, or
upload-auto-reopen. This new method is more robust and avoids the
complexity of managing per-user state.

The `OnelinerWatermark` type alias and its associated logic have been
removed from `AppState` and the relevant modules (`transcribe::worker`,
`transcribe::recovery`, `routes::oneliners`, `main`). New integration
tests have been added to verify that various delete operations (single
delete, undo delete, bulk delete) correctly trigger an ETag update,
ensuring cache invalidation.
2026-04-19 14:47:30 +02:00

931 lines
37 KiB
Rust

//! 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<String>,
}
#[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<Vec<CaseMarker>>;
/// Shared case store. Cheap to `clone()`; clones share the same state.
#[derive(Clone)]
pub struct CaseStore {
dir: PathBuf,
state: Arc<Mutex<HashMap<Uuid, CaseMarker>>>,
tx: watch::Sender<Snapshot>,
}
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<Snapshot>), 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<Uuid, CaseMarker> =
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<CaseMarker, CaseStoreError> {
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<usize, CaseStoreError> {
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<Uuid> = 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<Uuid> = 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<usize, CaseStoreError> {
let cutoff_str = format_utc(cutoff)?;
let mut state = self.state.lock().await;
let to_remove: Vec<Uuid> = 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<CaseMarker> {
let state = self.state.lock().await;
sorted_vec(&state)
}
fn broadcast(&self, state: &HashMap<Uuid, CaseMarker>) {
let snapshot = sorted_snapshot(state);
// receiver-count=0 is fine (nobody listening); ignore.
let _ = self.tx.send(snapshot);
}
}
fn format_utc(at: OffsetDateTime) -> Result<String, time::error::Format> {
at.to_offset(time::UtcOffset::UTC).format(&Rfc3339)
}
fn sorted_snapshot(state: &HashMap<Uuid, CaseMarker>) -> Snapshot {
Arc::new(sorted_vec(state))
}
fn sorted_vec(state: &HashMap<Uuid, CaseMarker>) -> Vec<CaseMarker> {
let mut v: Vec<CaseMarker> = 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<Vec<CaseMarker>, 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<CaseMarker, CaseStoreError> {
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<OnelinerEntry>) -> 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<Uuid> = 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<OnelinerEntry>) -> 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);
}
}