a1ff410d1b
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.
446 lines
14 KiB
Rust
446 lines
14 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. Direct write
|
|
// also means the watermark is NOT bumped here; that way the next
|
|
// GET /api/oneliners pins down the pre-reopen ETag cleanly.
|
|
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());
|
|
|
|
// Capture the ETag before the reopen.
|
|
let etag_before = {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/oneliners")
|
|
.header("X-API-Key", "test-key-123")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
resp.headers()
|
|
.get("etag")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_owned)
|
|
.expect("server must set ETag")
|
|
};
|
|
|
|
// Second upload — must auto-reopen.
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen 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);
|
|
|
|
// 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());
|
|
|
|
// Auto-reopen must bump the watermark: sending the pre-reopen
|
|
// If-None-Match must return 200 (not 304) so clients who were
|
|
// offline during the reopen notice it on their next poll.
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/oneliners")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header("If-None-Match", &etag_before)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"auto-reopen must invalidate the pre-reopen ETag"
|
|
);
|
|
let etag_after = resp
|
|
.headers()
|
|
.get("etag")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_owned)
|
|
.expect("server must set ETag");
|
|
assert_ne!(etag_after, etag_before);
|
|
|
|
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);
|
|
}
|
|
|