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
+140
View File
@@ -6,6 +6,7 @@ use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
use doctate_server::paths::{write_delete_marker, DeleteMarker, DELETE_MARKER};
fn test_config() -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
@@ -250,3 +251,142 @@ async fn upload_second_recording_same_case() {
let _ = std::fs::remove_dir_all(&data_path);
}
/// Scenario A: a new upload to a *soft-deleted* case must silently
/// remove the `.deleted` marker and store the new audio. Reopens the
/// case so it reappears in /api/oneliners without any manual "undo".
#[tokio::test]
async fn upload_reopens_soft_deleted_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "770e8400-e29b-41d4-a716-446655440000";
// First upload — creates the case directory.
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Soft-delete the case by writing the marker directly — simulates
// the user hitting the delete button in the web UI without having
// to drive the /web/cases/{id}/delete handler here.
let case_dir = data_path.join("dr_test").join(case_id);
let marker = DeleteMarker {
batch: uuid::Uuid::new_v4(),
deleted_at: "2026-04-13T11:00:00Z".into(),
};
write_delete_marker(&case_dir, &marker).await.unwrap();
assert!(case_dir.join(DELETE_MARKER).exists());
// Second upload — must auto-reopen.
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Marker is gone → case is visible again.
assert!(
!case_dir.join(DELETE_MARKER).exists(),
".deleted marker must be removed after re-upload"
);
// Both audios are on disk — the old one was not touched.
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
let _ = std::fs::remove_dir_all(&data_path);
}
/// Scenario B: a new upload to a case whose server-side directory has
/// been *hard-deleted* (e.g. by a future retention sweep or an
/// out-of-band cleanup) must transparently re-create the directory and
/// accept the audio. This guards the forward-looking scenario where the
/// client was offline long enough that the server has already purged
/// the case.
#[tokio::test]
async fn upload_resurrects_hard_deleted_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
let case_id = "880e8400-e29b-41d4-a716-446655440000";
// First upload.
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"original recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Wipe the case directory entirely — no marker, no audio, nothing.
let case_dir = data_path.join("dr_test").join(case_id);
std::fs::remove_dir_all(&case_dir).unwrap();
assert!(!case_dir.exists());
// Second upload with the same client-generated UUID.
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Directory is back, new audio is inside, old audio stays gone.
assert!(case_dir.exists());
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
let _ = std::fs::remove_dir_all(&data_path);
}