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:
2026-04-22 10:51:33 +02:00
parent f438e972d4
commit af3377a6bc
21 changed files with 374 additions and 793 deletions
+7 -2
View File
@@ -781,7 +781,7 @@ async fn closed_case_detail_with_show_closed_returns_200() {
// With ?show_closed=1 → 200 with the reopen form. // With ?show_closed=1 → 200 with the reopen form.
let body = common::body_string( let body = common::body_string(
app.oneshot(get_with_cookie( app.oneshot(get_with_cookie(
&format!("{}?show_closed=1", paths::case_detail(case_id)), format!("{}?show_closed=1", paths::case_detail(case_id)),
&cookie, &cookie,
)) ))
.await .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 body = format!("action=reset&case_id={case_a}&case_id={case_b}");
let resp = app let resp = app
.clone() .clone()
.oneshot(csrf_form_post(paths::BULK, &cookie_admin, &csrf_admin, &body)) .oneshot(csrf_form_post(
paths::BULK,
&cookie_admin,
&csrf_admin,
&body,
))
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.status(), StatusCode::SEE_OTHER);
+6 -9
View File
@@ -8,9 +8,8 @@ use common::{TestConfig, test_user};
#[tokio::test] #[tokio::test]
async fn valid_api_key_returns_user() { async fn valid_api_key_returns_user() {
let app = doctate_server::create_router( let app =
TestConfig::new().with_user(test_user("dr_test")).build(), doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
);
let response = app let response = app
.oneshot( .oneshot(
@@ -32,9 +31,8 @@ async fn valid_api_key_returns_user() {
#[tokio::test] #[tokio::test]
async fn invalid_api_key_returns_401() { async fn invalid_api_key_returns_401() {
let app = doctate_server::create_router( let app =
TestConfig::new().with_user(test_user("dr_test")).build(), doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
);
let response = app let response = app
.oneshot( .oneshot(
@@ -52,9 +50,8 @@ async fn invalid_api_key_returns_401() {
#[tokio::test] #[tokio::test]
async fn missing_api_key_returns_401() { async fn missing_api_key_returns_401() {
let app = doctate_server::create_router( let app =
TestConfig::new().with_user(test_user("dr_test")).build(), doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
);
let response = app let response = app
.oneshot( .oneshot(
+1 -3
View File
@@ -129,9 +129,7 @@ impl TestConfig {
/// `Config::test_default` themselves are broken (not reachable in /// `Config::test_default` themselves are broken (not reachable in
/// practice). /// practice).
pub fn build(self) -> Arc<Config> { pub fn build(self) -> Arc<Config> {
let data_path = self let data_path = self.data_path.unwrap_or_else(|| unique_tmpdir(self.label));
.data_path
.unwrap_or_else(|| unique_tmpdir(self.label));
let api_keys: HashMap<String, String> = self let api_keys: HashMap<String, String> = self
.users .users
.iter() .iter()
+1 -5
View File
@@ -121,11 +121,7 @@ pub fn header_str(response: &Response, name: &str) -> String {
/// Uses the server's canonical field names (`case_id`, `recorded_at`, /// Uses the server's canonical field names (`case_id`, `recorded_at`,
/// `audio`) from `doctate_common::constants`, so a server-side rename /// `audio`) from `doctate_common::constants`, so a server-side rename
/// propagates here for free. /// propagates here for free.
pub fn multipart_upload_body( pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
case_id: &str,
recorded_at: &str,
audio: &[u8],
) -> (String, Vec<u8>) {
let boundary = "----testboundary"; let boundary = "----testboundary";
let mut body = Vec::new(); let mut body = Vec::new();
+1 -1
View File
@@ -26,7 +26,7 @@ pub mod users;
// Re-exports for ergonomic `use common::*;` in most test files. // Re-exports for ergonomic `use common::*;` in most test files.
pub use config::{TestConfig, unique_tmpdir}; pub use config::{TestConfig, unique_tmpdir};
pub use http::{ 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, get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body,
}; };
pub use seed::{ pub use seed::{
+1 -5
View File
@@ -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` /// Like [`seed_recording`] but additionally writes the `.duration.txt`
/// sidecar — used by the delete-recording tests that assert every /// sidecar — used by the delete-recording tests that assert every
/// sidecar is cleaned up. /// sidecar is cleaned up.
pub fn seed_recording_with_sidecars( pub fn seed_recording_with_sidecars(case_dir: &Path, stem: &str, transcript: &str) -> String {
case_dir: &Path,
stem: &str,
transcript: &str,
) -> String {
let filename = seed_recording(case_dir, stem, Some(transcript)); let filename = seed_recording(case_dir, stem, Some(transcript));
std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap(); std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap();
filename filename
+1 -5
View File
@@ -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 /// Doctor user with custom retention settings — for the retention sweep
/// tests that drive the lazy auto-close / auto-purge logic. /// tests that drive the lazy auto-close / auto-purge logic.
pub fn test_user_with_retention( pub fn test_user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) -> User {
slug: &str,
auto_close_days: u32,
auto_delete_days: u32,
) -> User {
let mut u = test_user(slug); let mut u = test_user(slug);
u.retention = RetentionUserSettings { u.retention = RetentionUserSettings {
auto_close_days, auto_close_days,
+5 -1
View File
@@ -292,7 +292,11 @@ async fn bulk_with_injection_payload_returns_403_not_500() {
// the browser flow breaks and one of these fires. // the browser flow breaks and one of these fires.
async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String { 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"); assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
common::body_string(resp).await common::body_string(resp).await
} }
+2 -4
View File
@@ -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_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
let victim = let victim = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me");
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 survivor =
seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me");
// Derived artefacts the delete must also purge. // Derived artefacts the delete must also purge.
std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap();
@@ -14,12 +14,13 @@
//! case to `Empty` through the same terminal branch used for silent-only //! case to `Empty` through the same terminal branch used for silent-only
//! cases. //! cases.
mod common;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use doctate_common::oneliners::OnelinerState; use doctate_common::oneliners::OnelinerState;
use doctate_server::config::Config;
use doctate_server::events; use doctate_server::events;
use doctate_server::gazetteer::Gazetteer; use doctate_server::gazetteer::Gazetteer;
use doctate_server::{ use doctate_server::{
@@ -29,6 +30,8 @@ use tempfile::tempdir;
use wiremock::matchers::{method, path as wm_path}; use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use common::TestConfig;
#[tokio::test] #[tokio::test]
async fn failed_only_case_settles_to_empty_without_llm_call() { async fn failed_only_case_settles_to_empty_without_llm_call() {
let data = tempdir().unwrap(); let data = tempdir().unwrap();
@@ -50,10 +53,10 @@ async fn failed_only_case_settles_to_empty_without_llm_call() {
.mount(&mock) .mount(&mock)
.await; .await;
let mut cfg = Config::test_default(); let config = TestConfig::new()
cfg.data_path = data.path().to_path_buf(); .with_data_path(data.path().to_path_buf())
cfg.ollama_url = mock.uri(); .with_ollama(mock.uri())
let config = Arc::new(cfg); .build();
let vocab = Arc::new(Gazetteer::empty()); let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel(); let events_tx = events::channel();
+5 -17
View File
@@ -1,22 +1,14 @@
use std::sync::Arc; mod common;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
use doctate_server::config::Config; use common::TestConfig;
fn test_config() -> Arc<Config> {
Arc::new(Config {
data_path: "/tmp/doctate-test".into(),
log_path: "/tmp/doctate-test/logs".into(),
..Config::test_default()
})
}
#[tokio::test] #[tokio::test]
async fn health_returns_ok() { 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 let response = app
.oneshot( .oneshot(
@@ -33,7 +25,7 @@ async fn health_returns_ok() {
#[tokio::test] #[tokio::test]
async fn health_returns_json_with_status() { 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 let response = app
.oneshot( .oneshot(
@@ -45,11 +37,7 @@ async fn health_returns_json_with_status() {
.await .await
.unwrap(); .unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX) let json = common::body_json(response).await;
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok"); assert_eq!(json["status"], "ok");
assert_eq!(json["port"], 3000); assert_eq!(json["port"], 3000);
} }
+8 -5
View File
@@ -5,12 +5,13 @@
//! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on //! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on
//! `OnelinerHealBusy` to dedup concurrent page-loads. //! `OnelinerHealBusy` to dedup concurrent page-loads.
mod common;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use doctate_server::config::Config;
use doctate_server::events; use doctate_server::events;
use doctate_server::gazetteer::Gazetteer; use doctate_server::gazetteer::Gazetteer;
use doctate_server::{ use doctate_server::{
@@ -22,6 +23,8 @@ use tokio::time::timeout;
use wiremock::matchers::{method, path as wm_path}; use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use common::TestConfig;
fn write_transcript(case_dir: &Path) { fn write_transcript(case_dir: &Path) {
// Seed an m4a + transcript pair, matching the worker's on-disk // Seed an m4a + transcript pair, matching the worker's on-disk
// layout. The oneliner worker iterates `.m4a` files and resolves // 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) .mount(&mock)
.await; .await;
let mut cfg = Config::test_default(); let config = TestConfig::new()
cfg.data_path = data.path().to_path_buf(); .with_data_path(data.path().to_path_buf())
cfg.ollama_url = mock.uri(); .with_ollama(mock.uri())
let config = Arc::new(cfg); .build();
let vocab = Arc::new(Gazetteer::empty()); let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel(); let events_tx = events::channel();
+162 -199
View File
@@ -1,131 +1,62 @@
use std::collections::HashMap; mod common;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use axum::body::Body; use axum::http::StatusCode;
use axum::http::{Request, StatusCode}; use doctate_common::oneliners::OnelinerState;
use filetime::FileTime; use filetime::FileTime;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use doctate_common::oneliners::OnelinerState; use common::{
use doctate_server::config::{Config, User}; 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_SLUG: &str = "dr_oneliners";
const TEST_KEY: &str = "key-dr_oneliners";
fn test_config() -> (Arc<Config>, PathBuf) { fn test_config() -> (std::sync::Arc<doctate_server::config::Config>, PathBuf) {
let data_path = std::env::temp_dir().join(format!( let cfg = TestConfig::new()
"doctate-oneliners-{}-{}", .with_label("oneliners")
std::process::id(), .with_user(test_user(TEST_SLUG))
uuid::Uuid::new_v4() .build();
)); let dp = cfg.data_path.clone();
let config = Arc::new(Config { (cfg, dp)
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_with_window_hours(hours: u32) -> (Arc<Config>, PathBuf) { fn test_config_with_window_hours(
let (cfg, dp) = test_config(); hours: u32,
// Safe because `test_config` just created the Arc and handed it ) -> (std::sync::Arc<doctate_server::config::Config>, PathBuf) {
// back with refcount == 1. let cfg = TestConfig::new()
let mut cfg = .with_label("oneliners-win")
Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique")); .with_user(test_user_with_window_hours(TEST_SLUG, hours))
cfg.users[0].window_hours = hours; .build();
(Arc::new(cfg), dp) 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, user_root: &Path,
case_id: &str, case_id: &str,
m4a_age: Duration, m4a_age: Duration,
oneliner_text: Option<&str>, oneliner_text: Option<&str>,
) -> PathBuf { ) -> PathBuf {
let case_dir = user_root.join(TEST_SLUG).join(case_id); let case_dir = user_root.join(TEST_SLUG).join(case_id);
tokio::fs::create_dir_all(&case_dir).await.unwrap(); std::fs::create_dir_all(&case_dir).unwrap();
seed_recording_with_age(&case_dir, "2026-04-18T10-00-00Z", m4a_age);
// 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();
if let Some(text) = oneliner_text { if let Some(text) = oneliner_text {
write_ready_state(&case_dir, text).await; seed_oneliner_ready(&case_dir, text);
} }
case_dir case_dir
} }
async fn write_ready_state(case_dir: &Path, text: &str) { fn mark_deleted(case_dir: &Path) {
let state = OnelinerState::Ready { std::fs::write(case_dir.join(".closed"), "{}").unwrap();
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<Body> {
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<Body> {
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<axum::body::Body>) -> 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<axum::body::Body>, name: &str) -> String {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned()
} }
#[tokio::test] #[tokio::test]
@@ -135,10 +66,10 @@ async fn returns_401_without_api_key() {
let response = app let response = app
.oneshot( .oneshot(
Request::builder() axum::http::Request::builder()
.method("GET") .method("GET")
.uri("/api/oneliners") .uri("/api/oneliners")
.body(Body::empty()) .body(axum::body::Body::empty())
.unwrap(), .unwrap(),
) )
.await .await
@@ -149,10 +80,13 @@ async fn returns_401_without_api_key() {
#[tokio::test] #[tokio::test]
async fn empty_user_dir_returns_200_empty_list() { 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 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); assert_eq!(response.status(), StatusCode::OK);
let etag = header_str(&response, "etag"); 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; let body = body_json(response).await;
assert_eq!(body["window_hours"], 72); assert_eq!(body["window_hours"], 72);
assert!(body["oneliners"].as_array().unwrap().is_empty()); assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn recent_case_with_oneliner_shows_up() { async fn recent_case_with_oneliner_shows_up() {
let (config, dp) = test_config(); let (config, dp) = test_config();
let case_id = "550e8400-e29b-41d4-a716-000000000001"; let case_id = "550e8400-e29b-41d4-a716-000000000001";
seed_case( seed(
&dp, &dp,
case_id, case_id,
Duration::from_secs(60), Duration::from_secs(60),
Some("Kniegelenk re., V.a. Meniskus"), Some("Kniegelenk re., V.a. Meniskus"),
) );
.await;
let app = doctate_server::create_router(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); assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await; let body = body_json(response).await;
let arr = body["oneliners"].as_array().unwrap(); 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_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus");
assert!(arr[0]["created_at"].is_string()); assert!(arr[0]["created_at"].is_string());
assert!(arr[0]["updated_at"].is_string()); assert!(arr[0]["updated_at"].is_string());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn case_without_oneliner_shows_null() { async fn case_without_oneliner_shows_null() {
let (config, dp) = test_config(); let (config, dp) = test_config();
seed_case( seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000002", "550e8400-e29b-41d4-a716-000000000002",
Duration::from_secs(60), Duration::from_secs(60),
None, None,
) );
.await;
let app = doctate_server::create_router(config); 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(); let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert!(arr[0]["oneliner"].is_null()); assert!(arr[0]["oneliner"].is_null());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn case_older_than_default_window_excluded() { async fn case_older_than_default_window_excluded() {
let (config, dp) = test_config(); let (config, dp) = test_config();
seed_case( seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000003", "550e8400-e29b-41d4-a716-000000000003",
Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default
Some("old case"), Some("old case"),
) );
.await;
let app = doctate_server::create_router(config); 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()); assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
} }
/// Demonstrates that the visibility decision lives in users.toml: /// 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 age = Duration::from_secs(40 * 3600);
let (config_small, dp_small) = test_config_with_window_hours(16); 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 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_eq!(body["window_hours"], 16);
assert!(body["oneliners"].as_array().unwrap().is_empty()); assert!(body["oneliners"].as_array().unwrap().is_empty());
let (config_wide, dp_wide) = test_config_with_window_hours(48); 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 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["window_hours"], 48);
assert_eq!(body["oneliners"].as_array().unwrap().len(), 1); 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 /// Regression guard: a lingering `?hours=N` from an old client is
/// silently ignored — the user-config value drives the response. /// silently ignored — the user-config value drives the response.
#[tokio::test] #[tokio::test]
async fn hours_query_param_is_ignored() { 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 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); assert_eq!(body["window_hours"], 72);
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn matching_if_none_match_returns_304() { 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 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); assert_eq!(first.status(), StatusCode::OK);
let etag = header_str(&first, "etag"); let etag = header_str(&first, "etag");
assert!(!etag.is_empty()); assert!(!etag.is_empty());
let second = app 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 .await
.unwrap(); .unwrap();
assert_eq!(second.status(), StatusCode::NOT_MODIFIED); assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(header_str(&second, "etag"), etag); assert_eq!(header_str(&second, "etag"), etag);
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn stale_if_none_match_returns_200() { 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 app = doctate_server::create_router(config);
let response = app let response = app
.oneshot(get_with_if_none_match( .oneshot(get_with_api_key_if_none_match(
"/api/oneliners", "/api/oneliners",
TEST_KEY,
"W/\"99999999-72\"", "W/\"99999999-72\"",
)) ))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let _ = std::fs::remove_dir_all(&dp);
} }
/// Two users with different `window_hours` must produce different /// 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. /// users.toml invalidates client caches after the next restart.
#[tokio::test] #[tokio::test]
async fn etag_differs_between_users_with_different_window_hours() { async fn etag_differs_between_users_with_different_window_hours() {
let (config_a, dp_a) = test_config_with_window_hours(16); let (config_a, _dp_a) = test_config_with_window_hours(16);
let (config_b, dp_b) = test_config_with_window_hours(48); let (config_b, _dp_b) = test_config_with_window_hours(48);
let app_a = doctate_server::create_router(config_a); let app_a = doctate_server::create_router(config_a);
let app_b = doctate_server::create_router(config_b); let app_b = doctate_server::create_router(config_b);
let r_a = app_a.oneshot(get("/api/oneliners")).await.unwrap(); let r_a = app_a
let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap(); .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_a = header_str(&r_a, "etag");
let etag_b = header_str(&r_b, "etag"); let etag_b = header_str(&r_b, "etag");
assert_ne!(etag_a, etag_b); assert_ne!(etag_a, etag_b);
assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}"); assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}");
assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}"); 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] #[tokio::test]
async fn deleted_case_is_excluded() { async fn deleted_case_is_excluded() {
let (config, dp) = test_config(); let (config, dp) = test_config();
let case_dir = seed_case( let case_dir = seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000005", "550e8400-e29b-41d4-a716-000000000005",
Duration::from_secs(60), Duration::from_secs(60),
Some("deleted case"), Some("deleted case"),
) );
.await; mark_deleted(&case_dir);
mark_deleted(&case_dir).await;
let app = doctate_server::create_router(config); 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()); assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn multiple_cases_sorted_newest_first() { async fn multiple_cases_sorted_newest_first() {
let (config, dp) = test_config(); let (config, dp) = test_config();
seed_case( seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000010", "550e8400-e29b-41d4-a716-000000000010",
Duration::from_secs(2 * 3600), Duration::from_secs(2 * 3600),
Some("older"), Some("older"),
) );
.await; seed(
seed_case(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000011", "550e8400-e29b-41d4-a716-000000000011",
Duration::from_secs(30), Duration::from_secs(30),
Some("newer"), Some("newer"),
) );
.await;
let app = doctate_server::create_router(config); 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(); let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2); assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["oneliner"]["text"], "newer"); assert_eq!(arr[0]["oneliner"]["text"], "newer");
assert_eq!(arr[1]["oneliner"]["text"], "older"); 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 /// 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). // (new last_recording_at).
let case_a = "550e8400-e29b-41d4-a716-000000000020"; let case_a = "550e8400-e29b-41d4-a716-000000000020";
let dir_a = dp.join(TEST_SLUG).join(case_a); 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"); 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( filetime::set_file_mtime(
&old_m4a, &old_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)), FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)),
) )
.unwrap(); .unwrap();
let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a"); 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( filetime::set_file_mtime(
&fresh_m4a, &fresh_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)), FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
) )
.unwrap(); .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 // Case B: single recording 10h ago — created_at newer than A's, but
// last_recording_at older than A's fresh addendum. // last_recording_at older than A's fresh addendum.
seed_case( seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000021", "550e8400-e29b-41d4-a716-000000000021",
Duration::from_secs(10 * 3600), Duration::from_secs(10 * 3600),
Some("B (single, middle-aged)"), Some("B (single, middle-aged)"),
) );
.await;
let app = doctate_server::create_router(config); 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(); let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2); assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["case_id"], case_a, "A must sort first"); assert_eq!(arr[0]["case_id"], case_a, "A must sort first");
assert!(arr[0]["last_recording_at"].is_string()); assert!(arr[0]["last_recording_at"].is_string());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn empty_state_serializes_as_kind_empty() { async fn empty_state_serializes_as_kind_empty() {
let (config, dp) = test_config(); let (config, dp) = test_config();
let case_dir = seed_case( let case_dir = seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000030", "550e8400-e29b-41d4-a716-000000000030",
Duration::from_secs(60), Duration::from_secs(60),
None, None,
) );
.await; seed_oneliner_state(
write_state(
&case_dir, &case_dir,
&OnelinerState::Empty { &OnelinerState::Empty {
generated_at: "2026-04-18T10:00:00Z".into(), generated_at: "2026-04-18T10:00:00Z".into(),
}, },
) );
.await;
let app = doctate_server::create_router(config); 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(); let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["oneliner"]["kind"], "empty"); assert_eq!(arr[0]["oneliner"]["kind"], "empty");
assert!(arr[0]["oneliner"].get("text").is_none()); assert!(arr[0]["oneliner"].get("text").is_none());
let _ = std::fs::remove_dir_all(&dp);
} }
#[tokio::test] #[tokio::test]
async fn error_state_serializes_as_kind_error() { async fn error_state_serializes_as_kind_error() {
let (config, dp) = test_config(); let (config, dp) = test_config();
let case_dir = seed_case( let case_dir = seed(
&dp, &dp,
"550e8400-e29b-41d4-a716-000000000031", "550e8400-e29b-41d4-a716-000000000031",
Duration::from_secs(60), Duration::from_secs(60),
None, None,
) );
.await; seed_oneliner_state(
write_state(
&case_dir, &case_dir,
&OnelinerState::Error { &OnelinerState::Error {
generated_at: "2026-04-18T10:00:00Z".into(), generated_at: "2026-04-18T10:00:00Z".into(),
}, },
) );
.await;
let app = doctate_server::create_router(config); 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(); let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["oneliner"]["kind"], "error"); assert_eq!(arr[0]["oneliner"]["kind"], "error");
let _ = std::fs::remove_dir_all(&dp);
} }
+11 -29
View File
@@ -26,7 +26,11 @@ async fn trigger_sweep(app: &axum::Router, cookie: &str) {
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
} }
fn build_cfg(label: &'static str, auto_close_days: u32, auto_delete_days: u32) -> std::sync::Arc<doctate_server::config::Config> { fn build_cfg(
label: &'static str,
auto_close_days: u32,
auto_delete_days: u32,
) -> std::sync::Arc<doctate_server::config::Config> {
TestConfig::new() TestConfig::new()
.with_label(label) .with_label(label)
.with_user(test_user_with_retention( .with_user(test_user_with_retention(
@@ -93,11 +97,7 @@ async fn auto_close_respects_newest_recording() {
"2026-04-01T10-00-00Z", "2026-04-01T10-00-00Z",
Duration::from_secs(30 * 86400), Duration::from_secs(30 * 86400),
); );
seed_recording_with_age( seed_recording_with_age(&case_dir, "2026-04-01T11-00-00Z", Duration::from_secs(60));
&case_dir,
"2026-04-01T11-00-00Z",
Duration::from_secs(60),
);
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await; 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 cfg = build_cfg("purge-old", 0, 30);
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording_with_age( seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
&case_dir,
"2026-04-01T10-00-00Z",
Duration::from_secs(60),
);
// closed_at = 31 days ago → must be purged. // closed_at = 31 days ago → must be purged.
write_closed_marker( write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(31 * 86400)));
&case_dir,
&past_rfc3339(Duration::from_secs(31 * 86400)),
);
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await; 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 cfg = build_cfg("purge-zero", 0, 0);
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording_with_age( seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
&case_dir, write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400)));
"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 app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await; 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 cfg = build_cfg("purge-skip-open", 0, 30);
let case_id = "11111111-1111-1111-1111-111111111111"; let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording_with_age( seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
&case_dir,
"2026-04-01T10-00-00Z",
Duration::from_secs(60),
);
let app = doctate_server::create_router(cfg); let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await; let cookie = common::login(&app, "dr_a", "s").await;
+6 -20
View File
@@ -26,14 +26,9 @@ fn count_header_values(resp: &Response, name: &str) -> usize {
} }
async fn get(app: axum::Router, uri: &str) -> Response { async fn get(app: axum::Router, uri: &str) -> Response {
app.oneshot( app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
Request::builder() .await
.uri(uri) .unwrap()
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
} }
// ---------- presence tests ---------- // ---------- presence tests ----------
@@ -42,10 +37,7 @@ async fn get(app: axum::Router, uri: &str) -> Response {
async fn api_health_has_all_security_headers() { async fn api_health_has_all_security_headers() {
let resp = get(test_app(), "/api/health").await; let resp = get(test_app(), "/api/health").await;
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
assert_eq!( assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
header_opt(&resp, "x-content-type-options"),
Some("nosniff")
);
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer")); assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
assert!( assert!(
@@ -123,10 +115,7 @@ async fn csp_blocks_form_action_hijack() {
#[tokio::test] #[tokio::test]
async fn nosniff_blocks_mime_confusion() { async fn nosniff_blocks_mime_confusion() {
let resp = get(test_app(), "/api/health").await; let resp = get(test_app(), "/api/health").await;
assert_eq!( assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
header_opt(&resp, "x-content-type-options"),
Some("nosniff")
);
} }
/// Session-carrying URLs should not leak to third parties via `Referer`. /// 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). // session extractor).
let resp = get(test_app(), "/web/cases").await; let resp = get(test_app(), "/web/cases").await;
assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!( assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
header_opt(&resp, "x-content-type-options"),
Some("nosniff")
);
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY")); assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
assert!(header_opt(&resp, "content-security-policy").is_some()); assert!(header_opt(&resp, "content-security-policy").is_some());
} }
+8 -5
View File
@@ -9,13 +9,14 @@
//! makes `update_oneliner` settle the case to `OnelinerState::Empty` //! makes `update_oneliner` settle the case to `OnelinerState::Empty`
//! when no content transcript exists and no recording is still Pending. //! when no content transcript exists and no recording is still Pending.
mod common;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use doctate_common::oneliners::OnelinerState; use doctate_common::oneliners::OnelinerState;
use doctate_server::config::Config;
use doctate_server::events; use doctate_server::events;
use doctate_server::gazetteer::Gazetteer; use doctate_server::gazetteer::Gazetteer;
use doctate_server::{ use doctate_server::{
@@ -25,6 +26,8 @@ use tempfile::tempdir;
use wiremock::matchers::{method, path as wm_path}; use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use common::TestConfig;
/// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk /// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk
/// shape of a recording that whisper classified as silence. /// shape of a recording that whisper classified as silence.
fn seed_silent_recording(case_dir: &Path, stem: &str) { 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) .mount(&mock)
.await; .await;
let mut cfg = Config::test_default(); let config = TestConfig::new()
cfg.data_path = data.path().to_path_buf(); .with_data_path(data.path().to_path_buf())
cfg.ollama_url = mock.uri(); .with_ollama(mock.uri())
let config = Arc::new(cfg); .build();
let vocab = Arc::new(Gazetteer::empty()); let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel(); let events_tx = events::channel();
+18 -89
View File
@@ -11,88 +11,31 @@
//! channel through an HTTP test would require reading a never-ending //! channel through an HTTP test would require reading a never-ending
//! body, which these tests deliberately avoid. //! body, which these tests deliberately avoid.
use std::collections::HashMap; mod common;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode, header}; use axum::http::{Request, StatusCode, header};
use tower::util::ServiceExt; 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 { fn test_app() -> axum::Router {
std::env::temp_dir().join(format!( let cfg = TestConfig::new()
"doctate-sse-test-{}-{}", .with_label("sse")
std::process::id(), .with_user(test_user("alice"))
uuid::Uuid::new_v4() .build();
)) doctate_server::create_router(cfg)
}
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<Config> {
let users = vec![make_user("alice")];
let api_keys: HashMap<String, String> = 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");
} }
#[tokio::test] #[tokio::test]
async fn events_without_session_redirects_to_login() { async fn events_without_session_redirects_to_login() {
let config = build_config(unique_data_path()); let app = test_app();
let app = doctate_server::create_router(config);
let resp = app let resp = app
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("GET") .method("GET")
.uri("/web/events") .uri(paths::EVENTS)
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -100,38 +43,24 @@ async fn events_without_session_redirects_to_login() {
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp assert_eq!(
.headers() header_opt(&resp, header::LOCATION.as_str()),
.get(header::LOCATION) Some(paths::LOGIN)
.and_then(|v| v.to_str().ok()) );
.unwrap_or("");
assert_eq!(location, "/web/login");
} }
#[tokio::test] #[tokio::test]
async fn events_with_session_returns_sse_content_type() { async fn events_with_session_returns_sse_content_type() {
let config = build_config(unique_data_path()); let app = test_app();
let app = doctate_server::create_router(config); let cookie = common::login(&app, "alice", "s").await;
let cookie = login(app.clone(), "alice").await;
let resp = app let resp = app
.oneshot( .oneshot(get_with_cookie(paths::EVENTS, &cookie))
Request::builder()
.method("GET")
.uri("/web/events")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let ct = resp let ct = header_opt(&resp, header::CONTENT_TYPE.as_str()).unwrap_or("");
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!( assert!(
ct.starts_with("text/event-stream"), ct.starts_with("text/event-stream"),
"expected text/event-stream, got {ct:?}" "expected text/event-stream, got {ct:?}"
+24 -31
View File
@@ -1,9 +1,10 @@
mod common;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::sync::Arc; use doctate_server::config::WhisperUserSettings;
use doctate_server::config::{Config, User, WhisperUserSettings};
use doctate_server::transcribe; use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner}; 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::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{TestConfig, test_user};
fn fixture(name: &str) -> PathBuf { fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures") .join("tests/fixtures")
@@ -257,25 +260,15 @@ async fn recovery_enqueues_only_pending_recordings() {
// -------------------- Worker: failure marker -------------------- // -------------------- Worker: failure marker --------------------
fn test_config_with_whisper(whisper_url: String) -> Arc<Config> { fn config_with_whisper(whisper_url: String) -> std::sync::Arc<doctate_server::config::Config> {
Arc::new(Config { // Ollama URL is irrelevant — worker never reaches it in the failure path.
users: vec![User { // Port 1 is effectively unreachable, matching the historical intent.
slug: "dr_test".into(), TestConfig::new()
api_key: "k".into(), .with_label("transcribe")
web_password: "unused".into(), .with_user(test_user("dr_test"))
role: "doctor".into(), .with_whisper(whisper_url, 5)
whisper: Default::default(), .with_ollama("http://127.0.0.1:1")
retention: Default::default(), .build()
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()
})
} }
/// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to /// 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"); let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); 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(); let (tx, rx) = transcribe::channel();
tx.send(transcribe::TranscribeJob { tx.send(transcribe::TranscribeJob {
audio_path: audio.clone(), 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 drop(tx); // close channel so the worker loop exits after processing the job
let client = reqwest::Client::new(); 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));
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
transcribe::worker::run( transcribe::worker::run(
rx, rx,
config, 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"); let audio = case_dir.join("2026-04-13T10-31-00Z.m4a");
std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); 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(); let (tx, rx) = transcribe::channel();
tx.send(transcribe::TranscribeJob { tx.send(transcribe::TranscribeJob {
audio_path: audio.clone(), audio_path: audio.clone(),
@@ -358,8 +351,8 @@ async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
drop(tx); drop(tx);
let client = reqwest::Client::new(); 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));
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
transcribe::worker::run( transcribe::worker::run(
rx, rx,
config, config,
@@ -405,14 +398,14 @@ async fn transcribe_worker_normalizes_whisper_output() {
let vocab_dir = tempfile::tempdir().unwrap(); let vocab_dir = tempfile::tempdir().unwrap();
std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").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()) doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
.await .await
.unwrap(), .unwrap(),
); );
assert_eq!(vocab.len(), 1); 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(); let (tx, rx) = transcribe::channel();
tx.send(transcribe::TranscribeJob { tx.send(transcribe::TranscribeJob {
audio_path: audio.clone(), audio_path: audio.clone(),
@@ -423,7 +416,7 @@ async fn transcribe_worker_normalizes_whisper_output() {
drop(tx); drop(tx);
let client = reqwest::Client::new(); 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( transcribe::worker::run(
rx, rx,
config, config,
+9 -16
View File
@@ -11,12 +11,13 @@
//! terminating progress permanently. Only a manual admin reset un-failed //! terminating progress permanently. Only a manual admin reset un-failed
//! the recording. //! the recording.
mod common;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use doctate_server::config::{Config, User};
use doctate_server::events; use doctate_server::events;
use doctate_server::gazetteer::Gazetteer; use doctate_server::gazetteer::Gazetteer;
use doctate_server::{ use doctate_server::{
@@ -26,6 +27,8 @@ use tempfile::tempdir;
use wiremock::matchers::{method, path as wm_path}; use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{TestConfig, test_user};
fn fixture(name: &str) -> PathBuf { fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures") .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 // Config points at the mock Whisper, ollama intentionally unreachable
// (the oneliner heal may fire in parallel; we only assert on // (the oneliner heal may fire in parallel; we only assert on
// the transcript artefact, not on the oneliner). // the transcript artefact, not on the oneliner).
let mut cfg = Config::test_default(); let config = TestConfig::new()
cfg.data_path = data.path().to_path_buf(); .with_data_path(data.path().to_path_buf())
cfg.whisper_url = whisper.uri(); .with_user(test_user(slug))
cfg.whisper_timeout_seconds = 5; .with_whisper(whisper.uri(), 5)
cfg.users = vec![User { .build();
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 vocab = Arc::new(Gazetteer::empty()); let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel(); let events_tx = events::channel();
+64 -233
View File
@@ -1,66 +1,38 @@
use std::collections::HashMap; mod common;
use std::sync::Arc;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; 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}; use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker};
fn test_config() -> Arc<Config> { use common::{
let data_path = std::env::temp_dir().join(format!( TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str,
"doctate-test-{}-{}", multipart_upload_body, test_user,
std::process::id(), };
uuid::Uuid::new_v4()
)); const TEST_KEY: &str = "key-dr_test";
Arc::new(Config {
data_path, fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
users: vec![User { TestConfig::new()
slug: "dr_test".into(), .with_label("upload")
api_key: "test-key-123".into(), .with_user(test_user("dr_test"))
web_password: "unused".into(), .build()
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()
})
} }
/// Build a multipart body with the given fields. /// Wrap an `(content_type, body)` pair into the actual HTTP upload request.
fn multipart_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) { /// Keeps the multipart boundary and the audio-upload path in sync for
let boundary = "----testboundary"; /// every test in this file.
let mut body = Vec::new(); fn upload_request(content_type: &str, body: Vec<u8>, api_key: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
// case_id field .method("POST")
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); .uri(UPLOAD_PATH)
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n"); .header("Content-Type", content_type);
body.extend_from_slice(case_id.as_bytes()); if let Some(k) = api_key {
body.extend_from_slice(b"\r\n"); b = b.header("X-API-Key", k);
}
// recorded_at field b.body(Body::from(body)).unwrap()
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] #[tokio::test]
@@ -70,31 +42,17 @@ async fn upload_creates_new_case() {
let app = doctate_server::create_router(config); let app = doctate_server::create_router(config);
let case_id = "550e8400-e29b-41d4-a716-446655440000"; 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 let response = app
.oneshot( .oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX) let json = body_json(response).await;
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["case_id"], case_id); assert_eq!(json["case_id"], case_id);
assert_eq!(json["status"], "received"); assert_eq!(json["status"], "received");
@@ -104,31 +62,17 @@ async fn upload_creates_new_case() {
.join(case_id) .join(case_id)
.join("2026-04-13T10-30-00Z.m4a"); .join("2026-04-13T10-30-00Z.m4a");
assert!(file.exists(), "Audio file should exist at {file:?}"); assert!(file.exists(), "Audio file should exist at {file:?}");
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
} }
#[tokio::test] #[tokio::test]
async fn upload_invalid_case_id_returns_400() { async fn upload_invalid_case_id_returns_400() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(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 let response = app
.oneshot( .oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
@@ -137,27 +81,16 @@ async fn upload_invalid_case_id_returns_400() {
#[tokio::test] #[tokio::test]
async fn upload_without_auth_returns_401() { async fn upload_without_auth_returns_401() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(config);
let (boundary, body) = multipart_body( let (content_type, body) = multipart_upload_body(
"550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z", "2026-04-13T10:30:00Z",
b"audio", b"audio",
); );
let response = app let response = app
.oneshot( .oneshot(upload_request(&content_type, body, None))
Request::builder()
.method("POST")
.uri("/api/upload")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -166,28 +99,16 @@ async fn upload_without_auth_returns_401() {
#[tokio::test] #[tokio::test]
async fn upload_empty_audio_returns_400() { async fn upload_empty_audio_returns_400() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(config);
let (boundary, body) = multipart_body( let (content_type, body) = multipart_upload_body(
"550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z", "2026-04-13T10:30:00Z",
b"", // empty audio b"", // empty audio
); );
let response = app let response = app
.oneshot( .oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
@@ -203,40 +124,18 @@ async fn upload_second_recording_same_case() {
let case_id = "660e8400-e29b-41d4-a716-446655440000"; let case_id = "660e8400-e29b-41d4-a716-446655440000";
// First upload // 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 let response = app
.clone() .clone()
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
// Second upload, same case, different timestamp // 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 let response = app
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); 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); 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-30-00Z.m4a").exists());
assert!(case_dir.join("2026-04-13T10-45-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 /// 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"; let case_id = "770e8400-e29b-41d4-a716-446655440000";
// First upload — creates the case directory. // 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 let response = app
.clone() .clone()
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
@@ -297,40 +182,20 @@ async fn upload_reopens_closed_case() {
let etag_before = { let etag_before = {
let resp = app let resp = app
.clone() .clone()
.oneshot( .oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
Request::builder()
.method("GET")
.uri("/api/oneliners")
.header("X-API-Key", "test-key-123")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
resp.headers() let t = header_str(&resp, "etag");
.get("etag") assert!(!t.is_empty(), "server must set ETag");
.and_then(|v| v.to_str().ok()) t
.map(str::to_owned)
.expect("server must set ETag")
}; };
// Second upload — must auto-reopen. // 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 let response = app
.clone() .clone()
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); 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 // If-None-Match must return 200 (not 304) so clients who were
// offline during the reopen notice it on their next poll. // offline during the reopen notice it on their next poll.
let resp = app let resp = app
.oneshot( .oneshot(get_with_api_key_if_none_match(
Request::builder() "/api/oneliners",
.method("GET") TEST_KEY,
.uri("/api/oneliners") &etag_before,
.header("X-API-Key", "test-key-123") ))
.header("If-None-Match", &etag_before)
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -364,15 +225,9 @@ async fn upload_reopens_closed_case() {
StatusCode::OK, StatusCode::OK,
"auto-reopen must invalidate the pre-reopen ETag" "auto-reopen must invalidate the pre-reopen ETag"
); );
let etag_after = resp let etag_after = header_str(&resp, "etag");
.headers() assert!(!etag_after.is_empty(), "server must set ETag");
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.expect("server must set ETag");
assert_ne!(etag_after, etag_before); 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 /// 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"; let case_id = "880e8400-e29b-41d4-a716-446655440000";
// First upload. // 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 let response = app
.clone() .clone()
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
@@ -415,21 +259,10 @@ async fn upload_resurrects_hard_deleted_case() {
assert!(!case_dir.exists()); assert!(!case_dir.exists());
// Second upload with the same client-generated UUID. // Second upload with the same client-generated UUID.
let (boundary, body) = let (ct, body) =
multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording"); multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
let response = app let response = app
.oneshot( .oneshot(upload_request(&ct, body, Some(TEST_KEY)))
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 .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
@@ -438,6 +271,4 @@ async fn upload_resurrects_hard_deleted_case() {
assert!(case_dir.exists()); assert!(case_dir.exists());
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists()); assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists()); assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
let _ = std::fs::remove_dir_all(&data_path);
} }
+26 -109
View File
@@ -1,33 +1,16 @@
use std::collections::HashMap; mod common;
use std::sync::Arc;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
use doctate_server::config::{Config, User}; use common::{TestConfig, header_opt, test_user};
fn test_config() -> Arc<Config> { fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
let data_path = std::env::temp_dir().join(format!( TestConfig::new()
"doctate-web-test-{}-{}", .with_label("web")
std::process::id(), .with_user(test_user("dr_test"))
uuid::Uuid::new_v4() .build()
));
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()
})
} }
#[tokio::test] #[tokio::test]
@@ -36,8 +19,7 @@ async fn web_audio_serves_file() {
let data_path = config.data_path.clone(); let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440000"; let case_id = "770e8400-e29b-41d4-a716-446655440000";
let case_dir = data_path.join("dr_test").join(case_id); let case_dir = common::seed_case(&data_path, "dr_test", case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes = b"fake audio content for test"; let audio_bytes = b"fake audio content for test";
let filename = "2026-04-13T10-30-00Z.m4a"; let filename = "2026-04-13T10-30-00Z.m4a";
@@ -55,28 +37,15 @@ async fn web_audio_serves_file() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
assert_eq!( assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4"));
response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"audio/mp4"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) let bytes = common::body_bytes(response).await;
.await
.unwrap();
assert_eq!(&bytes[..], audio_bytes); assert_eq!(&bytes[..], audio_bytes);
let _ = std::fs::remove_dir_all(&data_path);
} }
#[tokio::test] #[tokio::test]
async fn web_audio_path_traversal_rejected() { async fn web_audio_path_traversal_rejected() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(config);
let response = app let response = app
.oneshot( .oneshot(
@@ -93,8 +62,7 @@ async fn web_audio_path_traversal_rejected() {
#[tokio::test] #[tokio::test]
async fn web_audio_invalid_case_id_rejected() { async fn web_audio_invalid_case_id_rejected() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(config);
let response = app let response = app
.oneshot( .oneshot(
@@ -115,8 +83,7 @@ async fn web_audio_serves_range_as_206() {
let data_path = config.data_path.clone(); let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440001"; let case_id = "770e8400-e29b-41d4-a716-446655440001";
let case_dir = data_path.join("dr_test").join(case_id); let case_dir = common::seed_case(&data_path, "dr_test", case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes: Vec<u8> = (0u8..=200u8).collect(); let audio_bytes: Vec<u8> = (0u8..=200u8).collect();
let filename = "2026-04-13T10-30-00Z.m4a"; 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.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!( assert_eq!(
response header_opt(&response, "content-range"),
.headers() Some(format!("bytes 10-19/{}", audio_bytes.len()).as_str())
.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"
); );
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) let bytes = common::body_bytes(response).await;
.await
.unwrap();
assert_eq!(&bytes[..], &audio_bytes[10..20]); assert_eq!(&bytes[..], &audio_bytes[10..20]);
let _ = std::fs::remove_dir_all(&data_path);
} }
#[tokio::test] #[tokio::test]
@@ -177,8 +119,7 @@ async fn web_audio_invalid_range_returns_416() {
let data_path = config.data_path.clone(); let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440002"; let case_id = "770e8400-e29b-41d4-a716-446655440002";
let case_dir = data_path.join("dr_test").join(case_id); let case_dir = common::seed_case(&data_path, "dr_test", case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes = b"short"; let audio_bytes = b"short";
let filename = "2026-04-13T10-30-00Z.m4a"; 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.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!( assert_eq!(
response header_opt(&response, "content-range"),
.headers() Some(format!("bytes */{}", audio_bytes.len()).as_str())
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
format!("bytes */{}", audio_bytes.len())
); );
let _ = std::fs::remove_dir_all(&data_path);
} }
#[tokio::test] #[tokio::test]
@@ -216,8 +150,7 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
let data_path = config.data_path.clone(); let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440003"; let case_id = "770e8400-e29b-41d4-a716-446655440003";
let case_dir = data_path.join("dr_test").join(case_id); let case_dir = common::seed_case(&data_path, "dr_test", case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes = b"full file content"; let audio_bytes = b"full file content";
let filename = "2026-04-13T10-30-00Z.m4a"; let filename = "2026-04-13T10-30-00Z.m4a";
@@ -235,32 +168,16 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes"));
assert_eq!( assert_eq!(
response header_opt(&response, "content-length"),
.headers() Some(audio_bytes.len().to_string().as_str())
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
); );
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] #[tokio::test]
async fn web_audio_nonexistent_file_returns_404() { async fn web_audio_nonexistent_file_returns_404() {
let config = test_config(); let app = doctate_server::create_router(test_config());
let app = doctate_server::create_router(config);
let response = app let response = app
.oneshot( .oneshot(