diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index afe7d4c..87de820 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -781,7 +781,7 @@ async fn closed_case_detail_with_show_closed_returns_200() { // With ?show_closed=1 → 200 with the reopen form. let body = common::body_string( app.oneshot(get_with_cookie( - &format!("{}?show_closed=1", paths::case_detail(case_id)), + format!("{}?show_closed=1", paths::case_detail(case_id)), &cookie, )) .await @@ -1252,7 +1252,12 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { let body = format!("action=reset&case_id={case_a}&case_id={case_b}"); let resp = app .clone() - .oneshot(csrf_form_post(paths::BULK, &cookie_admin, &csrf_admin, &body)) + .oneshot(csrf_form_post( + paths::BULK, + &cookie_admin, + &csrf_admin, + &body, + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs index 5258a14..a56ed78 100644 --- a/server/tests/auth_test.rs +++ b/server/tests/auth_test.rs @@ -8,9 +8,8 @@ use common::{TestConfig, test_user}; #[tokio::test] async fn valid_api_key_returns_user() { - let app = doctate_server::create_router( - TestConfig::new().with_user(test_user("dr_test")).build(), - ); + let app = + doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build()); let response = app .oneshot( @@ -32,9 +31,8 @@ async fn valid_api_key_returns_user() { #[tokio::test] async fn invalid_api_key_returns_401() { - let app = doctate_server::create_router( - TestConfig::new().with_user(test_user("dr_test")).build(), - ); + let app = + doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build()); let response = app .oneshot( @@ -52,9 +50,8 @@ async fn invalid_api_key_returns_401() { #[tokio::test] async fn missing_api_key_returns_401() { - let app = doctate_server::create_router( - TestConfig::new().with_user(test_user("dr_test")).build(), - ); + let app = + doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build()); let response = app .oneshot( diff --git a/server/tests/common/config.rs b/server/tests/common/config.rs index c3d55a5..c1c9277 100644 --- a/server/tests/common/config.rs +++ b/server/tests/common/config.rs @@ -129,9 +129,7 @@ impl TestConfig { /// `Config::test_default` themselves are broken (not reachable in /// practice). pub fn build(self) -> Arc { - let data_path = self - .data_path - .unwrap_or_else(|| unique_tmpdir(self.label)); + let data_path = self.data_path.unwrap_or_else(|| unique_tmpdir(self.label)); let api_keys: HashMap = self .users .iter() diff --git a/server/tests/common/http.rs b/server/tests/common/http.rs index 9712416..a90f285 100644 --- a/server/tests/common/http.rs +++ b/server/tests/common/http.rs @@ -121,11 +121,7 @@ pub fn header_str(response: &Response, name: &str) -> String { /// Uses the server's canonical field names (`case_id`, `recorded_at`, /// `audio`) from `doctate_common::constants`, so a server-side rename /// propagates here for free. -pub fn multipart_upload_body( - case_id: &str, - recorded_at: &str, - audio: &[u8], -) -> (String, Vec) { +pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec) { let boundary = "----testboundary"; let mut body = Vec::new(); diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index fb5dbcd..6fc7959 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -26,7 +26,7 @@ pub mod users; // Re-exports for ergonomic `use common::*;` in most test files. pub use config::{TestConfig, unique_tmpdir}; pub use http::{ - body_json, body_string, csrf_form_post, form_post, get_with_api_key, + body_bytes, body_json, body_string, csrf_form_post, form_post, get_with_api_key, get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body, }; pub use seed::{ diff --git a/server/tests/common/seed.rs b/server/tests/common/seed.rs index dde0256..cbd9bb2 100644 --- a/server/tests/common/seed.rs +++ b/server/tests/common/seed.rs @@ -40,11 +40,7 @@ pub fn seed_recording(case_dir: &Path, stem: &str, transcript: Option<&str>) -> /// Like [`seed_recording`] but additionally writes the `.duration.txt` /// sidecar — used by the delete-recording tests that assert every /// sidecar is cleaned up. -pub fn seed_recording_with_sidecars( - case_dir: &Path, - stem: &str, - transcript: &str, -) -> String { +pub fn seed_recording_with_sidecars(case_dir: &Path, stem: &str, transcript: &str) -> String { let filename = seed_recording(case_dir, stem, Some(transcript)); std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap(); filename diff --git a/server/tests/common/users.rs b/server/tests/common/users.rs index d714584..86588d0 100644 --- a/server/tests/common/users.rs +++ b/server/tests/common/users.rs @@ -58,11 +58,7 @@ pub fn test_user_with_password(slug: &str, password: &str) -> User { /// Doctor user with custom retention settings — for the retention sweep /// tests that drive the lazy auto-close / auto-purge logic. -pub fn test_user_with_retention( - slug: &str, - auto_close_days: u32, - auto_delete_days: u32, -) -> User { +pub fn test_user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) -> User { let mut u = test_user(slug); u.retention = RetentionUserSettings { auto_close_days, diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index 00a6a94..76e93a2 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -292,7 +292,11 @@ async fn bulk_with_injection_payload_returns_403_not_500() { // the browser flow breaks and one of these fires. async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String { - let resp = app.clone().oneshot(get_with_cookie(uri, cookie)).await.unwrap(); + let resp = app + .clone() + .oneshot(get_with_cookie(uri, cookie)) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed"); common::body_string(resp).await } diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index 76f1994..3248f4b 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -27,10 +27,8 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - let victim = - seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me"); - let survivor = - seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me"); + let victim = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me"); + let survivor = seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me"); // Derived artefacts the delete must also purge. std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); diff --git a/server/tests/failed_only_case_empty_oneliner_test.rs b/server/tests/failed_only_case_empty_oneliner_test.rs index d3d9f8e..5fadef4 100644 --- a/server/tests/failed_only_case_empty_oneliner_test.rs +++ b/server/tests/failed_only_case_empty_oneliner_test.rs @@ -14,12 +14,13 @@ //! case to `Empty` through the same terminal branch used for silent-only //! cases. +mod common; + use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use doctate_common::oneliners::OnelinerState; -use doctate_server::config::Config; use doctate_server::events; use doctate_server::gazetteer::Gazetteer; use doctate_server::{ @@ -29,6 +30,8 @@ use tempfile::tempdir; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::TestConfig; + #[tokio::test] async fn failed_only_case_settles_to_empty_without_llm_call() { let data = tempdir().unwrap(); @@ -50,10 +53,10 @@ async fn failed_only_case_settles_to_empty_without_llm_call() { .mount(&mock) .await; - let mut cfg = Config::test_default(); - cfg.data_path = data.path().to_path_buf(); - cfg.ollama_url = mock.uri(); - let config = Arc::new(cfg); + let config = TestConfig::new() + .with_data_path(data.path().to_path_buf()) + .with_ollama(mock.uri()) + .build(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); diff --git a/server/tests/health_test.rs b/server/tests/health_test.rs index abf0a87..b76477f 100644 --- a/server/tests/health_test.rs +++ b/server/tests/health_test.rs @@ -1,22 +1,14 @@ -use std::sync::Arc; +mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; -use doctate_server::config::Config; - -fn test_config() -> Arc { - Arc::new(Config { - data_path: "/tmp/doctate-test".into(), - log_path: "/tmp/doctate-test/logs".into(), - ..Config::test_default() - }) -} +use common::TestConfig; #[tokio::test] async fn health_returns_ok() { - let app = doctate_server::create_router(test_config()); + let app = doctate_server::create_router(TestConfig::new().with_label("health").build()); let response = app .oneshot( @@ -33,7 +25,7 @@ async fn health_returns_ok() { #[tokio::test] async fn health_returns_json_with_status() { - let app = doctate_server::create_router(test_config()); + let app = doctate_server::create_router(TestConfig::new().with_label("health").build()); let response = app .oneshot( @@ -45,11 +37,7 @@ async fn health_returns_json_with_status() { .await .unwrap(); - 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 = common::body_json(response).await; assert_eq!(json["status"], "ok"); assert_eq!(json["port"], 3000); } diff --git a/server/tests/oneliner_heal_decoupled_test.rs b/server/tests/oneliner_heal_decoupled_test.rs index f8c541b..72d081c 100644 --- a/server/tests/oneliner_heal_decoupled_test.rs +++ b/server/tests/oneliner_heal_decoupled_test.rs @@ -5,12 +5,13 @@ //! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on //! `OnelinerHealBusy` to dedup concurrent page-loads. +mod common; + use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use doctate_server::config::Config; use doctate_server::events; use doctate_server::gazetteer::Gazetteer; use doctate_server::{ @@ -22,6 +23,8 @@ use tokio::time::timeout; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::TestConfig; + fn write_transcript(case_dir: &Path) { // Seed an m4a + transcript pair, matching the worker's on-disk // layout. The oneliner worker iterates `.m4a` files and resolves @@ -68,10 +71,10 @@ async fn heal_orphans_returns_fast_and_dedups_parallel_calls() { .mount(&mock) .await; - let mut cfg = Config::test_default(); - cfg.data_path = data.path().to_path_buf(); - cfg.ollama_url = mock.uri(); - let config = Arc::new(cfg); + let config = TestConfig::new() + .with_data_path(data.path().to_path_buf()) + .with_ollama(mock.uri()) + .build(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 92c4f17..17aba48 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -1,131 +1,62 @@ -use std::collections::HashMap; +mod common; + use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::{Duration, SystemTime}; -use axum::body::Body; -use axum::http::{Request, StatusCode}; +use axum::http::StatusCode; +use doctate_common::oneliners::OnelinerState; use filetime::FileTime; use tower::util::ServiceExt; -use doctate_common::oneliners::OnelinerState; -use doctate_server::config::{Config, User}; +use common::{ + TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str, + seed_oneliner_ready, seed_oneliner_state, seed_recording_with_age, test_user, + test_user_with_window_hours, +}; -const TEST_KEY: &str = "test-key-oneliners"; const TEST_SLUG: &str = "dr_oneliners"; +const TEST_KEY: &str = "key-dr_oneliners"; -fn test_config() -> (Arc, PathBuf) { - let data_path = std::env::temp_dir().join(format!( - "doctate-oneliners-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - let config = Arc::new(Config { - data_path: data_path.clone(), - users: vec![User { - slug: TEST_SLUG.into(), - api_key: TEST_KEY.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.into(), TEST_SLUG.into())]), - ..Config::test_default() - }); - (config, data_path) +fn test_config() -> (std::sync::Arc, PathBuf) { + let cfg = TestConfig::new() + .with_label("oneliners") + .with_user(test_user(TEST_SLUG)) + .build(); + let dp = cfg.data_path.clone(); + (cfg, dp) } -fn test_config_with_window_hours(hours: u32) -> (Arc, PathBuf) { - let (cfg, dp) = test_config(); - // Safe because `test_config` just created the Arc and handed it - // back with refcount == 1. - let mut cfg = - Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique")); - cfg.users[0].window_hours = hours; - (Arc::new(cfg), dp) +fn test_config_with_window_hours( + hours: u32, +) -> (std::sync::Arc, PathBuf) { + let cfg = TestConfig::new() + .with_label("oneliners-win") + .with_user(test_user_with_window_hours(TEST_SLUG, hours)) + .build(); + let dp = cfg.data_path.clone(); + (cfg, dp) } -async fn seed_case( +/// Seed a case with an aged m4a and optionally a ready oneliner. +/// Combines the common primitives — a single call is more ergonomic +/// here than three repeated triples across every test. +fn seed( user_root: &Path, case_id: &str, m4a_age: Duration, oneliner_text: Option<&str>, ) -> PathBuf { let case_dir = user_root.join(TEST_SLUG).join(case_id); - tokio::fs::create_dir_all(&case_dir).await.unwrap(); - - // Write a dummy .m4a and rewind its mtime to simulate "case born N ago". - let m4a = case_dir.join("2026-04-18T10-00-00Z.m4a"); - tokio::fs::write(&m4a, b"x").await.unwrap(); - let target_mtime = SystemTime::now() - m4a_age; - filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap(); - + std::fs::create_dir_all(&case_dir).unwrap(); + seed_recording_with_age(&case_dir, "2026-04-18T10-00-00Z", m4a_age); if let Some(text) = oneliner_text { - write_ready_state(&case_dir, text).await; + seed_oneliner_ready(&case_dir, text); } case_dir } -async fn write_ready_state(case_dir: &Path, text: &str) { - let state = OnelinerState::Ready { - text: text.to_owned(), - generated_at: "2026-04-18T10:00:00Z".into(), - }; - let bytes = serde_json::to_vec(&state).unwrap(); - tokio::fs::write(case_dir.join("oneliner.json"), bytes) - .await - .unwrap(); -} - -async fn write_state(case_dir: &Path, state: &OnelinerState) { - let bytes = serde_json::to_vec(state).unwrap(); - tokio::fs::write(case_dir.join("oneliner.json"), bytes) - .await - .unwrap(); -} - -async fn mark_deleted(case_dir: &Path) { - tokio::fs::write(case_dir.join(".closed"), "{}") - .await - .unwrap(); -} - -fn get(uri: &str) -> Request { - Request::builder() - .method("GET") - .uri(uri) - .header("X-API-Key", TEST_KEY) - .body(Body::empty()) - .unwrap() -} - -fn get_with_if_none_match(uri: &str, etag: &str) -> Request { - Request::builder() - .method("GET") - .uri(uri) - .header("X-API-Key", TEST_KEY) - .header("If-None-Match", etag) - .body(Body::empty()) - .unwrap() -} - -async fn body_json(response: axum::http::Response) -> serde_json::Value { - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() -} - -fn header_str(response: &axum::http::Response, name: &str) -> String { - response - .headers() - .get(name) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_owned() +fn mark_deleted(case_dir: &Path) { + std::fs::write(case_dir.join(".closed"), "{}").unwrap(); } #[tokio::test] @@ -135,10 +66,10 @@ async fn returns_401_without_api_key() { let response = app .oneshot( - Request::builder() + axum::http::Request::builder() .method("GET") .uri("/api/oneliners") - .body(Body::empty()) + .body(axum::body::Body::empty()) .unwrap(), ) .await @@ -149,10 +80,13 @@ async fn returns_401_without_api_key() { #[tokio::test] async fn empty_user_dir_returns_200_empty_list() { - let (config, dp) = test_config(); + let (config, _dp) = test_config(); let app = doctate_server::create_router(config); - let response = app.oneshot(get("/api/oneliners")).await.unwrap(); + let response = app + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let etag = header_str(&response, "etag"); @@ -162,24 +96,24 @@ async fn empty_user_dir_returns_200_empty_list() { let body = body_json(response).await; assert_eq!(body["window_hours"], 72); assert!(body["oneliners"].as_array().unwrap().is_empty()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn recent_case_with_oneliner_shows_up() { let (config, dp) = test_config(); let case_id = "550e8400-e29b-41d4-a716-000000000001"; - seed_case( + seed( &dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus"), - ) - .await; + ); let app = doctate_server::create_router(config); - let response = app.oneshot(get("/api/oneliners")).await.unwrap(); + let response = app + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; let arr = body["oneliners"].as_array().unwrap(); @@ -189,46 +123,48 @@ async fn recent_case_with_oneliner_shows_up() { assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus"); assert!(arr[0]["created_at"].is_string()); assert!(arr[0]["updated_at"].is_string()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn case_without_oneliner_shows_null() { let (config, dp) = test_config(); - seed_case( + seed( &dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None, - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert!(arr[0]["oneliner"].is_null()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn case_older_than_default_window_excluded() { let (config, dp) = test_config(); - seed_case( + seed( &dp, "550e8400-e29b-41d4-a716-000000000003", Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default Some("old case"), - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; assert!(body["oneliners"].as_array().unwrap().is_empty()); - - let _ = std::fs::remove_dir_all(&dp); } /// Demonstrates that the visibility decision lives in users.toml: @@ -240,71 +176,88 @@ async fn case_visibility_follows_user_window_hours() { let age = Duration::from_secs(40 * 3600); let (config_small, dp_small) = test_config_with_window_hours(16); - seed_case(&dp_small, case_id, age, Some("40h case")).await; + seed(&dp_small, case_id, age, Some("40h case")); let app_small = doctate_server::create_router(config_small); - let body = body_json(app_small.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app_small + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; assert_eq!(body["window_hours"], 16); assert!(body["oneliners"].as_array().unwrap().is_empty()); let (config_wide, dp_wide) = test_config_with_window_hours(48); - seed_case(&dp_wide, case_id, age, Some("40h case")).await; + seed(&dp_wide, case_id, age, Some("40h case")); let app_wide = doctate_server::create_router(config_wide); - let body = body_json(app_wide.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app_wide + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; assert_eq!(body["window_hours"], 48); assert_eq!(body["oneliners"].as_array().unwrap().len(), 1); - - let _ = std::fs::remove_dir_all(&dp_small); - let _ = std::fs::remove_dir_all(&dp_wide); } /// Regression guard: a lingering `?hours=N` from an old client is /// silently ignored — the user-config value drives the response. #[tokio::test] async fn hours_query_param_is_ignored() { - let (config, dp) = test_config(); + let (config, _dp) = test_config(); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners?hours=999")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners?hours=999", TEST_KEY)) + .await + .unwrap(), + ) + .await; assert_eq!(body["window_hours"], 72); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn matching_if_none_match_returns_304() { - let (config, dp) = test_config(); + let (config, _dp) = test_config(); let app = doctate_server::create_router(config); - let first = app.clone().oneshot(get("/api/oneliners")).await.unwrap(); + let first = app + .clone() + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(); assert_eq!(first.status(), StatusCode::OK); let etag = header_str(&first, "etag"); assert!(!etag.is_empty()); let second = app - .oneshot(get_with_if_none_match("/api/oneliners", &etag)) + .oneshot(get_with_api_key_if_none_match( + "/api/oneliners", + TEST_KEY, + &etag, + )) .await .unwrap(); assert_eq!(second.status(), StatusCode::NOT_MODIFIED); assert_eq!(header_str(&second, "etag"), etag); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn stale_if_none_match_returns_200() { - let (config, dp) = test_config(); + let (config, _dp) = test_config(); let app = doctate_server::create_router(config); let response = app - .oneshot(get_with_if_none_match( + .oneshot(get_with_api_key_if_none_match( "/api/oneliners", + TEST_KEY, "W/\"99999999-72\"", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - - let _ = std::fs::remove_dir_all(&dp); } /// Two users with different `window_hours` must produce different @@ -312,70 +265,76 @@ async fn stale_if_none_match_returns_200() { /// users.toml invalidates client caches after the next restart. #[tokio::test] async fn etag_differs_between_users_with_different_window_hours() { - let (config_a, dp_a) = test_config_with_window_hours(16); - let (config_b, dp_b) = test_config_with_window_hours(48); + let (config_a, _dp_a) = test_config_with_window_hours(16); + let (config_b, _dp_b) = test_config_with_window_hours(48); let app_a = doctate_server::create_router(config_a); let app_b = doctate_server::create_router(config_b); - let r_a = app_a.oneshot(get("/api/oneliners")).await.unwrap(); - let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap(); + let r_a = app_a + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(); + let r_b = app_b + .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(); let etag_a = header_str(&r_a, "etag"); let etag_b = header_str(&r_b, "etag"); assert_ne!(etag_a, etag_b); assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}"); assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}"); - - let _ = std::fs::remove_dir_all(&dp_a); - let _ = std::fs::remove_dir_all(&dp_b); } #[tokio::test] async fn deleted_case_is_excluded() { let (config, dp) = test_config(); - let case_dir = seed_case( + let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000005", Duration::from_secs(60), Some("deleted case"), - ) - .await; - mark_deleted(&case_dir).await; + ); + mark_deleted(&case_dir); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; assert!(body["oneliners"].as_array().unwrap().is_empty()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn multiple_cases_sorted_newest_first() { let (config, dp) = test_config(); - seed_case( + seed( &dp, "550e8400-e29b-41d4-a716-000000000010", Duration::from_secs(2 * 3600), Some("older"), - ) - .await; - seed_case( + ); + seed( &dp, "550e8400-e29b-41d4-a716-000000000011", Duration::from_secs(30), Some("newer"), - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["oneliner"]["text"], "newer"); assert_eq!(arr[1]["oneliner"]["text"], "older"); - - let _ = std::fs::remove_dir_all(&dp); } /// Regression guard: if a case was first created long ago but got a @@ -390,94 +349,98 @@ async fn sorts_by_last_recording_not_created() { // (new last_recording_at). let case_a = "550e8400-e29b-41d4-a716-000000000020"; let dir_a = dp.join(TEST_SLUG).join(case_a); - tokio::fs::create_dir_all(&dir_a).await.unwrap(); + std::fs::create_dir_all(&dir_a).unwrap(); let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a"); - tokio::fs::write(&old_m4a, b"x").await.unwrap(); + std::fs::write(&old_m4a, b"x").unwrap(); filetime::set_file_mtime( &old_m4a, FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)), ) .unwrap(); let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a"); - tokio::fs::write(&fresh_m4a, b"x").await.unwrap(); + std::fs::write(&fresh_m4a, b"x").unwrap(); filetime::set_file_mtime( &fresh_m4a, FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)), ) .unwrap(); - write_ready_state(&dir_a, "A (fresh addendum)").await; + seed_oneliner_ready(&dir_a, "A (fresh addendum)"); // Case B: single recording 10h ago — created_at newer than A's, but // last_recording_at older than A's fresh addendum. - seed_case( + seed( &dp, "550e8400-e29b-41d4-a716-000000000021", Duration::from_secs(10 * 3600), Some("B (single, middle-aged)"), - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["case_id"], case_a, "A must sort first"); assert!(arr[0]["last_recording_at"].is_string()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn empty_state_serializes_as_kind_empty() { let (config, dp) = test_config(); - let case_dir = seed_case( + let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000030", Duration::from_secs(60), None, - ) - .await; - write_state( + ); + seed_oneliner_state( &case_dir, &OnelinerState::Empty { generated_at: "2026-04-18T10:00:00Z".into(), }, - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["oneliner"]["kind"], "empty"); assert!(arr[0]["oneliner"].get("text").is_none()); - - let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn error_state_serializes_as_kind_error() { let (config, dp) = test_config(); - let case_dir = seed_case( + let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000031", Duration::from_secs(60), None, - ) - .await; - write_state( + ); + seed_oneliner_state( &case_dir, &OnelinerState::Error { generated_at: "2026-04-18T10:00:00Z".into(), }, - ) - .await; + ); let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; + let body = body_json( + app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) + .await + .unwrap(), + ) + .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["oneliner"]["kind"], "error"); - - let _ = std::fs::remove_dir_all(&dp); } diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index 2fc108b..adad96a 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -26,7 +26,11 @@ async fn trigger_sweep(app: &axum::Router, cookie: &str) { assert_eq!(resp.status(), StatusCode::OK); } -fn build_cfg(label: &'static str, auto_close_days: u32, auto_delete_days: u32) -> std::sync::Arc { +fn build_cfg( + label: &'static str, + auto_close_days: u32, + auto_delete_days: u32, +) -> std::sync::Arc { TestConfig::new() .with_label(label) .with_user(test_user_with_retention( @@ -93,11 +97,7 @@ async fn auto_close_respects_newest_recording() { "2026-04-01T10-00-00Z", Duration::from_secs(30 * 86400), ); - seed_recording_with_age( - &case_dir, - "2026-04-01T11-00-00Z", - Duration::from_secs(60), - ); + seed_recording_with_age(&case_dir, "2026-04-01T11-00-00Z", Duration::from_secs(60)); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_a", "s").await; @@ -114,16 +114,9 @@ async fn auto_purge_removes_old_closed_case() { let cfg = build_cfg("purge-old", 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - seed_recording_with_age( - &case_dir, - "2026-04-01T10-00-00Z", - Duration::from_secs(60), - ); + seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60)); // closed_at = 31 days ago → must be purged. - write_closed_marker( - &case_dir, - &past_rfc3339(Duration::from_secs(31 * 86400)), - ); + write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(31 * 86400))); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_a", "s").await; @@ -142,15 +135,8 @@ async fn auto_purge_disabled_when_days_zero() { let cfg = build_cfg("purge-zero", 0, 0); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - seed_recording_with_age( - &case_dir, - "2026-04-01T10-00-00Z", - Duration::from_secs(60), - ); - write_closed_marker( - &case_dir, - &past_rfc3339(Duration::from_secs(365 * 86400)), - ); + seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60)); + write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400))); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_a", "s").await; @@ -198,11 +184,7 @@ async fn auto_purge_skips_open_cases() { let cfg = build_cfg("purge-skip-open", 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - seed_recording_with_age( - &case_dir, - "2026-04-01T10-00-00Z", - Duration::from_secs(60), - ); + seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60)); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_a", "s").await; diff --git a/server/tests/security_headers_test.rs b/server/tests/security_headers_test.rs index 9d88883..d0ebce1 100644 --- a/server/tests/security_headers_test.rs +++ b/server/tests/security_headers_test.rs @@ -26,14 +26,9 @@ fn count_header_values(resp: &Response, name: &str) -> usize { } async fn get(app: axum::Router, uri: &str) -> Response { - app.oneshot( - Request::builder() - .uri(uri) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap() + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() } // ---------- presence tests ---------- @@ -42,10 +37,7 @@ async fn get(app: axum::Router, uri: &str) -> Response { async fn api_health_has_all_security_headers() { let resp = get(test_app(), "/api/health").await; assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - header_opt(&resp, "x-content-type-options"), - Some("nosniff") - ); + assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff")); assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer")); assert!( @@ -123,10 +115,7 @@ async fn csp_blocks_form_action_hijack() { #[tokio::test] async fn nosniff_blocks_mime_confusion() { let resp = get(test_app(), "/api/health").await; - assert_eq!( - header_opt(&resp, "x-content-type-options"), - Some("nosniff") - ); + assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff")); } /// Session-carrying URLs should not leak to third parties via `Referer`. @@ -162,10 +151,7 @@ async fn error_redirect_still_carries_security_headers() { // session extractor). let resp = get(test_app(), "/web/cases").await; assert_eq!(resp.status(), StatusCode::FOUND); - assert_eq!( - header_opt(&resp, "x-content-type-options"), - Some("nosniff") - ); + assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff")); assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); assert!(header_opt(&resp, "content-security-policy").is_some()); } diff --git a/server/tests/silent_case_empty_test.rs b/server/tests/silent_case_empty_test.rs index 5777d95..ea3a23c 100644 --- a/server/tests/silent_case_empty_test.rs +++ b/server/tests/silent_case_empty_test.rs @@ -9,13 +9,14 @@ //! makes `update_oneliner` settle the case to `OnelinerState::Empty` //! when no content transcript exists and no recording is still Pending. +mod common; + use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use doctate_common::oneliners::OnelinerState; -use doctate_server::config::Config; use doctate_server::events; use doctate_server::gazetteer::Gazetteer; use doctate_server::{ @@ -25,6 +26,8 @@ use tempfile::tempdir; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::TestConfig; + /// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk /// shape of a recording that whisper classified as silence. fn seed_silent_recording(case_dir: &Path, stem: &str) { @@ -52,10 +55,10 @@ async fn silent_only_case_settles_to_empty_without_llm_call() { .mount(&mock) .await; - let mut cfg = Config::test_default(); - cfg.data_path = data.path().to_path_buf(); - cfg.ollama_url = mock.uri(); - let config = Arc::new(cfg); + let config = TestConfig::new() + .with_data_path(data.path().to_path_buf()) + .with_ollama(mock.uri()) + .build(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); diff --git a/server/tests/sse_integration.rs b/server/tests/sse_integration.rs index 408f50b..72a8868 100644 --- a/server/tests/sse_integration.rs +++ b/server/tests/sse_integration.rs @@ -11,88 +11,31 @@ //! channel through an HTTP test would require reading a never-ending //! body, which these tests deliberately avoid. -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; +mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use tower::util::ServiceExt; -use doctate_server::config::{Config, User}; +use common::{TestConfig, get_with_cookie, header_opt, paths, test_user}; -fn unique_data_path() -> PathBuf { - std::env::temp_dir().join(format!( - "doctate-sse-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )) -} - -fn make_user(slug: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - // Password "s" — matches the login helper below. - web_password: bcrypt::hash("s", 4).unwrap(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - } -} - -fn build_config(data_path: PathBuf) -> Arc { - let users = vec![make_user("alice")]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - Arc::new(Config { - data_path, - users, - api_keys, - ..Config::test_default() - }) -} - -/// Log in via POST /web/login and return the resulting `session=…` cookie -/// header value. Mirrors the helper in the other integration tests. -async fn login(app: axum::Router, slug: &str) -> String { - let body = format!("slug={slug}&password=s"); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/web/login") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - for v in resp.headers().get_all(header::SET_COOKIE).iter() { - let s = v.to_str().unwrap(); - if let Some(pair) = s.split(';').next() - && pair.starts_with("session=") - { - return pair.to_string(); - } - } - panic!("no session cookie after login"); +fn test_app() -> axum::Router { + let cfg = TestConfig::new() + .with_label("sse") + .with_user(test_user("alice")) + .build(); + doctate_server::create_router(cfg) } #[tokio::test] async fn events_without_session_redirects_to_login() { - let config = build_config(unique_data_path()); - let app = doctate_server::create_router(config); + let app = test_app(); let resp = app .oneshot( Request::builder() .method("GET") - .uri("/web/events") + .uri(paths::EVENTS) .body(Body::empty()) .unwrap(), ) @@ -100,38 +43,24 @@ async fn events_without_session_redirects_to_login() { .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); - let location = resp - .headers() - .get(header::LOCATION) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert_eq!(location, "/web/login"); + assert_eq!( + header_opt(&resp, header::LOCATION.as_str()), + Some(paths::LOGIN) + ); } #[tokio::test] async fn events_with_session_returns_sse_content_type() { - let config = build_config(unique_data_path()); - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "alice").await; + let app = test_app(); + let cookie = common::login(&app, "alice", "s").await; let resp = app - .oneshot( - Request::builder() - .method("GET") - .uri("/web/events") - .header(header::COOKIE, cookie) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(get_with_cookie(paths::EVENTS, &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let ct = resp - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + let ct = header_opt(&resp, header::CONTENT_TYPE.as_str()).unwrap_or(""); assert!( ct.starts_with("text/event-stream"), "expected text/event-stream, got {ct:?}" diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 0965bef..23d812e 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -1,9 +1,10 @@ +mod common; + use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; -use std::sync::Arc; - -use doctate_server::config::{Config, User, WhisperUserSettings}; +use doctate_server::config::WhisperUserSettings; use doctate_server::transcribe; use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner}; @@ -13,6 +14,8 @@ use serde_json::json; use wiremock::matchers::{body_partial_json, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::{TestConfig, test_user}; + fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures") @@ -257,25 +260,15 @@ async fn recovery_enqueues_only_pending_recordings() { // -------------------- Worker: failure marker -------------------- -fn test_config_with_whisper(whisper_url: String) -> Arc { - Arc::new(Config { - users: vec![User { - slug: "dr_test".into(), - api_key: "k".into(), - web_password: "unused".into(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - }], - whisper_url, - whisper_timeout_seconds: 5, - // Ollama URL is irrelevant — worker never reaches it in the failure path. - ollama_url: "http://127.0.0.1:1".into(), - ollama_model: "test".into(), - ..Config::test_default() - }) +fn config_with_whisper(whisper_url: String) -> std::sync::Arc { + // Ollama URL is irrelevant — worker never reaches it in the failure path. + // Port 1 is effectively unreachable, matching the historical intent. + TestConfig::new() + .with_label("transcribe") + .with_user(test_user("dr_test")) + .with_whisper(whisper_url, 5) + .with_ollama("http://127.0.0.1:1") + .build() } /// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to @@ -299,7 +292,7 @@ async fn worker_renames_audio_to_failed_on_permanent_whisper_error() { let audio = case_dir.join("2026-04-13T10-30-00Z.m4a"); std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); - let config = test_config_with_whisper(server.uri()); + let config = config_with_whisper(server.uri()); let (tx, rx) = transcribe::channel(); tx.send(transcribe::TranscribeJob { audio_path: audio.clone(), @@ -310,8 +303,8 @@ async fn worker_renames_audio_to_failed_on_permanent_whisper_error() { drop(tx); // close channel so the worker loop exits after processing the job let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty()); transcribe::worker::run( rx, config, @@ -347,7 +340,7 @@ async fn worker_leaves_m4a_intact_on_transient_whisper_error() { let audio = case_dir.join("2026-04-13T10-31-00Z.m4a"); std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); - let config = test_config_with_whisper(server.uri()); + let config = config_with_whisper(server.uri()); let (tx, rx) = transcribe::channel(); tx.send(transcribe::TranscribeJob { audio_path: audio.clone(), @@ -358,8 +351,8 @@ async fn worker_leaves_m4a_intact_on_transient_whisper_error() { drop(tx); let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty()); transcribe::worker::run( rx, config, @@ -405,14 +398,14 @@ async fn transcribe_worker_normalizes_whisper_output() { let vocab_dir = tempfile::tempdir().unwrap(); std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap(); - let vocab = std::sync::Arc::new( + let vocab = Arc::new( doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path()) .await .unwrap(), ); assert_eq!(vocab.len(), 1); - let config = test_config_with_whisper(server.uri()); + let config = config_with_whisper(server.uri()); let (tx, rx) = transcribe::channel(); tx.send(transcribe::TranscribeJob { audio_path: audio.clone(), @@ -423,7 +416,7 @@ async fn transcribe_worker_normalizes_whisper_output() { drop(tx); let client = reqwest::Client::new(); - let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); transcribe::worker::run( rx, config, diff --git a/server/tests/transient_failure_retries_test.rs b/server/tests/transient_failure_retries_test.rs index 2fa79ce..8ada14d 100644 --- a/server/tests/transient_failure_retries_test.rs +++ b/server/tests/transient_failure_retries_test.rs @@ -11,12 +11,13 @@ //! terminating progress permanently. Only a manual admin reset un-failed //! the recording. +mod common; + use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use doctate_server::config::{Config, User}; use doctate_server::events; use doctate_server::gazetteer::Gazetteer; use doctate_server::{ @@ -26,6 +27,8 @@ use tempfile::tempdir; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use common::{TestConfig, test_user}; + fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures") @@ -64,21 +67,11 @@ async fn transient_whisper_failure_is_reenqueued_and_recovers() { // Config points at the mock Whisper, ollama intentionally unreachable // (the oneliner heal may fire in parallel; we only assert on // the transcript artefact, not on the oneliner). - let mut cfg = Config::test_default(); - cfg.data_path = data.path().to_path_buf(); - cfg.whisper_url = whisper.uri(); - cfg.whisper_timeout_seconds = 5; - cfg.users = vec![User { - slug: slug.into(), - api_key: "k".into(), - web_password: "unused".into(), - role: "doctor".into(), - whisper: Default::default(), - retention: Default::default(), - window_hours: 72, - preview_lines: 2, - }]; - let config = Arc::new(cfg); + let config = TestConfig::new() + .with_data_path(data.path().to_path_buf()) + .with_user(test_user(slug)) + .with_whisper(whisper.uri(), 5) + .build(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index a4ba3aa..c20ba23 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -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 { - 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 { + 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) { - 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, api_key: Option<&str>) -> Request { + 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); } diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 563c35d..8492dbe 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -1,33 +1,16 @@ -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 common::{TestConfig, header_opt, test_user}; -fn test_config() -> Arc { - let data_path = std::env::temp_dir().join(format!( - "doctate-web-test-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - Arc::new(Config { - data_path, - users: vec![User { - slug: "dr_test".into(), - api_key: "test-key".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".into(), "dr_test".into())]), - ..Config::test_default() - }) +fn test_config() -> std::sync::Arc { + TestConfig::new() + .with_label("web") + .with_user(test_user("dr_test")) + .build() } #[tokio::test] @@ -36,8 +19,7 @@ async fn web_audio_serves_file() { let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440000"; - let case_dir = data_path.join("dr_test").join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); + let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"fake audio content for test"; let filename = "2026-04-13T10-30-00Z.m4a"; @@ -55,28 +37,15 @@ async fn web_audio_serves_file() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap(), - "audio/mp4" - ); + assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4")); - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); + let bytes = common::body_bytes(response).await; assert_eq!(&bytes[..], audio_bytes); - - let _ = std::fs::remove_dir_all(&data_path); } #[tokio::test] async fn web_audio_path_traversal_rejected() { - let config = test_config(); - let app = doctate_server::create_router(config); + let app = doctate_server::create_router(test_config()); let response = app .oneshot( @@ -93,8 +62,7 @@ async fn web_audio_path_traversal_rejected() { #[tokio::test] async fn web_audio_invalid_case_id_rejected() { - let config = test_config(); - let app = doctate_server::create_router(config); + let app = doctate_server::create_router(test_config()); let response = app .oneshot( @@ -115,8 +83,7 @@ async fn web_audio_serves_range_as_206() { let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440001"; - let case_dir = data_path.join("dr_test").join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); + let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes: Vec = (0u8..=200u8).collect(); let filename = "2026-04-13T10-30-00Z.m4a"; @@ -136,39 +103,14 @@ async fn web_audio_serves_range_as_206() { assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!( - response - .headers() - .get("content-range") - .unwrap() - .to_str() - .unwrap(), - format!("bytes 10-19/{}", audio_bytes.len()) - ); - assert_eq!( - response - .headers() - .get("content-length") - .unwrap() - .to_str() - .unwrap(), - "10" - ); - assert_eq!( - response - .headers() - .get("accept-ranges") - .unwrap() - .to_str() - .unwrap(), - "bytes" + header_opt(&response, "content-range"), + Some(format!("bytes 10-19/{}", audio_bytes.len()).as_str()) ); + assert_eq!(header_opt(&response, "content-length"), Some("10")); + assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes")); - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); + let bytes = common::body_bytes(response).await; assert_eq!(&bytes[..], &audio_bytes[10..20]); - - let _ = std::fs::remove_dir_all(&data_path); } #[tokio::test] @@ -177,8 +119,7 @@ async fn web_audio_invalid_range_returns_416() { let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440002"; - let case_dir = data_path.join("dr_test").join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); + let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"short"; let filename = "2026-04-13T10-30-00Z.m4a"; @@ -198,16 +139,9 @@ async fn web_audio_invalid_range_returns_416() { assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!( - response - .headers() - .get("content-range") - .unwrap() - .to_str() - .unwrap(), - format!("bytes */{}", audio_bytes.len()) + header_opt(&response, "content-range"), + Some(format!("bytes */{}", audio_bytes.len()).as_str()) ); - - let _ = std::fs::remove_dir_all(&data_path); } #[tokio::test] @@ -216,8 +150,7 @@ async fn web_audio_no_range_header_advertises_accept_ranges() { let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440003"; - let case_dir = data_path.join("dr_test").join(case_id); - std::fs::create_dir_all(&case_dir).unwrap(); + let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"full file content"; let filename = "2026-04-13T10-30-00Z.m4a"; @@ -235,32 +168,16 @@ async fn web_audio_no_range_header_advertises_accept_ranges() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); + assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes")); assert_eq!( - response - .headers() - .get("accept-ranges") - .unwrap() - .to_str() - .unwrap(), - "bytes" + header_opt(&response, "content-length"), + Some(audio_bytes.len().to_string().as_str()) ); - assert_eq!( - response - .headers() - .get("content-length") - .unwrap() - .to_str() - .unwrap(), - audio_bytes.len().to_string() - ); - - let _ = std::fs::remove_dir_all(&data_path); } #[tokio::test] async fn web_audio_nonexistent_file_returns_404() { - let config = test_config(); - let app = doctate_server::create_router(config); + let app = doctate_server::create_router(test_config()); let response = app .oneshot(