Refactor case store to support deletions

Introduce `reconcile_with_server_snapshot` to the `CaseStore`. This
function
removes local markers that are synced to the server and within the
server's reported window but are no longer present in the server's
snapshot. This ensures that cases deleted server-side are also removed
locally, fixing an issue where deleted cases would still appear on the
client.

This change also modifies the `poll_once` function in `server_sync`
to call both `merge_server_snapshot` and the new
`reconcile_with_server_snapshot` after a successful poll.

Additionally, a test case `poll_once_reconciles_missing_synced_marker`
has been added to verify the correct behavior of the reconciliation
process.

The server-side upload handler is updated to automatically reopen
soft-deleted cases if a new upload is received for them. This ensures
that soft-deleted cases reappear in the API and UI without manual
intervention. Two new tests, `upload_reopens_soft_deleted_case` and
`upload_resurrects_hard_deleted_case`, are added to cover the
soft-delete
reopening and hard-delete resurrection scenarios, respectively.
This commit is contained in:
2026-04-19 13:43:26 +02:00
parent 5d308df2b5
commit 5e8d2f8e58
4 changed files with 425 additions and 7 deletions
+60
View File
@@ -428,7 +428,13 @@ pub(crate) async fn poll_once(
StatusCode::OK => {
let new_etag = extract_etag(&resp);
let body: OnelinersResponse = resp.json().await?;
// Merge adds/updates markers the server still knows about;
// reconcile removes the ones it no longer does (within the
// window reported by `as_of` / `window_hours`). Running both
// after every successful poll keeps the client UI in step
// with server-side deletions without any extra round-trips.
store.merge_server_snapshot(&body).await?;
store.reconcile_with_server_snapshot(&body).await?;
if let Some(tag) = new_etag.clone() {
let cached = CachedSnapshot {
@@ -484,6 +490,7 @@ mod tests {
use crate::upload::write_sidecar;
use doctate_common::oneliners::OnelinerEntry;
use tempfile::TempDir;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use wiremock::matchers::{header, method, path as wpath};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -597,6 +604,59 @@ mod tests {
));
}
/// End-to-end reconcile: a synced marker that the server omits from
/// its next snapshot must vanish locally after a single poll. This
/// is the scenario the user reported ("deleted cases still show up
/// on the client") — the wiring is: `poll_once` → `merge` → `reconcile`.
#[tokio::test]
async fn poll_once_reconciles_missing_synced_marker() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wpath(ONELINERS_PATH))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", TEST_ETAG)
// Authoritative snapshot with zero entries — any
// synced local marker inside the window is deleted
// server-side.
.set_body_json(OnelinersResponse {
as_of: "2026-04-18T10:00:00Z".into(),
window_hours: 72,
oneliners: vec![],
}),
)
.mount(&server)
.await;
let tmp = TempDir::new().unwrap();
let (store, cache) = make_store_and_cache(&tmp).await;
// Seed a synced marker inside the window.
let stale_id = Uuid::new_v4();
store
.create_local(
stale_id,
time::OffsetDateTime::parse("2026-04-18T09:00:00Z", &Rfc3339).unwrap(),
)
.await
.unwrap();
store.mark_synced(stale_id).await.unwrap();
assert_eq!(store.list().await.len(), 1);
// Poll once — the merge produces no add/update, reconcile drops
// the stale marker.
let http = reqwest::Client::new();
let mut etag: Option<String> = None;
let outcome = poll_once(&http, &cfg(&server), &store, &cache, &mut etag)
.await
.unwrap();
assert_eq!(outcome, PollOutcome::Success);
assert!(
store.list().await.is_empty(),
"reconcile must remove the synced marker the server no longer lists"
);
}
#[tokio::test]
async fn poll_once_200_merges_and_caches() {
let server = MockServer::start().await;