refactor(tests): migrate remaining batches to tests/common/
Finishes the lift of shared helpers into `tests/common/`. Covered: - health, web, oneliners_api, upload (31 tests; upload exercises the new `multipart_upload_body` helper driven by doctate_common field constants) - sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled, silent_case_empty, failed_only_case_empty_oneliner, transient_failure_retries (24 tests) Also applied cargo fmt across the test tree and fixed one clippy needless_borrows_for_generic_args warning in analyze_test. All 387 tests pass; 3 ignored (as before). Side effect: health_test previously used a hardcoded `/tmp/doctate-test` data path, which parallel `cargo test` runs could collide on. The migration replaces it with the common unique-tmpdir pattern, removing a latent flake.
This commit is contained in:
+64
-233
@@ -1,66 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use doctate_common::UPLOAD_PATH;
|
||||
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,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
})
|
||||
use common::{
|
||||
TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str,
|
||||
multipart_upload_body, test_user,
|
||||
};
|
||||
|
||||
const TEST_KEY: &str = "key-dr_test";
|
||||
|
||||
fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
|
||||
TestConfig::new()
|
||||
.with_label("upload")
|
||||
.with_user(test_user("dr_test"))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Wrap an `(content_type, body)` pair into the actual HTTP upload request.
|
||||
/// Keeps the multipart boundary and the audio-upload path in sync for
|
||||
/// every test in this file.
|
||||
fn upload_request(content_type: &str, body: Vec<u8>, api_key: Option<&str>) -> Request<Body> {
|
||||
let mut b = Request::builder()
|
||||
.method("POST")
|
||||
.uri(UPLOAD_PATH)
|
||||
.header("Content-Type", content_type);
|
||||
if let Some(k) = api_key {
|
||||
b = b.header("X-API-Key", k);
|
||||
}
|
||||
b.body(Body::from(body)).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -70,31 +42,17 @@ async fn upload_creates_new_case() {
|
||||
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 (content_type, body) =
|
||||
multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.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();
|
||||
|
||||
let json = body_json(response).await;
|
||||
assert_eq!(json["case_id"], case_id);
|
||||
assert_eq!(json["status"], "received");
|
||||
|
||||
@@ -104,31 +62,17 @@ async fn upload_creates_new_case() {
|
||||
.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 app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
||||
let (content_type, body) =
|
||||
multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -137,27 +81,16 @@ async fn upload_invalid_case_id_returns_400() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_without_auth_returns_401() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
let (content_type, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, None))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -166,28 +99,16 @@ async fn upload_without_auth_returns_401() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_empty_audio_returns_400() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
let (content_type, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -203,40 +124,18 @@ async fn upload_second_recording_same_case() {
|
||||
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 (ct, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.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 (ct, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -245,9 +144,6 @@ async fn upload_second_recording_same_case() {
|
||||
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
|
||||
@@ -262,21 +158,10 @@ async fn upload_reopens_closed_case() {
|
||||
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 (ct, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -297,40 +182,20 @@ async fn upload_reopens_closed_case() {
|
||||
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(),
|
||||
)
|
||||
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
|
||||
.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")
|
||||
let t = header_str(&resp, "etag");
|
||||
assert!(!t.is_empty(), "server must set ETag");
|
||||
t
|
||||
};
|
||||
|
||||
// Second upload — must auto-reopen.
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
|
||||
let (ct, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -348,15 +213,11 @@ async fn upload_reopens_closed_case() {
|
||||
// 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(),
|
||||
)
|
||||
.oneshot(get_with_api_key_if_none_match(
|
||||
"/api/oneliners",
|
||||
TEST_KEY,
|
||||
&etag_before,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -364,15 +225,9 @@ async fn upload_reopens_closed_case() {
|
||||
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");
|
||||
let etag_after = header_str(&resp, "etag");
|
||||
assert!(!etag_after.is_empty(), "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
|
||||
@@ -390,21 +245,10 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
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 (ct, body) = multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -415,21 +259,10 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
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 (ct, body) =
|
||||
multipart_upload_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(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -438,6 +271,4 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
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