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.
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);
+6 -9
View File
@@ -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(
+1 -3
View File
@@ -129,9 +129,7 @@ impl TestConfig {
/// `Config::test_default` themselves are broken (not reachable in
/// practice).
pub fn build(self) -> Arc<Config> {
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<String, String> = self
.users
.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`,
/// `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<u8>) {
pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
let boundary = "----testboundary";
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.
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::{
+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`
/// 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
+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
/// 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,
+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.
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
}
+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_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();
@@ -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();
+5 -17
View File
@@ -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<Config> {
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);
}
+8 -5
View File
@@ -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();
+162 -199
View File
@@ -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<Config>, 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<doctate_server::config::Config>, 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<Config>, 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<doctate_server::config::Config>, 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<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()
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);
}
+11 -29
View File
@@ -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<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()
.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;
+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 {
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());
}
+8 -5
View File
@@ -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();
+18 -89
View File
@@ -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<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");
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:?}"
+24 -31
View File
@@ -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<Config> {
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<doctate_server::config::Config> {
// 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,
+9 -16
View File
@@ -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();
+64 -233
View File
@@ -1,66 +1,38 @@
use std::collections::HashMap;
use std::sync::Arc;
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
use doctate_common::UPLOAD_PATH;
use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker};
fn test_config() -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
Arc::new(Config {
data_path,
users: vec![User {
slug: "dr_test".into(),
api_key: "test-key-123".into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
}],
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
..Config::test_default()
})
use common::{
TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str,
multipart_upload_body, test_user,
};
const TEST_KEY: &str = "key-dr_test";
fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
TestConfig::new()
.with_label("upload")
.with_user(test_user("dr_test"))
.build()
}
/// Build a multipart body with the given fields.
fn multipart_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
let boundary = "----testboundary";
let mut body = Vec::new();
// case_id field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
body.extend_from_slice(case_id.as_bytes());
body.extend_from_slice(b"\r\n");
// recorded_at field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
body.extend_from_slice(recorded_at.as_bytes());
body.extend_from_slice(b"\r\n");
// audio field
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
);
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
body.extend_from_slice(audio);
body.extend_from_slice(b"\r\n");
// End boundary
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(boundary.to_owned(), body)
/// Wrap an `(content_type, body)` pair into the actual HTTP upload request.
/// Keeps the multipart boundary and the audio-upload path in sync for
/// every test in this file.
fn upload_request(content_type: &str, body: Vec<u8>, api_key: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
.method("POST")
.uri(UPLOAD_PATH)
.header("Content-Type", content_type);
if let Some(k) = api_key {
b = b.header("X-API-Key", k);
}
b.body(Body::from(body)).unwrap()
}
#[tokio::test]
@@ -70,31 +42,17 @@ async fn upload_creates_new_case() {
let app = doctate_server::create_router(config);
let case_id = "550e8400-e29b-41d4-a716-446655440000";
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
let (content_type, body) =
multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let json = body_json(response).await;
assert_eq!(json["case_id"], case_id);
assert_eq!(json["status"], "received");
@@ -104,31 +62,17 @@ async fn upload_creates_new_case() {
.join(case_id)
.join("2026-04-13T10-30-00Z.m4a");
assert!(file.exists(), "Audio file should exist at {file:?}");
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn upload_invalid_case_id_returns_400() {
let config = test_config();
let app = doctate_server::create_router(config);
let app = doctate_server::create_router(test_config());
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
let (content_type, body) =
multipart_upload_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
.await
.unwrap();
@@ -137,27 +81,16 @@ async fn upload_invalid_case_id_returns_400() {
#[tokio::test]
async fn upload_without_auth_returns_401() {
let config = test_config();
let app = doctate_server::create_router(config);
let app = doctate_server::create_router(test_config());
let (boundary, body) = multipart_body(
let (content_type, body) = multipart_upload_body(
"550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z",
b"audio",
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&content_type, body, None))
.await
.unwrap();
@@ -166,28 +99,16 @@ async fn upload_without_auth_returns_401() {
#[tokio::test]
async fn upload_empty_audio_returns_400() {
let config = test_config();
let app = doctate_server::create_router(config);
let app = doctate_server::create_router(test_config());
let (boundary, body) = multipart_body(
let (content_type, body) = multipart_upload_body(
"550e8400-e29b-41d4-a716-446655440000",
"2026-04-13T10:30:00Z",
b"", // empty audio
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
.await
.unwrap();
@@ -203,40 +124,18 @@ async fn upload_second_recording_same_case() {
let case_id = "660e8400-e29b-41d4-a716-446655440000";
// First upload
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Second upload, same case, different timestamp
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -245,9 +144,6 @@ async fn upload_second_recording_same_case() {
let case_dir = data_path.join("dr_test").join(case_id);
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
// Cleanup
let _ = std::fs::remove_dir_all(&data_path);
}
/// Scenario A: a new upload to a *closed* case must silently remove
@@ -262,21 +158,10 @@ async fn upload_reopens_closed_case() {
let case_id = "770e8400-e29b-41d4-a716-446655440000";
// First upload — creates the case directory.
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -297,40 +182,20 @@ async fn upload_reopens_closed_case() {
let etag_before = {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/oneliners")
.header("X-API-Key", "test-key-123")
.body(Body::empty())
.unwrap(),
)
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
resp.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.expect("server must set ETag")
let t = header_str(&resp, "etag");
assert!(!t.is_empty(), "server must set ETag");
t
};
// Second upload — must auto-reopen.
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -348,15 +213,11 @@ async fn upload_reopens_closed_case() {
// If-None-Match must return 200 (not 304) so clients who were
// offline during the reopen notice it on their next poll.
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/oneliners")
.header("X-API-Key", "test-key-123")
.header("If-None-Match", &etag_before)
.body(Body::empty())
.unwrap(),
)
.oneshot(get_with_api_key_if_none_match(
"/api/oneliners",
TEST_KEY,
&etag_before,
))
.await
.unwrap();
assert_eq!(
@@ -364,15 +225,9 @@ async fn upload_reopens_closed_case() {
StatusCode::OK,
"auto-reopen must invalidate the pre-reopen ETag"
);
let etag_after = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.expect("server must set ETag");
let etag_after = header_str(&resp, "etag");
assert!(!etag_after.is_empty(), "server must set ETag");
assert_ne!(etag_after, etag_before);
let _ = std::fs::remove_dir_all(&data_path);
}
/// Scenario B: a new upload to a case whose server-side directory has
@@ -390,21 +245,10 @@ async fn upload_resurrects_hard_deleted_case() {
let case_id = "880e8400-e29b-41d4-a716-446655440000";
// First upload.
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"original recording");
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"original recording");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -415,21 +259,10 @@ async fn upload_resurrects_hard_deleted_case() {
assert!(!case_dir.exists());
// Second upload with the same client-generated UUID.
let (boundary, body) =
multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
let (ct, body) =
multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/upload")
.header("X-API-Key", "test-key-123")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -438,6 +271,4 @@ async fn upload_resurrects_hard_deleted_case() {
assert!(case_dir.exists());
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
let _ = std::fs::remove_dir_all(&data_path);
}
+26 -109
View File
@@ -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<Config> {
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<doctate_server::config::Config> {
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<u8> = (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(