Refactor: Track analysis jobs via InFlight struct

This commit replaces the disk-based `analysis_input.json` marker for
in-progress analysis jobs with an in-memory
`Arc<RwLock<HashSet<PathBuf>>>`.

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

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

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

This change simplifies state management and improves the reliability of
analysis job tracking.
This commit is contained in:
2026-05-05 20:01:07 +02:00
parent e0cfd5d512
commit 76bf65be1a
15 changed files with 704 additions and 336 deletions
+157 -107
View File
@@ -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"
);
}
+8 -1
View File
@@ -11,9 +11,16 @@
use std::path::Path;
pub use doctate_common::ONELINER_FILENAME;
pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, Document};
pub use doctate_server::analyze::{DOCUMENT_FILE, Document};
pub use doctate_server::paths::CLOSE_MARKER;
/// Legacy filename of the per-case analysis input. The server no
/// longer writes or reads it — the in-flight marker lives in `InFlight`
/// and the payload travels with the `AnalyzeJob`. This constant is
/// retained only for tests that simulate an orphan file from a
/// pre-refactor server. New tests should not seed this file.
pub const ANALYSIS_INPUT_FILE: &str = "analysis_input.json";
/// Seed a `document.json` for tests. `covered_mtime` is the RFC3339
/// snapshot mtime the document is supposed to "cover" — for tests that
/// don't care about the auto-trigger state machine, pass any RFC3339
+3 -8
View File
@@ -14,9 +14,9 @@ use doctate_common::oneliners::OnelinerState;
use tower::util::ServiceExt;
use common::{
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post,
login_with_csrf, paths, seed_case, seed_oneliner_ready, seed_oneliner_state,
seed_recording_with_sidecars, test_user, write_document_for_test,
DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post, login_with_csrf, paths,
seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording_with_sidecars, test_user,
write_document_for_test,
};
fn delete_body(filename: &str) -> String {
@@ -38,7 +38,6 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
// delete — keeps the original contract „auto state must be wiped".
seed_oneliner_ready(&case_dir, "knee, left, pain");
write_document_for_test(&case_dir, "# stale", "1970-01-01T00:00:00Z");
std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), b"{}").unwrap();
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
@@ -91,10 +90,6 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
!case_dir.join(DOCUMENT_FILE).exists(),
"document.md must be cleared"
);
assert!(
!case_dir.join(ANALYSIS_INPUT_FILE).exists(),
"analysis_input.json must be cleared"
);
}
#[tokio::test]
+1
View File
@@ -72,6 +72,7 @@ fn build_state_with_stores() -> AppState {
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
analyze_in_flight: doctate_server::analyze::InFlight::new(),
}
}
+1
View File
@@ -85,6 +85,7 @@ fn build_app_with_events_tx(
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
analyze_in_flight: doctate_server::analyze::InFlight::new(),
}
}