5e8d2f8e58
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.
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
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!(
|
|
"doctate-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
Arc::new(Config {
|
|
data_path,
|
|
users: vec![User {
|
|
slug: "dr_test".into(),
|
|
api_key: "test-key-123".into(),
|
|
web_password: "unused".into(),
|
|
role: "doctor".into(),
|
|
whisper: Default::default(),
|
|
}],
|
|
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
/// Build a multipart body with the given fields.
|
|
fn multipart_body(
|
|
case_id: &str,
|
|
recorded_at: &str,
|
|
audio: &[u8],
|
|
) -> (String, Vec<u8>) {
|
|
let boundary = "----testboundary";
|
|
let mut body = Vec::new();
|
|
|
|
// case_id field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
|
|
body.extend_from_slice(case_id.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// recorded_at field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
|
|
body.extend_from_slice(recorded_at.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// audio field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
|
|
);
|
|
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
|
|
body.extend_from_slice(audio);
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// End boundary
|
|
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
|
|
|
(boundary.to_owned(), body)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_creates_new_case() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
|
|
|
|
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);
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
assert_eq!(json["case_id"], case_id);
|
|
assert_eq!(json["status"], "received");
|
|
|
|
// Verify file on disk
|
|
let file = data_path
|
|
.join("dr_test")
|
|
.join(case_id)
|
|
.join("2026-04-13T10-30-00Z.m4a");
|
|
assert!(file.exists(), "Audio file should exist at {file:?}");
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_invalid_case_id_returns_400() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
|
|
|
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::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_without_auth_returns_401() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body(
|
|
"550e8400-e29b-41d4-a716-446655440000",
|
|
"2026-04-13T10:30:00Z",
|
|
b"audio",
|
|
);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_empty_audio_returns_400() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body(
|
|
"550e8400-e29b-41d4-a716-446655440000",
|
|
"2026-04-13T10:30:00Z",
|
|
b"", // empty audio
|
|
);
|
|
|
|
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::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_second_recording_same_case() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
|
|
|
// First upload
|
|
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);
|
|
|
|
// Second upload, same case, different timestamp
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second 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);
|
|
|
|
// Both files should exist
|
|
let case_dir = data_path.join("dr_test").join(case_id);
|
|
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
|
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
|
|
|
// Cleanup
|
|
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);
|
|
}
|
|
|