13e1950050
The gazetteer has been refactored to act as a post-AI normalization filter. Previously, it was used to annotate LLM input with potential corrections from a vocabulary. This approach was ineffective because LLMs often override such hints. The new approach applies the gazetteer *after* the LLM has generated its output. This allows for deterministic correction of known terminology, including fixing LLM output drift (e.g., anglicized drug names). Key changes: - `annotate` function renamed to `replace`. - The output format changes from `Canonical [?original]` to simply the `Canonical` form. - The gazetteer now operates on the final LLM output before persistence, ensuring consistency. - Tests have been updated to reflect this new behavior, focusing on the final output rather than the LLM request payload.
1015 lines
37 KiB
Rust
1015 lines
37 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::{header, Request, StatusCode};
|
|
use doctate_server::analyze;
|
|
use doctate_server::config::{Config, User};
|
|
use serde_json::{json, Value};
|
|
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));
|
|
|
|
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));
|
|
|
|
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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Document view
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn document_view_reads_document() {
|
|
let config = config_with_llm(unique_tmp("dv"), "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"), "der Inhalt").unwrap();
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/web/cases/{case_id}/document"))
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
|
assert!(body_str.contains("der Inhalt"), "expected content in body");
|
|
assert!(body_str.contains("Dokument"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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();
|
|
// Ensure timestamps differ at sub-second resolution (RFC3339 includes
|
|
// fractional seconds via the Rfc3339 well-known format).
|
|
tokio::time::sleep(Duration::from_millis(20)).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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn document_view_without_document_returns_404() {
|
|
let config = config_with_llm(unique_tmp("dv2"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
seed_case(&config.data_path, "dr_a", case_id);
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/web/cases/{case_id}/document"))
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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));
|
|
|
|
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;
|
|
}
|