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
+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);
}