Files
doctate/server/tests/analyze_test.rs
T
Brummel 1d702e2d85 Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.

Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
  struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
  reloads.
- Integration points: Workers and route handlers now emit events when
  relevant state changes occur.
2026-04-19 23:33:53 +02:00

1044 lines
36 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_server::analyze;
use doctate_server::config::{Config, User};
use serde_json::{Value, json};
use tower::util::ServiceExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// ---------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------
fn unique_tmp(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"doctate-close-{label}-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
fn make_user(slug: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
}
}
fn make_admin(slug: &str) -> User {
let mut u = make_user(slug);
u.role = "admin".into();
u
}
fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc<Config> {
config_with_llm_users(data_path, llm_url, vec![make_user("dr_a")])
}
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
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,
llm_url,
llm_api_key: "test-key".into(),
llm_model: "test-model".into(),
..Config::test_default()
})
}
fn config_without_llm(data_path: PathBuf) -> Arc<Config> {
let users = vec![make_user("dr_a")];
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()
})
}
fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
let dir = data_path.join(slug).join(case_id);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) {
let filename = format!("2026-04-15T{ts_hms}Z.m4a");
std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap();
if let Some(text) = transcript {
let tx_name = format!("2026-04-15T{ts_hms}Z.transcript.txt");
std::fs::write(case_dir.join(tx_name), text).unwrap();
}
}
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 analyze_request(case_id: &str, cookie: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/analyze"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
fn delete_request(case_id: &str, cookie: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/delete"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
// ---------------------------------------------------------------------
// HTTP-level precondition tests
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_without_cookie_redirects_to_login() {
let config = config_with_llm(unique_tmp("a"), "http://unused".into());
let app = doctate_server::create_router(config);
let case_id = "11111111-1111-1111-1111-111111111111";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/analyze"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
}
#[tokio::test]
async fn analyze_foreign_case_returns_404() {
let config = config_with_llm(unique_tmp("b"), "http://unused".into());
// Seed a case owned by someone else.
let foreign_case = "22222222-2222-2222-2222-222222222222";
let foreign_dir = config.data_path.join("dr_other").join(foreign_case);
std::fs::create_dir_all(&foreign_dir).unwrap();
seed_recording(&foreign_dir, "10-00-00", Some("text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(foreign_case, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn analyze_invalid_uuid_returns_400() {
let config = config_with_llm(unique_tmp("c"), "http://unused".into());
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request("not-a-uuid", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn analyze_case_without_recordings_returns_400() {
let config = config_with_llm(unique_tmp("d"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
seed_case(&config.data_path, "dr_a", case_id);
// No .m4a files.
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn analyze_case_with_missing_transcript_returns_400() {
let config = config_with_llm(unique_tmp("e"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
seed_recording(&case_dir, "10-05-00", None); // still transcribing
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn analyze_case_without_llm_returns_503() {
let config = config_without_llm(unique_tmp("f"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
// ---------------------------------------------------------------------
// HTTP-level happy path + race
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_case_happy_path_writes_input_and_redirects() {
let config = config_with_llm(unique_tmp("g"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("Patient klagt über Knie."));
seed_recording(
&case_dir,
"10-05-00",
Some("Korrektur: links, nicht rechts."),
);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases"
);
let input_path = case_dir.join("analysis_input.json");
assert!(input_path.exists(), "analysis_input.json missing");
let raw = std::fs::read_to_string(&input_path).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
let recs = parsed["recordings"].as_array().unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0]["recorded_at"], "2026-04-15T10:00:00Z");
assert_eq!(recs[0]["text"], "Patient klagt über Knie.");
assert_eq!(recs[1]["recorded_at"], "2026-04-15T10:05:00Z");
assert!(parsed["last_recording_mtime"].is_string());
}
#[tokio::test]
async fn analyze_case_second_time_returns_409() {
let config = config_with_llm(unique_tmp("h"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let first = app
.clone()
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::SEE_OTHER);
let second = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn analyze_case_skips_blank_transcript_from_recordings() {
let config = config_with_llm(unique_tmp("i"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("echt"));
seed_recording(&case_dir, "10-05-00", Some(" ")); // blank
seed_recording(&case_dir, "10-10-00", Some("auch echt"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let raw = std::fs::read_to_string(case_dir.join("analysis_input.json")).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
let recs = parsed["recordings"].as_array().unwrap();
assert_eq!(recs.len(), 2, "blank transcript must be filtered out");
}
// ---------------------------------------------------------------------
// Worker + wiremock integration
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_worker_writes_document_via_wiremock() {
let tmp = unique_tmp("w");
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
std::fs::create_dir_all(&case_dir).unwrap();
let input = json!({
"last_recording_mtime": "2026-04-15T10:00:00Z",
"recordings": [
{ "recorded_at": "2026-04-15T10:00:00Z", "text": "Patient mit Knie." }
]
});
std::fs::write(
case_dir.join("analysis_input.json"),
serde_json::to_vec_pretty(&input).unwrap(),
)
.unwrap();
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 config = config_with_llm(tmp.clone(), mock.uri());
let (tx, rx) = analyze::channel();
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 handle = tokio::spawn(analyze::worker::run(
rx,
config,
client,
busy,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
})
.unwrap();
// Poll for the document (worker is in a separate task).
let document_path = case_dir.join("document.md");
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]
async fn analyze_worker_normalizes_llm_output() {
let tmp = unique_tmp("g");
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_dir).unwrap();
let input = json!({
"last_recording_mtime": "2026-04-15T10:00:00Z",
"recordings": [
{ "recorded_at": "2026-04-15T10:00:00Z", "text": "Patient mit Blutung im Zerebrum." }
]
});
std::fs::write(
case_dir.join("analysis_input.json"),
serde_json::to_vec_pretty(&input).unwrap(),
)
.unwrap();
// Vocab directory with a single anatomy entry.
let vocab_dir = unique_tmp("vg");
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 config = config_with_llm(tmp.clone(), mock.uri());
let (tx, rx) = analyze::channel();
let client = reqwest::Client::new();
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let handle = tokio::spawn(analyze::worker::run(
rx,
config,
client,
busy,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
})
.unwrap();
let document_path = case_dir.join("document.md");
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());
let mut names: Vec<_> = std::fs::read_dir(&case_dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|s| s.ends_with(".transcript.txt"))
.unwrap_or(false)
})
.collect();
names.sort();
for path in names {
let text = std::fs::read_to_string(&path).unwrap();
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
// ---------------------------------------------------------------------
#[tokio::test]
async fn recovery_enqueues_pending_analysis() {
let tmp = unique_tmp("r1");
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("analysis_input.json"), "{}").unwrap();
let (tx, mut rx) = analyze::channel();
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
let job = rx.try_recv().expect("expected one job");
assert_eq!(job.case_dir, case_dir);
assert!(rx.try_recv().is_err(), "no further jobs expected");
}
#[tokio::test]
async fn recovery_skips_completed_analysis() {
let tmp = unique_tmp("r2");
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("analysis_input.json"), "{}").unwrap();
std::fs::write(case_dir.join("document.md"), "done").unwrap();
let (tx, mut rx) = analyze::channel();
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
assert!(
rx.try_recv().is_err(),
"completed analysis must not re-enqueue"
);
}
// ---------------------------------------------------------------------
// Re-analysis path: same handler, version derived from existing documents
// ---------------------------------------------------------------------
#[tokio::test]
async fn analyze_deletes_old_document_and_writes_fresh_input() {
let config = config_with_llm(unique_tmp("rn-5"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
std::fs::write(case_dir.join("document.md"), "altes Dokument").unwrap();
seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme"));
seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(
!case_dir.join("document.md").exists(),
"old document must be deleted before re-analysis"
);
let input_path = case_dir.join("analysis_input.json");
assert!(input_path.exists(), "analysis_input.json missing");
let parsed: Value =
serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap();
assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2);
}
// ---------------------------------------------------------------------
// Soft-delete + Undo
// ---------------------------------------------------------------------
#[tokio::test]
async fn delete_writes_marker_and_redirects() {
let config = config_with_llm(unique_tmp("del-1"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(delete_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases"
);
let marker = case_dir.join(".deleted");
assert!(marker.exists(), ".deleted marker missing");
let raw = std::fs::read_to_string(&marker).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
assert!(parsed["batch"].is_string(), "batch must be a UUID string");
assert!(
parsed["deleted_at"].is_string(),
"deleted_at must be a string"
);
}
#[tokio::test]
async fn deleted_case_returns_404_on_detail() {
let config = config_with_llm(unique_tmp("del-2"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.clone()
.oneshot(delete_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/web/cases/{case_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn undo_delete_restores_latest_batch_only() {
let config = config_with_llm(unique_tmp("undo-1"), "http://unused".into());
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
seed_recording(&dir_a, "10-00-00", Some("a"));
seed_recording(&dir_b, "10-05-00", Some("b"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
// Two separate delete clicks → two distinct batches.
let _ = app
.clone()
.oneshot(delete_request(case_a, &cookie))
.await
.unwrap();
// Batch IDs derive from `now_rfc3339()`, which rounds to whole
// seconds — so the sleep must cross a second boundary for the two
// deletes to land in distinct batches.
tokio::time::sleep(Duration::from_millis(1100)).await;
let _ = app
.clone()
.oneshot(delete_request(case_b, &cookie))
.await
.unwrap();
let undo = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/undo-delete")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(undo.status(), StatusCode::SEE_OTHER);
// case_b (latest delete) restored, case_a still deleted.
assert!(
!dir_b.join(".deleted").exists(),
"case_b marker should be gone"
);
assert!(dir_a.join(".deleted").exists(), "case_a marker must remain");
}
#[tokio::test]
async fn bulk_delete_shares_one_batch_uuid() {
let config = config_with_llm(unique_tmp("bulk-d"), "http://unused".into());
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
seed_recording(&dir_a, "10-00-00", Some("a"));
seed_recording(&dir_b, "10-05-00", Some("b"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let body = format!("action=delete&case_id={case_a}&case_id={case_b}");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.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(".deleted")).unwrap()).unwrap();
let m_b: Value =
serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap();
assert_eq!(
m_a["batch"], m_b["batch"],
"bulk-delete must share batch UUID"
);
}
#[tokio::test]
async fn bulk_analyze_processes_each_selected_case() {
let config = config_with_llm(unique_tmp("bulk-a"), "http://unused".into());
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
seed_recording(&dir_a, "10-00-00", Some("a"));
seed_recording(&dir_b, "10-05-00", Some("b"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let body = format!("action=analyze&case_id={case_a}&case_id={case_b}");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(dir_a.join("analysis_input.json").exists());
assert!(dir_b.join("analysis_input.json").exists());
}
#[tokio::test]
async fn recovery_skips_deleted_cases() {
let tmp = unique_tmp("rec-del");
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("analysis_input.json"), "{}").unwrap();
std::fs::write(
case_dir.join(".deleted"),
r#"{"batch":"00000000-0000-0000-0000-000000000000","deleted_at":"2026-04-15T10:00:00Z"}"#,
)
.unwrap();
let (tx, mut rx) = analyze::channel();
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
assert!(rx.try_recv().is_err(), "deleted case must not be enqueued");
}
// ---------------------------------------------------------------------
// Admin-only reset
// ---------------------------------------------------------------------
fn reset_request(case_id: &str, cookie: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/reset"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn reset_case_clears_derived_and_unfails_audio() {
let tmp = unique_tmp("reset-admin");
let config = config_with_llm_users(
tmp.clone(),
"http://unused".into(),
vec![make_admin("dr_a")],
);
let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&config.data_path, "dr_a", case_id);
// Good recording with transcript.
seed_recording(&dir, "09-00-00", 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.txt"), "Knie re.").unwrap();
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases");
// 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.transcript.txt").exists());
assert!(!dir.join("oneliner.txt").exists());
assert!(!dir.join("document.md").exists());
assert!(!dir.join("analysis_input.json").exists());
}
#[tokio::test]
async fn reset_case_requires_admin() {
let tmp = unique_tmp("reset-nonadmin");
// Default `make_user` has role=doctor, not admin.
let config = config_with_llm(tmp.clone(), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&dir, "09-00-00", Some("hallo"));
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Nothing touched.
assert!(dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
assert!(dir.join("document.md").exists());
}
#[tokio::test]
async fn bulk_reset_processes_multiple_cases_admin_only() {
let tmp = unique_tmp("bulk-reset");
let config = config_with_llm_users(
tmp.clone(),
"http://unused".into(),
vec![make_admin("dr_a"), make_user("dr_b")],
);
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
seed_recording(&dir_a, "10-00-00", Some("a"));
seed_recording(&dir_b, "10-05-00", Some("b"));
std::fs::write(dir_a.join("document.md"), "A").unwrap();
std::fs::write(dir_b.join("document.md"), "B").unwrap();
// 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(&config.data_path, "dr_b", case_c);
seed_recording(&dir_c, "11-00-00", Some("c"));
std::fs::write(dir_c.join("document.md"), "C").unwrap();
let app = doctate_server::create_router(config);
// Admin: bulk reset succeeds.
let cookie_admin = login(app.clone(), "dr_a").await;
let body = format!("action=reset&case_id={case_a}&case_id={case_b}");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie_admin)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(!dir_a.join("document.md").exists());
assert!(!dir_b.join("document.md").exists());
assert!(!dir_a.join("2026-04-15T10-00-00Z.transcript.txt").exists());
assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists());
// Non-admin: bulk reset is rejected, dr_b's case untouched.
let cookie_doctor = login(app.clone(), "dr_b").await;
let body = format!("action=reset&case_id={case_c}");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie_doctor)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(dir_c.join("document.md").exists());
assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").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]
async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
let tmp = unique_tmp("ollama");
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_dir).unwrap();
let input = json!({
"last_recording_mtime": "2026-04-16T10:00:00Z",
"recordings": [
{ "recorded_at": "2026-04-16T10:00:00Z", "text": "Test." }
]
});
std::fs::write(
case_dir.join("analysis_input.json"),
serde_json::to_vec_pretty(&input).unwrap(),
)
.unwrap();
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. Inline because
// `config_with_llm` hard-codes "test-key" for hosted-provider tests.
let users = vec![make_user("dr_a")];
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
let config = Arc::new(Config {
data_path: tmp.clone(),
users,
api_keys,
llm_url: mock.uri(),
llm_api_key: String::new(),
llm_model: "llama3.3:70b".into(),
..Config::test_default()
});
let (tx, rx) = analyze::channel();
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 handle = tokio::spawn(analyze::worker::run(
rx,
config,
client,
busy,
vocab,
doctate_server::events::channel(),
));
tx.send(analyze::AnalyzeJob {
case_dir: case_dir.clone(),
})
.unwrap();
let document_path = case_dir.join("document.md");
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;
}