3218fc62bd
The /api/oneliners time window is now read per-user from users.toml (window_hours, default 72h). Clients no longer carry a window: client.toml oneliner_window_hours, SyncConfig.window_hours, the ?hours=N query param, and the render_case_list cutoff filter are gone. ETag suffix keeps the effective hours so an admin edit to users.toml invalidates client caches on the next request. OnelinersResponse.window_hours stays in the wire format, but now exists solely to anchor client reconciliation.
443 lines
14 KiB
Rust
443 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::{CLOSE_MARKER, CloseMarker, write_close_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(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
}],
|
|
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 *closed* case must silently remove
|
|
/// the `.closed` 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_closed_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);
|
|
|
|
// Close the case by writing the marker directly — simulates the
|
|
// user hitting the close 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 = CloseMarker {
|
|
closed_at: "2026-04-13T11:00:00Z".into(),
|
|
};
|
|
write_close_marker(&case_dir, &marker).await.unwrap();
|
|
assert!(case_dir.join(CLOSE_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(CLOSE_MARKER).exists(),
|
|
".closed 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);
|
|
}
|