Files
doctate/server/tests/analyze_test.rs
T
Brummel 76bf65be1a Refactor: Track analysis jobs via InFlight struct
This commit replaces the disk-based `analysis_input.json` marker for
in-progress analysis jobs with an in-memory
`Arc<RwLock<HashSet<PathBuf>>>`.

Previously, the existence of `analysis_input.json` was used as a signal
that a case was queued or being analyzed. This led to stale "Queued"
states after server restarts, as the disk file would persist while the
server process tracking it had vanished.

The new `InFlight` struct, held in `AppState`, provides a process-local
marker. This ensures that:

- Server restarts correctly reset the state, as the `HashSet` is
  cleared.
- Race conditions for triggering analysis are handled by the `try_claim`
  method, replacing the previous reliance on file system `O_EXCL` flags.
- The `AnalyzeJob` payload now travels with the job over the channel,
  eliminating the need for workers to re-read `analysis_input.json` from
  disk.

This change simplifies state management and improves the reliability of
analysis job tracking.
2026-05-05 20:01:07 +02:00

1665 lines
58 KiB
Rust

mod common;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use axum::http::{StatusCode, header};
use tower::util::ServiceExt;
use doctate_common::BulkAction;
use doctate_server::analyze;
use serde_json::{Value, json};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{
ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig,
csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, test_admin,
test_user, unique_tmpdir, write_document_for_test,
};
// ---------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------
type CfgPair = (
std::sync::Arc<doctate_server::config::Config>,
std::sync::Arc<doctate_server::settings::Settings>,
);
fn cfg_llm(label: &'static str) -> CfgPair {
TestConfig::new()
.with_label(label)
.with_user(test_user("dr_a"))
.with_llm("http://unused")
.build_pair()
}
fn cfg_llm_admin(label: &'static str) -> CfgPair {
TestConfig::new()
.with_label(label)
.with_user(test_admin("dr_a"))
.with_llm("http://unused")
.build_pair()
}
fn cfg_without_llm(label: &'static str) -> CfgPair {
TestConfig::new()
.with_label(label)
.with_user(test_user("dr_a"))
.build_pair()
}
// ---------------------------------------------------------------------
// HTTP-level precondition tests
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_without_cookie_redirects_to_login() {
let (cfg, settings) = cfg_llm("a");
let app = doctate_server::create_router_with_settings(cfg, settings);
let case_id = "11111111-1111-1111-1111-111111111111";
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(paths::case_analyze(case_id))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
}
#[tokio::test]
async fn analyze_foreign_case_returns_404() {
let (cfg, settings) = cfg_llm("b");
// Seed a case owned by someone else.
let foreign_case = "22222222-2222-2222-2222-222222222222";
let foreign_dir = cfg.data_path.join("dr_other").join(foreign_case);
std::fs::create_dir_all(&foreign_dir).unwrap();
seed_recording(&foreign_dir, "2026-04-15T10-00-00Z", Some("text"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(foreign_case),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn analyze_invalid_uuid_returns_400() {
let (cfg, settings) = cfg_llm("c");
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze("not-a-uuid"),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn analyze_case_without_recordings_returns_400() {
let (cfg, settings) = cfg_llm("d");
let case_id = "11111111-1111-1111-1111-111111111111";
seed_case(&cfg.data_path, "dr_a", case_id);
// No .m4a files.
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn analyze_case_with_missing_transcript_returns_400() {
let (cfg, settings) = cfg_llm("e");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
seed_recording(&case_dir, "2026-04-15T10-05-00Z", None); // still transcribing
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[ignore = "post-refactor any_backend_available() reads IONOS_API_KEY which TestConfig pre-sets; cannot be unset for one test in a shared process (Edition 2024 unsafe env::remove_var + parallel tests)"]
async fn analyze_case_without_llm_returns_503() {
let (cfg, settings) = cfg_without_llm("f");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
// ---------------------------------------------------------------------
// HTTP-level happy path + race
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_case_happy_path_sends_job_and_redirects() {
let (cfg, settings) = cfg_llm("g");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(
&case_dir,
"2026-04-15T10-00-00Z",
Some("Patient klagt über Knie."),
);
seed_recording(
&case_dir,
"2026-04-15T10-05-00Z",
Some("Korrektur: links, nicht rechts."),
);
let (app, store, in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
format!("/cases/{case_id}")
);
// Slot is claimed for the duration of the worker's run — proxied
// here by the receiver still holding the un-consumed job.
assert!(
in_flight.contains(&case_dir),
"in-flight slot must be held until the worker drops it"
);
let job = analyze_rx
.try_recv()
.expect("handler must enqueue exactly one job");
assert_eq!(job.case_dir, case_dir);
assert_eq!(job.input.recordings.len(), 2);
assert_eq!(job.input.recordings[0].recorded_at, "2026-04-15T10:00:00Z");
assert_eq!(job.input.recordings[0].text, "Patient klagt über Knie.");
assert_eq!(job.input.recordings[1].recorded_at, "2026-04-15T10:05:00Z");
assert!(!job.input.last_recording_mtime.is_empty());
assert!(
analyze_rx.try_recv().is_err(),
"exactly one job must be enqueued"
);
}
#[tokio::test]
async fn analyze_case_second_time_returns_409() {
let (cfg, settings) = cfg_llm("h");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("text"));
// Hold the receiver alive across both requests — otherwise the
// first send fails, the slot is released, and the second click
// would be accepted as a fresh trigger. In production the worker
// keeps the slot held via `InFlightGuard` until it finishes.
let (app, store, _in_flight, _analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let first = app
.clone()
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::SEE_OTHER);
let second = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
}
/// Bug regression (case c414cf52, 2026-05-03): a prior LLM run failed,
/// leaving `.analysis_failed.json` on disk. The user clicks "Erneut
/// versuchen" — the handler must accept the retry. With the in-flight
/// marker now process-local, no on-disk sentinel can falsely block the
/// retry; the explicit user click claims a fresh slot and sends a job
/// with the current transcripts.
#[tokio::test]
async fn analyze_retry_after_failure_marker_succeeds() {
let (cfg, settings) = cfg_llm("retry-after-fail");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("frischer Text"));
// Failure marker from the prior aborted attempt remains on disk —
// the new model leaves it in place; the worker overwrites it on
// re-failure or removes it on success.
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.unwrap();
let (app, store, _in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"retry with failure marker present must succeed, not 409"
);
// The job carries the fresh transcript — there is no on-disk state
// to validate any more, the channel is the truth.
let job = analyze_rx
.try_recv()
.expect("retry must enqueue a fresh job");
assert_eq!(job.input.recordings.len(), 1);
assert_eq!(job.input.recordings[0].text, "frischer Text");
}
#[tokio::test]
async fn analyze_case_skips_blank_transcript_from_recordings() {
let (cfg, settings) = cfg_llm("i");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("echt"));
seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some(" ")); // blank
seed_recording(&case_dir, "2026-04-15T10-10-00Z", Some("auch echt"));
let (app, store, _in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let job = analyze_rx.try_recv().expect("job must be enqueued");
assert_eq!(
job.input.recordings.len(),
2,
"blank transcript must be filtered out"
);
let _ = case_dir; // name kept for grep symmetry with the seed calls
}
// ---------------------------------------------------------------------
// Worker + wiremock integration
//
// These end-to-end tests build a wiremock server and used to override
// the LLM endpoint via `settings.llm.url`. After the multi-backend
// refactor (analyze::backend), the active backend list is built once
// per process from hard-coded URLs (Ionos endpoint) and `IONOS_API_KEY`
// — there is no per-test hook to point chat_once() at a wiremock
// server. Re-enabling these tests requires injecting a per-test
// `Vec<LlmBackend>` through the worker (Arc<...> instead of the static
// catalog). Marked `#[ignore]` for now; the orthogonal coverage from
// the case_page_test integration tests still exercises the rest of the
// flow.
// ---------------------------------------------------------------------
#[tokio::test]
#[ignore = "needs per-test backend injection (see module comment)"]
async fn analyze_worker_writes_document_via_wiremock() {
let tmp = unique_tmpdir("analyze-w");
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap();
let input = analyze::AnalysisInput {
last_recording_mtime: "2026-04-15T10:00:00Z".to_owned(),
recordings: vec![analyze::RecordingInput {
recorded_at: "2026-04-15T10:00:00Z".to_owned(),
text: "Patient mit Knie.".to_owned(),
}],
backend_id: String::new(),
};
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"message": { "content": "Zusammenfassung: Knie-Beschwerden." }
}]
})))
.mount(&mock)
.await;
let _ = TestConfig::new()
.with_label("analyze-w")
.with_data_path(tmp.clone())
.with_user(test_user("dr_a"))
.with_llm(mock.uri())
.build_pair();
let (tx, rx) = analyze::channel();
let client = reqwest::Client::new();
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
let in_flight = analyze::InFlight::new();
let handle = tokio::spawn(analyze::worker::run(
rx,
client,
busy,
in_flight,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
input,
})
.unwrap();
// Poll for the document (worker is in a separate task).
let document_path = case_dir.join(DOCUMENT_FILE);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !document_path.exists() {
if std::time::Instant::now() > deadline {
panic!("document.md not written within 5s");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let content = std::fs::read_to_string(&document_path).unwrap();
assert!(
content.contains("Knie-Beschwerden"),
"unexpected content: {content}"
);
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
/// The analyze worker must pass the LLM output through the Gazetteer
/// as a post-AI normalization filter. The mock LLM is primed to return
/// "Zerebrum" (an edit-distance-1 variant of the vocab entry
/// "Cerebrum"). The persisted `document.md` must contain the canonical
/// "Cerebrum", not the LLM's drift.
#[tokio::test]
#[ignore = "needs per-test backend injection (see module comment)"]
async fn analyze_worker_normalizes_llm_output() {
let tmp = unique_tmpdir("analyze-gaz");
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_dir).unwrap();
let input = analyze::AnalysisInput {
last_recording_mtime: "2026-04-15T10:00:00Z".to_owned(),
recordings: vec![analyze::RecordingInput {
recorded_at: "2026-04-15T10:00:00Z".to_owned(),
text: "Patient mit Blutung im Zerebrum.".to_owned(),
}],
backend_id: String::new(),
};
// Vocab directory with a single anatomy entry.
let vocab_dir = unique_tmpdir("analyze-vocab");
std::fs::create_dir_all(&vocab_dir).unwrap();
std::fs::write(vocab_dir.join("anatomy.txt"), "Cerebrum\n").unwrap();
let vocab = Arc::new(
doctate_server::gazetteer::Gazetteer::load_dir(&vocab_dir)
.await
.unwrap(),
);
assert_eq!(vocab.len(), 1);
// Mock LLM returns the *uncorrected* form — we're proving the
// gazetteer rewrites it on the way out of the analyze worker.
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"message": { "content": "Patient mit Blutung im Zerebrum." }
}]
})))
.mount(&mock)
.await;
let _ = TestConfig::new()
.with_label("analyze-gaz")
.with_data_path(tmp.clone())
.with_user(test_user("dr_a"))
.with_llm(mock.uri())
.build_pair();
let (tx, rx) = analyze::channel();
let client = reqwest::Client::new();
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
let in_flight = analyze::InFlight::new();
let handle = tokio::spawn(analyze::worker::run(
rx,
client,
busy,
in_flight,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
input,
})
.unwrap();
let document_path = case_dir.join(DOCUMENT_FILE);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !document_path.exists() {
if std::time::Instant::now() > deadline {
panic!("document.md not written within 5s");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// The persisted document must show the canonical form even though
// the LLM returned the drift form.
let document = std::fs::read_to_string(&document_path).unwrap();
assert_eq!(document, "Patient mit Blutung im Cerebrum.");
// Sanity: one LLM call happened.
let requests = mock.received_requests().await.unwrap();
assert_eq!(requests.len(), 1, "expected exactly one LLM request");
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
/// Manual debug utility — not part of the default test set.
/// Usage:
/// DRY_CASE_DIR=/path/to/case cargo test --test analyze_test replace_real_case_dry -- --ignored --nocapture
///
/// Prints each transcript in a given case dir side-by-side with its
/// gazetteer-normalized version. Useful to eyeball false-positives /
/// false-negatives on real cases.
#[tokio::test]
#[ignore]
async fn replace_real_case_dry() {
let vocab_dir = std::env::var("DRY_VOCAB_DIR")
.unwrap_or_else(|_| "/home/brummel/dev/doctate/server/vocab".into());
let case_dir = std::env::var("DRY_CASE_DIR").unwrap_or_else(|_| {
"/home/brummel/dev/doctate/tmpdata/dr_mueller/e817d8b8-43eb-436a-8364-76b80a1e627b".into()
});
let vocab = doctate_server::gazetteer::Gazetteer::load_dir(Path::new(&vocab_dir))
.await
.expect("load vocab");
println!("Gazetteer entries: {}", vocab.len());
// Pair the recording metadata sidecars (`<stem>.json`) with their
// audio file so case-level JSONs (oneliner.json, analysis_input.json)
// are skipped automatically.
let mut names: Vec<_> = std::fs::read_dir(&case_dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
let Some(stem) = p
.file_name()
.and_then(|n| n.to_str())
.and_then(|s| s.strip_suffix(".json"))
else {
return false;
};
// Per-recording sidecar iff a sibling `<stem>.m4a` exists.
p.with_file_name(format!("{stem}.m4a")).exists()
})
.collect();
names.sort();
for path in names {
let bytes = std::fs::read(&path).unwrap();
let meta: doctate_common::RecordingMeta = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
println!(
"\n=== {} === [malformed: {e}]",
path.file_name().unwrap().to_string_lossy()
);
continue;
}
};
let text = match meta.transcript {
doctate_common::Transcript::Content { text } => text,
doctate_common::Transcript::Silent => {
println!(
"\n=== {} === [silent — no text]",
path.file_name().unwrap().to_string_lossy()
);
continue;
}
};
let replaced = vocab.replace(&text);
println!("\n=== {} ===", path.file_name().unwrap().to_string_lossy());
println!("--- original ---\n{text}");
println!("--- replaced ---\n{replaced}");
if replaced == text {
println!("[no replacements applied]");
}
}
}
// ---------------------------------------------------------------------
// Recovery
// ---------------------------------------------------------------------
/// Boot-scan is observation-only after the typed-state refactor: it
/// must never enqueue jobs. With the in-flight set replacing on-disk
/// markers, an orphan `analysis_input.json` left over from a
/// pre-refactor server run is a no-op: the empty `InFlight` set means
/// the case is not classified as `Queued`.
#[tokio::test]
async fn recovery_does_not_enqueue_at_boot() {
let tmp = unique_tmpdir("analyze-r1");
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap();
// Pre-refactor leftover; ignored by the new model.
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
let (_tx, mut rx) = analyze::channel();
let in_flight = analyze::InFlight::new();
analyze::recovery::boot_scan_state(
&tmp,
std::time::Duration::ZERO,
std::time::SystemTime::now(),
std::time::SystemTime::UNIX_EPOCH,
&in_flight,
)
.await;
assert!(
rx.try_recv().is_err(),
"boot scan must not enqueue any jobs in the new state model"
);
}
// ---------------------------------------------------------------------
// Re-analysis path: same handler, version derived from existing documents
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_deletes_old_document_and_sends_fresh_job() {
let (cfg, settings) = cfg_llm("rn-5");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
write_document_for_test(&case_dir, "altes Dokument", "1970-01-01T00:00:00Z");
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme"));
seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some("zweite Aufnahme"));
let (app, store, _in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_analyze(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(
!case_dir.join(DOCUMENT_FILE).exists(),
"old document must be deleted before re-analysis"
);
let job = analyze_rx.try_recv().expect("re-analysis must enqueue");
assert_eq!(job.input.recordings.len(), 2);
}
// ---------------------------------------------------------------------
// Close / Reopen
// ---------------------------------------------------------------------
#[tokio::test]
async fn close_writes_marker_and_hides_case() {
let (cfg, settings) = cfg_llm("close-1");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/cases"
);
let marker = case_dir.join(CLOSE_MARKER);
assert!(marker.exists(), ".closed marker missing");
let raw = std::fs::read_to_string(&marker).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
assert!(
parsed["closed_at"].is_string(),
"closed_at must be a string"
);
}
#[tokio::test]
async fn closed_case_returns_404_on_detail() {
let (cfg, settings) = cfg_llm("close-2");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let resp = app
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn reopen_removes_marker_and_restores_case() {
let (cfg, settings) = cfg_llm("reopen-1");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// Close, then reopen via the explicit endpoint.
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert!(case_dir.join(CLOSE_MARKER).exists());
let resp = app
.clone()
.oneshot(csrf_form_post(
paths::case_reopen(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(
!case_dir.join(CLOSE_MARKER).exists(),
".closed marker must be gone after reopen"
);
// Case is addressable again at the detail route — no lingering state.
let detail = app
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
.await
.unwrap();
assert_eq!(detail.status(), StatusCode::OK);
}
#[tokio::test]
async fn show_closed_query_includes_closed_cases() {
let (cfg, settings) = cfg_llm("show-closed");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("a closed one"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// Close first.
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
// Default listing must NOT show it.
let body = common::body_string(
app.clone()
.oneshot(get_with_cookie(paths::CASES, &cookie))
.await
.unwrap(),
)
.await;
assert!(
!body.contains(case_id),
"default /cases must hide closed cases"
);
// With show_closed=1 it must appear, with the closed badge.
let body = common::body_string(
app.oneshot(get_with_cookie("/cases?show_closed=1", &cookie))
.await
.unwrap(),
)
.await;
assert!(
body.contains(case_id),
"show_closed=1 must list closed cases"
);
assert!(
body.contains("geschlossen"),
"closed badge must render in show_closed mode"
);
assert!(
body.contains("reopen"),
"reopen button must be rendered for closed cases"
);
}
#[tokio::test]
async fn closed_case_detail_with_show_closed_returns_200() {
let (cfg, settings) = cfg_llm("show-detail");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("x"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
// 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)),
&cookie,
))
.await
.unwrap(),
)
.await;
assert!(
body.contains(&format!("{}/reopen", paths::case_detail(case_id))),
"detail page of a closed case must offer a reopen form"
);
assert!(
!body.contains(&format!("{}\"", paths::case_close(case_id))),
"detail page of a closed case must NOT offer a close form"
);
}
#[tokio::test]
async fn close_redirects_back_to_referer_query() {
// Close from the show-closed listing must keep ?show_closed=1 so
// the user stays in the view they were in.
let (cfg, settings) = cfg_llm("close-ref-q");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, "");
req.headers_mut()
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/cases?show_closed=1"
);
}
#[tokio::test]
async fn close_from_detail_redirects_to_list_preserving_query() {
// Close from /cases/{uuid}?show_closed=1 must NOT redirect back
// to the detail page (it 404s after the close). The uuid segment
// is stripped; the query survives so the user lands in show-closed.
let (cfg, settings) = cfg_llm("close-ref-det");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, "");
req.headers_mut().insert(
header::REFERER,
format!("{}?show_closed=1", paths::case_detail(case_id))
.parse()
.unwrap(),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/cases?show_closed=1",
"close from detail must redirect to the LIST with the query intact"
);
}
#[tokio::test]
async fn reopen_redirects_back_to_referer() {
// User reopens a case from the show-closed listing. The redirect
// must preserve `?show_closed=1` so the user stays in that view
// instead of being ejected back to the default open-only listing.
let (cfg, settings) = cfg_llm("reopen-ref");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
let mut req = csrf_form_post(paths::case_reopen(case_id), &cookie, &csrf, "");
req.headers_mut()
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/cases?show_closed=1",
"reopen must redirect back to the referer path (preserving the query)"
);
}
#[tokio::test]
async fn reopen_is_noop_on_open_case() {
let (cfg, settings) = cfg_llm("reopen-2");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_reopen(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(!case_dir.join(CLOSE_MARKER).exists());
}
#[tokio::test]
async fn purge_closed_removes_directory() {
// purge-closed is admin-only (matches the UI, which hides the bar).
let (cfg, settings) = cfg_llm_admin("purge-1");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert!(case_dir.join(CLOSE_MARKER).exists());
let resp = app
.oneshot(csrf_form_post(
paths::PURGE_CLOSED,
&cookie,
&csrf,
"confirm=yes",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(
!case_dir.exists(),
"case directory must be removed after purge"
);
}
#[tokio::test]
async fn purge_requires_confirm_param() {
let (cfg, settings) = cfg_llm_admin("purge-2");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
let resp = app
.oneshot(csrf_form_post(paths::PURGE_CLOSED, &cookie, &csrf, ""))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(
case_dir.exists(),
"case directory must survive purge without confirm=yes"
);
assert!(case_dir.join(CLOSE_MARKER).exists());
}
#[tokio::test]
async fn purge_closed_keeps_open_cases() {
let (cfg, settings) = cfg_llm_admin("purge-3");
let closed_id = "11111111-1111-1111-1111-111111111111";
let open_id = "22222222-2222-2222-2222-222222222222";
let dir_closed = seed_case(&cfg.data_path, "dr_a", closed_id);
let dir_open = seed_case(&cfg.data_path, "dr_a", open_id);
seed_recording(&dir_closed, "2026-04-15T10-00-00Z", Some("a"));
seed_recording(&dir_open, "2026-04-15T10-00-00Z", Some("b"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(closed_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
let resp = app
.oneshot(csrf_form_post(
paths::PURGE_CLOSED,
&cookie,
&csrf,
"confirm=yes",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(!dir_closed.exists(), "closed case must be purged");
assert!(dir_open.exists(), "open case must survive the purge");
}
/// Regression guard: purge-closed is destructive and admin-only. A
/// non-admin POST must be rejected with 403 and leave the closed case
/// directory intact.
#[tokio::test]
async fn purge_closed_rejected_for_non_admin() {
// Default `test_user` is role=doctor.
let (cfg, settings) = cfg_llm("purge-nonadmin");
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("ok"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// Close the case first so there is something for purge to target.
let _ = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
let resp = app
.oneshot(csrf_form_post(
paths::PURGE_CLOSED,
&cookie,
&csrf,
"confirm=yes",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(case_dir.exists(), "non-admin must not be able to purge");
}
#[tokio::test]
async fn bulk_close_shares_one_closed_at_timestamp() {
// Bulk actions are admin-only since the UI hides the selection
// from non-admins; the handler enforces the same gate.
let (cfg, settings) = cfg_llm_admin("bulk-c");
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&cfg.data_path, "dr_a", case_a);
let dir_b = seed_case(&cfg.data_path, "dr_a", case_b);
seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a"));
seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = format!(
"action={}&case_id={case_a}&case_id={case_b}",
BulkAction::Close
);
let resp = app
.oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let m_a: Value =
serde_json::from_str(&std::fs::read_to_string(dir_a.join(CLOSE_MARKER)).unwrap()).unwrap();
let m_b: Value =
serde_json::from_str(&std::fs::read_to_string(dir_b.join(CLOSE_MARKER)).unwrap()).unwrap();
assert_eq!(
m_a["closed_at"], m_b["closed_at"],
"bulk-close must share one closed_at timestamp across its selection"
);
}
#[tokio::test]
async fn bulk_analyze_processes_each_selected_case() {
// Bulk analyze is admin-only (UI + handler).
let (cfg, settings) = cfg_llm_admin("bulk-a");
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&cfg.data_path, "dr_a", case_a);
let dir_b = seed_case(&cfg.data_path, "dr_a", case_b);
seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a"));
seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b"));
let (app, store, in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = format!(
"action={}&case_id={case_a}&case_id={case_b}",
BulkAction::Analyze
);
let resp = app
.oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(in_flight.contains(&dir_a));
assert!(in_flight.contains(&dir_b));
let mut received = Vec::new();
while let Ok(job) = analyze_rx.try_recv() {
received.push(job.case_dir);
}
assert_eq!(received.len(), 2, "both selected cases must be enqueued");
assert!(received.iter().any(|p| p == &dir_a));
assert!(received.iter().any(|p| p == &dir_b));
}
#[tokio::test]
async fn recovery_skips_closed_cases() {
let tmp = unique_tmpdir("analyze-rec-closed");
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
case_dir.join(CLOSE_MARKER),
r#"{"closed_at":"2026-04-15T10:00:00Z"}"#,
)
.unwrap();
let in_flight = analyze::InFlight::new();
in_flight.try_claim(&case_dir);
let summary = analyze::recovery::boot_scan_state(
&tmp,
std::time::Duration::ZERO,
std::time::SystemTime::now(),
std::time::SystemTime::UNIX_EPOCH,
&in_flight,
)
.await;
assert_eq!(
summary,
analyze::recovery::BootStateSummary::default(),
"closed case must not appear in any state bucket"
);
}
// ---------------------------------------------------------------------
// Admin-only reset
// ---------------------------------------------------------------------
#[tokio::test]
async fn reset_case_clears_derived_and_unfails_audio() {
let (cfg, settings) = cfg_llm_admin("reset-admin");
let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&cfg.data_path, "dr_a", case_id);
// Good recording with transcript.
seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo"));
// A failed recording: raw audio written, but filename carries `.failed`.
std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap();
// Derived artefacts the reset must wipe.
std::fs::write(
dir.join(ONELINER_FILENAME),
br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#,
)
.unwrap();
write_document_for_test(&dir, "# Doc\n", "1970-01-01T00:00:00Z");
// Failure marker from a prior aborted run — the bonus-fix says
// reset must wipe this too, otherwise the case stays classified
// as `Failed` forever after the user explicitly requested a fresh
// start.
std::fs::write(
dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T09:00:00Z","reason":"x","failed_at":"2026-04-15T09:01:00Z"}"#,
)
.unwrap();
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_reset(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
&format!("/cases/{case_id}")
);
// Audio preserved, .failed renamed to .m4a.
assert!(dir.join("2026-04-15T09-00-00Z.m4a").exists());
assert!(dir.join("2026-04-15T09-05-00Z.m4a").exists());
assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists());
// Derived artefacts gone.
assert!(!dir.join("2026-04-15T09-00-00Z.json").exists());
assert!(!dir.join(ONELINER_FILENAME).exists());
assert!(!dir.join(DOCUMENT_FILE).exists());
assert!(
!dir.join(".analysis_failed.json").exists(),
"reset must clear the failure marker so the case is no longer Failed"
);
}
#[tokio::test]
async fn reset_case_requires_admin() {
// Default `test_user` has role=doctor, not admin.
let (cfg, settings) = cfg_llm("reset-nonadmin");
let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo"));
write_document_for_test(&dir, "# Doc\n", "1970-01-01T00:00:00Z");
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_reset(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Nothing touched.
assert!(dir.join("2026-04-15T09-00-00Z.json").exists());
assert!(dir.join(DOCUMENT_FILE).exists());
}
#[tokio::test]
async fn bulk_reset_processes_multiple_cases_admin_only() {
let (cfg, settings) = TestConfig::new()
.with_label("bulk-reset")
.with_user(test_admin("dr_a"))
.with_user(test_user("dr_b"))
.with_llm("http://unused")
.build_pair();
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&cfg.data_path, "dr_a", case_a);
let dir_b = seed_case(&cfg.data_path, "dr_a", case_b);
seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a"));
seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b"));
write_document_for_test(&dir_a, "A", "1970-01-01T00:00:00Z");
write_document_for_test(&dir_b, "B", "1970-01-01T00:00:00Z");
// dr_b gets its own case to verify non-admin branch without clobbering dr_a.
let case_c = "33333333-3333-3333-3333-333333333333";
let dir_c = seed_case(&cfg.data_path, "dr_b", case_c);
seed_recording(&dir_c, "2026-04-15T11-00-00Z", Some("c"));
write_document_for_test(&dir_c, "C", "1970-01-01T00:00:00Z");
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
// Admin: bulk reset succeeds.
let (cookie_admin, csrf_admin) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = format!(
"action={}&case_id={case_a}&case_id={case_b}",
BulkAction::Reset
);
let resp = app
.clone()
.oneshot(csrf_form_post(
paths::BULK,
&cookie_admin,
&csrf_admin,
&body,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(!dir_a.join(DOCUMENT_FILE).exists());
assert!(!dir_b.join(DOCUMENT_FILE).exists());
assert!(!dir_a.join("2026-04-15T10-00-00Z.json").exists());
assert!(!dir_b.join("2026-04-15T10-05-00Z.json").exists());
// Non-admin: bulk reset is rejected, dr_b's case untouched.
let (cookie_doctor, csrf_doctor) = login_with_csrf(&app, &store, "dr_b", "s").await;
let body = format!("action={}&case_id={case_c}", BulkAction::Reset);
let resp = app
.oneshot(csrf_form_post(
paths::BULK,
&cookie_doctor,
&csrf_doctor,
&body,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(dir_c.join(DOCUMENT_FILE).exists());
assert!(dir_c.join("2026-04-15T11-00-00Z.json").exists());
}
/// Regression guard: the bulk route handles *all* actions (including
/// `close`) and is admin-only. A non-admin POST must be rejected with
/// 403 and the case must remain open. Matches the UI which no longer
/// renders the bulk bar for non-admins.
#[tokio::test]
async fn bulk_close_rejected_for_non_admin() {
// Default `test_user` is role=doctor.
let (cfg, settings) = cfg_llm("bulk-close-nonadmin");
let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&cfg.data_path, "dr_a", case_id);
seed_recording(&dir, "2026-04-15T10-00-00Z", Some("a"));
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let body = format!("action={}&case_id={case_id}", BulkAction::Close);
let resp = app
.oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Close marker must NOT have been written.
assert!(!dir.join(CLOSE_MARKER).exists());
}
// ---------------------------------------------------------------------
// Ollama-style (no-auth) provider
// ---------------------------------------------------------------------
/// Proves the analysis pipeline works against an OpenAI-compatible endpoint
/// that expects **no** Authorization header — i.e. Ollama's `/v1` surface.
/// Guard mock (registered first, so wiremock picks it up for any request that
/// actually carries the header) must stay at `.expect(0)`; happy mock takes
/// all no-auth calls. This catches any future regression where the client
/// accidentally sends `Authorization: Bearer `.
#[tokio::test]
#[ignore = "needs per-test backend injection (see module comment)"]
async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
let tmp = unique_tmpdir("analyze-ollama");
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_dir).unwrap();
let input = analyze::AnalysisInput {
last_recording_mtime: "2026-04-16T10:00:00Z".to_owned(),
recordings: vec![analyze::RecordingInput {
recorded_at: "2026-04-16T10:00:00Z".to_owned(),
text: "Test.".to_owned(),
}],
backend_id: String::new(),
};
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(wiremock::matchers::header_exists("authorization"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{ "message": { "content": "# Doc via Ollama" } }]
})))
.expect(1)
.mount(&mock)
.await;
// Ollama-style config: explicitly empty api_key — `with_llm_explicit`
// lets the builder carry "" through, whereas `with_llm` would default
// to the hosted-provider test key.
let _ = TestConfig::new()
.with_label("analyze-ollama")
.with_data_path(tmp.clone())
.with_user(test_user("dr_a"))
.with_llm_explicit(mock.uri(), "", "llama3.3:70b")
.build_pair();
let (tx, rx) = analyze::channel();
let client = reqwest::Client::new();
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
let in_flight = analyze::InFlight::new();
let handle = tokio::spawn(analyze::worker::run(
rx,
client,
busy,
in_flight,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
input,
})
.unwrap();
let document_path = case_dir.join(DOCUMENT_FILE);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !document_path.exists() {
if std::time::Instant::now() > deadline {
panic!("document.md not written within 5s");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let content = std::fs::read_to_string(&document_path).unwrap();
assert!(content.contains("Ollama"), "unexpected content: {content}");
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
// ---------------------------------------------------------------------
// Bulk "Aktualisieren" endpoint
// ---------------------------------------------------------------------
/// `POST /cases/analyze-required` walks every UUID-named case and force-
/// enqueues those in `Required`/`Idle`. `Failed` and `Current` cases must
/// stay untouched. The InFlight set is the canonical "would have been
/// enqueued" signal; the channel receiver lets us count delivered jobs.
#[tokio::test]
async fn analyze_required_endpoint_enqueues_only_required_cases() {
use doctate_server::analyze::auto_trigger::FailureMarker;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
fn to_rfc3339(t: std::time::SystemTime) -> String {
OffsetDateTime::from(t).format(&Rfc3339).unwrap()
}
let (cfg, settings) = cfg_llm("ar-1");
let user_root = cfg.data_path.join("dr_a");
// Required: transcribed recording, no document.
let req_id = "11111111-1111-1111-1111-111111111111";
let req_dir = seed_case(&cfg.data_path, "dr_a", req_id);
seed_recording(&req_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme"));
// Current: document covers the recording's mtime exactly.
let cur_id = "22222222-2222-2222-2222-222222222222";
let cur_dir = seed_case(&cfg.data_path, "dr_a", cur_id);
seed_recording(&cur_dir, "2026-04-15T10-00-00Z", Some("alles okay"));
let cur_m4a_mtime = std::fs::metadata(cur_dir.join("2026-04-15T10-00-00Z.m4a"))
.unwrap()
.modified()
.unwrap();
write_document_for_test(&cur_dir, "fertig", &to_rfc3339(cur_m4a_mtime));
// Failed: failure marker matches latest recording mtime.
let fail_id = "33333333-3333-3333-3333-333333333333";
let fail_dir = seed_case(&cfg.data_path, "dr_a", fail_id);
seed_recording(&fail_dir, "2026-04-15T10-00-00Z", Some("kaputt"));
let fail_m4a_mtime = std::fs::metadata(fail_dir.join("2026-04-15T10-00-00Z.m4a"))
.unwrap()
.modified()
.unwrap();
let marker = FailureMarker {
last_recording_mtime: to_rfc3339(fail_m4a_mtime),
reason: "test".into(),
failed_at: "2026-04-15T10-05-00Z".into(),
backend_id: None,
details: None,
};
std::fs::write(
fail_dir.join(".analysis_failed.json"),
serde_json::to_vec(&marker).unwrap(),
)
.unwrap();
let (app, store, in_flight, mut analyze_rx) =
doctate_server::create_router_with_full_handles(cfg, settings);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let resp = app
.oneshot(csrf_form_post(paths::ANALYZE_REQUIRED, &cookie, &csrf, ""))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"/cases",
"must redirect back to the case list"
);
let req_path = user_root.join(req_id);
let cur_path = user_root.join(cur_id);
let fail_path = user_root.join(fail_id);
// Required → claim held + job in channel.
assert!(
in_flight.contains(&req_path),
"Required case must have a claim"
);
// Current → no claim, document.json untouched.
assert!(
!in_flight.contains(&cur_path),
"Current case must not be claimed"
);
assert!(
cur_path.join(DOCUMENT_FILE).exists(),
"Current case's document must remain in place"
);
// Failed → no claim. Marker stays so the user sees the failure
// banner on the case page and can retry per-backend manually.
assert!(
!in_flight.contains(&fail_path),
"Failed case must not be claimed"
);
assert!(
fail_path.join(".analysis_failed.json").exists(),
"failure marker must persist"
);
let mut received = Vec::new();
while let Ok(job) = analyze_rx.try_recv() {
received.push(job.case_dir);
}
assert_eq!(
received,
vec![req_path],
"only the Required case must be enqueued"
);
}