0660727faf
This commit introduces a significant refactor to the auto-trigger and recovery mechanisms for the analysis pipeline. The core change is the introduction of a more granular `CaseAnalysisState` enum, replacing the simpler `AutoDecision`. The `evaluate_state` function now computes the full lifecycle state of a case, considering factors like the presence of recordings, pending transcripts, queued jobs, failure markers, document staleness, and an idle threshold. This provides a richer understanding of the case's status. The `evaluate_case` function is retained as a backwards-compatible adapter for existing callsites and tests. It maps the new `CaseAnalysisState` to the old `AutoDecision` enum, simplifying the decision to "enqueue" or "skip." The `recovery` module is also refactored. The `scan_and_enqueue` function is replaced by `boot_scan_state`, which is now purely observational. It aggregates statistics about cases in different states at boot time and logs them, but it no longer attempts to re-enqueue jobs. The responsibility for triggering analysis now lies with the per-render `try_enqueue_all_for_user` logic, which utilizes the new typed state machine. This refactoring aims to provide more clarity and control over the analysis pipeline's state management, particularly in handling cases that have previously failed or are waiting for further input.
1512 lines
52 KiB
Rust
1512 lines
52 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_writes_input_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) = 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::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
format!("/cases/{case_id}")
|
|
);
|
|
|
|
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
|
|
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 (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"));
|
|
|
|
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 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_input.json` AND `.analysis_failed.json` on disk.
|
|
/// The user clicks "Erneut versuchen" — the handler must accept the
|
|
/// retry instead of rejecting it as 409 Conflict ("Analyse läuft
|
|
/// bereits"). The failure marker is the explicit "worker has given up"
|
|
/// signal that legitimises overwriting the stale sentinel.
|
|
#[tokio::test]
|
|
async fn analyze_retry_after_failure_marker_overwrites_stale_input() {
|
|
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"));
|
|
|
|
// Simulate the post-failure on-disk state: stale input from the
|
|
// attempt the worker gave up on, plus the matching failure marker.
|
|
std::fs::write(
|
|
case_dir.join(ANALYSIS_INPUT_FILE),
|
|
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","recordings":[{"recorded_at":"2026-04-15T10:00:00Z","text":"alter Text"}],"backend_id":""}"#,
|
|
)
|
|
.unwrap();
|
|
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) = 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::SEE_OTHER,
|
|
"retry with failure marker present must succeed, not 409"
|
|
);
|
|
|
|
// Input was rewritten with the fresh transcript — the stale "alter Text"
|
|
// must be gone.
|
|
let raw = std::fs::read_to_string(case_dir.join(ANALYSIS_INPUT_FILE)).unwrap();
|
|
let parsed: Value = serde_json::from_str(&raw).unwrap();
|
|
let recs = parsed["recordings"].as_array().unwrap();
|
|
assert_eq!(recs.len(), 1);
|
|
assert_eq!(recs[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) = 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::SEE_OTHER);
|
|
|
|
let raw = std::fs::read_to_string(case_dir.join(ANALYSIS_INPUT_FILE)).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
|
|
//
|
|
// 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 = 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_FILE),
|
|
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 _ = 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 handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
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_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 = 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_FILE),
|
|
serde_json::to_vec_pretty(&input).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// 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 handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
));
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.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: even a
|
|
/// case sitting in the `Queued` state on disk must NOT be re-enqueued
|
|
/// at startup. The per-page-render `try_enqueue_all_for_user` is the
|
|
/// only auto-trigger.
|
|
#[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();
|
|
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
|
|
|
|
let (_tx, mut rx) = analyze::channel();
|
|
analyze::recovery::boot_scan_state(
|
|
&tmp,
|
|
std::time::Duration::ZERO,
|
|
std::time::SystemTime::now(),
|
|
)
|
|
.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_writes_fresh_input() {
|
|
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) = 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::SEE_OTHER);
|
|
|
|
assert!(
|
|
!case_dir.join(DOCUMENT_FILE).exists(),
|
|
"old document must be deleted before re-analysis"
|
|
);
|
|
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
|
|
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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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) = 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::Analyze
|
|
);
|
|
let resp = app
|
|
.oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
assert!(dir_a.join(ANALYSIS_INPUT_FILE).exists());
|
|
assert!(dir_b.join(ANALYSIS_INPUT_FILE).exists());
|
|
}
|
|
|
|
#[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(ANALYSIS_INPUT_FILE), "{}").unwrap();
|
|
std::fs::write(
|
|
case_dir.join(CLOSE_MARKER),
|
|
r#"{"closed_at":"2026-04-15T10:00:00Z"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let summary = analyze::recovery::boot_scan_state(
|
|
&tmp,
|
|
std::time::Duration::ZERO,
|
|
std::time::SystemTime::now(),
|
|
)
|
|
.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");
|
|
std::fs::write(dir.join(ANALYSIS_INPUT_FILE), "{}").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_INPUT_FILE).exists());
|
|
}
|
|
|
|
#[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 = 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_FILE),
|
|
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 — `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 handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
));
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.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;
|
|
}
|