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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user