|
|
|
@@ -188,7 +188,7 @@ async fn analyze_case_without_llm_returns_503() {
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn analyze_case_happy_path_writes_input_and_redirects() {
|
|
|
|
|
async fn analyze_case_happy_path_sends_job_and_redirects() {
|
|
|
|
|
let (cfg, settings) = cfg_llm("g");
|
|
|
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
|
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
|
|
@@ -203,7 +203,8 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
|
|
|
|
|
Some("Korrektur: links, nicht rechts."),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
|
|
|
|
|
let (app, store, in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let resp = app
|
|
|
|
@@ -225,17 +226,26 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
|
|
|
|
|
format!("/cases/{case_id}")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
|
|
|
|
|
assert!(input_path.exists(), "analysis_input.json missing");
|
|
|
|
|
// Slot is claimed for the duration of the worker's run — proxied
|
|
|
|
|
// here by the receiver still holding the un-consumed job.
|
|
|
|
|
assert!(
|
|
|
|
|
in_flight.contains(&case_dir),
|
|
|
|
|
"in-flight slot must be held until the worker drops it"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let 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());
|
|
|
|
|
let job = analyze_rx
|
|
|
|
|
.try_recv()
|
|
|
|
|
.expect("handler must enqueue exactly one job");
|
|
|
|
|
assert_eq!(job.case_dir, case_dir);
|
|
|
|
|
assert_eq!(job.input.recordings.len(), 2);
|
|
|
|
|
assert_eq!(job.input.recordings[0].recorded_at, "2026-04-15T10:00:00Z");
|
|
|
|
|
assert_eq!(job.input.recordings[0].text, "Patient klagt über Knie.");
|
|
|
|
|
assert_eq!(job.input.recordings[1].recorded_at, "2026-04-15T10:05:00Z");
|
|
|
|
|
assert!(!job.input.last_recording_mtime.is_empty());
|
|
|
|
|
assert!(
|
|
|
|
|
analyze_rx.try_recv().is_err(),
|
|
|
|
|
"exactly one job must be enqueued"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
@@ -245,7 +255,12 @@ async fn analyze_case_second_time_returns_409() {
|
|
|
|
|
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);
|
|
|
|
|
// Hold the receiver alive across both requests — otherwise the
|
|
|
|
|
// first send fails, the slot is released, and the second click
|
|
|
|
|
// would be accepted as a fresh trigger. In production the worker
|
|
|
|
|
// keeps the slot held via `InFlightGuard` until it finishes.
|
|
|
|
|
let (app, store, _in_flight, _analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let first = app
|
|
|
|
@@ -273,32 +288,29 @@ async fn analyze_case_second_time_returns_409() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
/// leaving `.analysis_failed.json` on disk. The user clicks "Erneut
|
|
|
|
|
/// versuchen" — the handler must accept the retry. With the in-flight
|
|
|
|
|
/// marker now process-local, no on-disk sentinel can falsely block the
|
|
|
|
|
/// retry; the explicit user click claims a fresh slot and sends a job
|
|
|
|
|
/// with the current transcripts.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn analyze_retry_after_failure_marker_overwrites_stale_input() {
|
|
|
|
|
async fn analyze_retry_after_failure_marker_succeeds() {
|
|
|
|
|
let (cfg, settings) = cfg_llm("retry-after-fail");
|
|
|
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
|
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
|
|
|
seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("frischer Text"));
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
// Failure marker from the prior aborted attempt remains on disk —
|
|
|
|
|
// the new model leaves it in place; the worker overwrites it on
|
|
|
|
|
// re-failure or removes it on success.
|
|
|
|
|
std::fs::write(
|
|
|
|
|
case_dir.join(".analysis_failed.json"),
|
|
|
|
|
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout","failed_at":"2026-04-15T10:05:00Z"}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
|
|
|
|
|
let (app, store, _in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let resp = app
|
|
|
|
@@ -316,13 +328,13 @@ async fn analyze_retry_after_failure_marker_overwrites_stale_input() {
|
|
|
|
|
"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");
|
|
|
|
|
// The job carries the fresh transcript — there is no on-disk state
|
|
|
|
|
// to validate any more, the channel is the truth.
|
|
|
|
|
let job = analyze_rx
|
|
|
|
|
.try_recv()
|
|
|
|
|
.expect("retry must enqueue a fresh job");
|
|
|
|
|
assert_eq!(job.input.recordings.len(), 1);
|
|
|
|
|
assert_eq!(job.input.recordings[0].text, "frischer Text");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
@@ -334,7 +346,8 @@ async fn analyze_case_skips_blank_transcript_from_recordings() {
|
|
|
|
|
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 (app, store, _in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let resp = app
|
|
|
|
@@ -348,10 +361,13 @@ async fn analyze_case_skips_blank_transcript_from_recordings() {
|
|
|
|
|
.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");
|
|
|
|
|
let job = analyze_rx.try_recv().expect("job must be enqueued");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
job.input.recordings.len(),
|
|
|
|
|
2,
|
|
|
|
|
"blank transcript must be filtered out"
|
|
|
|
|
);
|
|
|
|
|
let _ = case_dir; // name kept for grep symmetry with the seed calls
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
@@ -376,17 +392,14 @@ async fn analyze_worker_writes_document_via_wiremock() {
|
|
|
|
|
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 input = analyze::AnalysisInput {
|
|
|
|
|
last_recording_mtime: "2026-04-15T10:00:00Z".to_owned(),
|
|
|
|
|
recordings: vec![analyze::RecordingInput {
|
|
|
|
|
recorded_at: "2026-04-15T10:00:00Z".to_owned(),
|
|
|
|
|
text: "Patient mit Knie.".to_owned(),
|
|
|
|
|
}],
|
|
|
|
|
backend_id: String::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mock = MockServer::start().await;
|
|
|
|
|
Mock::given(method("POST"))
|
|
|
|
@@ -410,16 +423,19 @@ async fn analyze_worker_writes_document_via_wiremock() {
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
|
|
|
let in_flight = analyze::InFlight::new();
|
|
|
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
|
|
|
rx,
|
|
|
|
|
client,
|
|
|
|
|
busy,
|
|
|
|
|
in_flight,
|
|
|
|
|
vocab,
|
|
|
|
|
doctate_server::events::channel(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
|
|
|
case_dir: case_dir.clone(),
|
|
|
|
|
input,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
@@ -455,17 +471,14 @@ async fn analyze_worker_normalizes_llm_output() {
|
|
|
|
|
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();
|
|
|
|
|
let input = analyze::AnalysisInput {
|
|
|
|
|
last_recording_mtime: "2026-04-15T10:00:00Z".to_owned(),
|
|
|
|
|
recordings: vec![analyze::RecordingInput {
|
|
|
|
|
recorded_at: "2026-04-15T10:00:00Z".to_owned(),
|
|
|
|
|
text: "Patient mit Blutung im Zerebrum.".to_owned(),
|
|
|
|
|
}],
|
|
|
|
|
backend_id: String::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Vocab directory with a single anatomy entry.
|
|
|
|
|
let vocab_dir = unique_tmpdir("analyze-vocab");
|
|
|
|
@@ -501,16 +514,19 @@ async fn analyze_worker_normalizes_llm_output() {
|
|
|
|
|
let (tx, rx) = analyze::channel();
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
let in_flight = analyze::InFlight::new();
|
|
|
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
|
|
|
rx,
|
|
|
|
|
client,
|
|
|
|
|
busy,
|
|
|
|
|
in_flight,
|
|
|
|
|
vocab,
|
|
|
|
|
doctate_server::events::channel(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
|
|
|
case_dir: case_dir.clone(),
|
|
|
|
|
input,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
@@ -613,23 +629,27 @@ async fn replace_real_case_dry() {
|
|
|
|
|
// 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.
|
|
|
|
|
/// Boot-scan is observation-only after the typed-state refactor: it
|
|
|
|
|
/// must never enqueue jobs. With the in-flight set replacing on-disk
|
|
|
|
|
/// markers, an orphan `analysis_input.json` left over from a
|
|
|
|
|
/// pre-refactor server run is a no-op: the empty `InFlight` set means
|
|
|
|
|
/// the case is not classified as `Queued`.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn recovery_does_not_enqueue_at_boot() {
|
|
|
|
|
let tmp = unique_tmpdir("analyze-r1");
|
|
|
|
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
|
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
|
// Pre-refactor leftover; ignored by the new model.
|
|
|
|
|
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
|
|
|
|
|
|
|
|
|
|
let (_tx, mut rx) = analyze::channel();
|
|
|
|
|
let in_flight = analyze::InFlight::new();
|
|
|
|
|
analyze::recovery::boot_scan_state(
|
|
|
|
|
&tmp,
|
|
|
|
|
std::time::Duration::ZERO,
|
|
|
|
|
std::time::SystemTime::now(),
|
|
|
|
|
std::time::SystemTime::UNIX_EPOCH,
|
|
|
|
|
&in_flight,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
@@ -644,7 +664,7 @@ async fn recovery_does_not_enqueue_at_boot() {
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn analyze_deletes_old_document_and_writes_fresh_input() {
|
|
|
|
|
async fn analyze_deletes_old_document_and_sends_fresh_job() {
|
|
|
|
|
let (cfg, settings) = cfg_llm("rn-5");
|
|
|
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
|
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
|
|
@@ -652,7 +672,8 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
|
|
|
|
|
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 (app, store, _in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let resp = app
|
|
|
|
@@ -670,11 +691,8 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
|
|
|
|
|
!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);
|
|
|
|
|
let job = analyze_rx.try_recv().expect("re-analysis must enqueue");
|
|
|
|
|
assert_eq!(job.input.recordings.len(), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
@@ -1211,7 +1229,8 @@ async fn bulk_analyze_processes_each_selected_case() {
|
|
|
|
|
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 (app, store, in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let body = format!(
|
|
|
|
@@ -1224,8 +1243,15 @@ async fn bulk_analyze_processes_each_selected_case() {
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
|
|
|
|
|
|
assert!(dir_a.join(ANALYSIS_INPUT_FILE).exists());
|
|
|
|
|
assert!(dir_b.join(ANALYSIS_INPUT_FILE).exists());
|
|
|
|
|
assert!(in_flight.contains(&dir_a));
|
|
|
|
|
assert!(in_flight.contains(&dir_b));
|
|
|
|
|
let mut received = Vec::new();
|
|
|
|
|
while let Ok(job) = analyze_rx.try_recv() {
|
|
|
|
|
received.push(job.case_dir);
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(received.len(), 2, "both selected cases must be enqueued");
|
|
|
|
|
assert!(received.iter().any(|p| p == &dir_a));
|
|
|
|
|
assert!(received.iter().any(|p| p == &dir_b));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
@@ -1233,18 +1259,20 @@ 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 in_flight = analyze::InFlight::new();
|
|
|
|
|
in_flight.try_claim(&case_dir);
|
|
|
|
|
let summary = analyze::recovery::boot_scan_state(
|
|
|
|
|
&tmp,
|
|
|
|
|
std::time::Duration::ZERO,
|
|
|
|
|
std::time::SystemTime::now(),
|
|
|
|
|
std::time::SystemTime::UNIX_EPOCH,
|
|
|
|
|
&in_flight,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
@@ -1276,7 +1304,15 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
write_document_for_test(&dir, "# Doc\n", "1970-01-01T00:00:00Z");
|
|
|
|
|
std::fs::write(dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap();
|
|
|
|
|
// Failure marker from a prior aborted run — the bonus-fix says
|
|
|
|
|
// reset must wipe this too, otherwise the case stays classified
|
|
|
|
|
// as `Failed` forever after the user explicitly requested a fresh
|
|
|
|
|
// start.
|
|
|
|
|
std::fs::write(
|
|
|
|
|
dir.join(".analysis_failed.json"),
|
|
|
|
|
r#"{"last_recording_mtime":"2026-04-15T09:00:00Z","reason":"x","failed_at":"2026-04-15T09:01:00Z"}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
@@ -1304,7 +1340,10 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
|
|
|
|
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());
|
|
|
|
|
assert!(
|
|
|
|
|
!dir.join(".analysis_failed.json").exists(),
|
|
|
|
|
"reset must clear the failure marker so the case is no longer Failed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
@@ -1440,17 +1479,14 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
|
|
|
|
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 input = analyze::AnalysisInput {
|
|
|
|
|
last_recording_mtime: "2026-04-16T10:00:00Z".to_owned(),
|
|
|
|
|
recordings: vec![analyze::RecordingInput {
|
|
|
|
|
recorded_at: "2026-04-16T10:00:00Z".to_owned(),
|
|
|
|
|
text: "Test.".to_owned(),
|
|
|
|
|
}],
|
|
|
|
|
backend_id: String::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mock = MockServer::start().await;
|
|
|
|
|
Mock::given(method("POST"))
|
|
|
|
@@ -1483,16 +1519,19 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
|
|
|
let in_flight = analyze::InFlight::new();
|
|
|
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
|
|
|
rx,
|
|
|
|
|
client,
|
|
|
|
|
busy,
|
|
|
|
|
in_flight,
|
|
|
|
|
vocab,
|
|
|
|
|
doctate_server::events::channel(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
|
|
|
case_dir: case_dir.clone(),
|
|
|
|
|
input,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
@@ -1518,9 +1557,8 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
|
|
|
|
|
|
|
|
|
/// `POST /cases/analyze-required` walks every UUID-named case and force-
|
|
|
|
|
/// enqueues those in `Required`/`Idle`. `Failed` and `Current` cases must
|
|
|
|
|
/// stay untouched. We probe the resulting state via the on-disk
|
|
|
|
|
/// `analysis_input.json` marker — written before the channel send, so its
|
|
|
|
|
/// presence is the canonical "would have been enqueued" signal.
|
|
|
|
|
/// stay untouched. The InFlight set is the canonical "would have been
|
|
|
|
|
/// enqueued" signal; the channel receiver lets us count delivered jobs.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn analyze_required_endpoint_enqueues_only_required_cases() {
|
|
|
|
|
use doctate_server::analyze::auto_trigger::FailureMarker;
|
|
|
|
@@ -1570,7 +1608,8 @@ async fn analyze_required_endpoint_enqueues_only_required_cases() {
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
|
|
|
|
|
let (app, store, in_flight, mut analyze_rx) =
|
|
|
|
|
doctate_server::create_router_with_full_handles(cfg, settings);
|
|
|
|
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
|
|
|
|
|
|
|
|
|
let resp = app
|
|
|
|
@@ -1584,31 +1623,42 @@ async fn analyze_required_endpoint_enqueues_only_required_cases() {
|
|
|
|
|
"must redirect back to the case list"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Required → analysis_input.json was written, the case is now Queued.
|
|
|
|
|
let req_path = user_root.join(req_id);
|
|
|
|
|
let cur_path = user_root.join(cur_id);
|
|
|
|
|
let fail_path = user_root.join(fail_id);
|
|
|
|
|
|
|
|
|
|
// Required → claim held + job in channel.
|
|
|
|
|
assert!(
|
|
|
|
|
user_root.join(req_id).join(ANALYSIS_INPUT_FILE).exists(),
|
|
|
|
|
"Required case must have been enqueued"
|
|
|
|
|
in_flight.contains(&req_path),
|
|
|
|
|
"Required case must have a claim"
|
|
|
|
|
);
|
|
|
|
|
// Current → no enqueue, document.json untouched.
|
|
|
|
|
// Current → no claim, document.json untouched.
|
|
|
|
|
assert!(
|
|
|
|
|
!user_root.join(cur_id).join(ANALYSIS_INPUT_FILE).exists(),
|
|
|
|
|
"Current case must not be enqueued"
|
|
|
|
|
!in_flight.contains(&cur_path),
|
|
|
|
|
"Current case must not be claimed"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
user_root.join(cur_id).join(DOCUMENT_FILE).exists(),
|
|
|
|
|
cur_path.join(DOCUMENT_FILE).exists(),
|
|
|
|
|
"Current case's document must remain in place"
|
|
|
|
|
);
|
|
|
|
|
// Failed → no enqueue. Marker stays so the user sees the failure
|
|
|
|
|
// Failed → no claim. Marker stays so the user sees the failure
|
|
|
|
|
// banner on the case page and can retry per-backend manually.
|
|
|
|
|
assert!(
|
|
|
|
|
!user_root.join(fail_id).join(ANALYSIS_INPUT_FILE).exists(),
|
|
|
|
|
"Failed case must not be enqueued"
|
|
|
|
|
!in_flight.contains(&fail_path),
|
|
|
|
|
"Failed case must not be claimed"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
user_root
|
|
|
|
|
.join(fail_id)
|
|
|
|
|
.join(".analysis_failed.json")
|
|
|
|
|
.exists(),
|
|
|
|
|
fail_path.join(".analysis_failed.json").exists(),
|
|
|
|
|
"failure marker must persist"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut received = Vec::new();
|
|
|
|
|
while let Ok(job) = analyze_rx.try_recv() {
|
|
|
|
|
received.push(job.case_dir);
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(
|
|
|
|
|
received,
|
|
|
|
|
vec![req_path],
|
|
|
|
|
"only the Required case must be enqueued"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|