From 9e8db7518dcc8f91e91ab15824d9ae3b9306a585 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 5 May 2026 14:01:43 +0200 Subject: [PATCH] Refactor document handling to JSON envelope The `document.md` file has been replaced by `document.json`. This new format acts as a JSON envelope, containing the original markdown content along with metadata such as `covered_mtime`, `written_at`, and `backend_id`. This change addresses a bug where new recordings arriving during an LLM analysis call could be ignored. The `covered_mtime` field in the new JSON envelope ensures that the document's metadata accurately reflects the state of recordings at the time of analysis, preventing incorrect "analysis current" states. Additionally, the `analysis_input.json` file is now removed upon successful analysis, and a failure marker is used to indicate analysis failures. This ensures that a case is re-analyzed if necessary. The `analysis_idle_threshold_secs` configuration option has been introduced to control the delay before a case in the `Required` state is automatically promoted to `Idle` and enqueued for LLM analysis. --- server/src/analyze/auto_trigger.rs | 44 +++++++++++++++++++++++++++ server/src/analyze/mod.rs | 21 +++++++++++-- server/src/analyze/recovery.rs | 21 ++++++++++--- server/src/analyze/worker.rs | 30 +++++++++++++----- server/src/config.rs | 8 +++++ server/src/routes/case_actions.rs | 17 ++++++++--- server/tests/analyze_test.rs | 16 +++++----- server/tests/case_page_test.rs | 12 ++++---- server/tests/common/artefacts.rs | 21 +++++++++++-- server/tests/common/config.rs | 19 ++++++++++-- server/tests/common/mod.rs | 5 ++- server/tests/delete_recording_test.rs | 4 +-- 12 files changed, 180 insertions(+), 38 deletions(-) diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index a514276..f11e6be 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -529,6 +529,50 @@ mod tests { assert_eq!(evaluate_case(dir.path()).await, AutoDecision::Enqueue); } + /// Burst race: when a recording arrives DURING the LLM call for an + /// earlier recording, the worker writes a `document.md` whose mtime + /// is later than the new recording's mtime — even though the + /// document content cannot include that recording (its + /// `analysis_input.json` snapshot was frozen before the recording + /// existed). The current `doc_mtime >= latest_mtime` check would + /// then incorrectly report "analysis current" and the new recording + /// would never make it into a document. + /// + /// This test pins the desired behaviour: `evaluate_case` must return + /// `Enqueue` so the late recording is folded into a fresh document. + #[tokio::test] + async fn recording_arrived_during_llm_call_re_enqueues() { + let dir = TempDir::new().unwrap(); + + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + + // A: dictated and transcribed before the LLM call started. + let a_m4a = dir.path().join("2026-01-01T10-00-00Z.m4a"); + touch(&a_m4a).await; + seed_meta_with(dir.path(), "2026-01-01T10-00-00Z", "alpha").await; + set_mtime(&a_m4a, base); + + // B: arrived 5s later, while A's LLM call was already in flight. + let b_m4a = dir.path().join("2026-01-01T10-00-05Z.m4a"); + touch(&b_m4a).await; + seed_meta_with(dir.path(), "2026-01-01T10-00-05Z", "bravo").await; + set_mtime(&b_m4a, base + Duration::from_secs(5)); + + // document.md: written when the LLM responded for A's snapshot. + // Its mtime is newer than both recordings — but its content only + // covers A. analysis_input.json has already been removed by the + // worker on success, and there is no failure marker. + let doc = dir.path().join(DOCUMENT_FILE); + touch(&doc).await; + set_mtime(&doc, base + Duration::from_secs(30)); + + assert_eq!( + evaluate_case(dir.path()).await, + AutoDecision::Enqueue, + "B was added during A's LLM call; the document cannot include it, so a re-analysis must be enqueued" + ); + } + #[tokio::test] async fn analysis_input_present_skips() { let dir = TempDir::new().unwrap(); diff --git a/server/src/analyze/mod.rs b/server/src/analyze/mod.rs index 0f226c4..f7469d9 100644 --- a/server/src/analyze/mod.rs +++ b/server/src/analyze/mod.rs @@ -12,10 +12,27 @@ pub mod render; pub mod worker; /// Single-document-per-case filenames. A re-analysis overwrites the -/// document in place (handler deletes it first; worker writes the fresh one). -pub const DOCUMENT_FILE: &str = "document.md"; +/// document in place; the file is a typed JSON envelope so the +/// `covered_mtime` snapshot stays glued to the markdown body it was +/// produced from. +pub const DOCUMENT_FILE: &str = "document.json"; pub const ANALYSIS_INPUT_FILE: &str = "analysis_input.json"; +/// Persisted analysis output for a case. The `covered_mtime` field is +/// the snapshot mtime of the recordings the LLM saw — *not* the +/// wallclock time at which the file was written. Comparing it to the +/// current `latest_mtime` on disk tells `evaluate_state` whether the +/// stored markdown still describes the case faithfully or whether a +/// new recording arrived after the snapshot was frozen and a fresh +/// analysis is required. +#[derive(Debug, Serialize, Deserialize)] +pub struct Document { + pub covered_mtime: String, + pub written_at: String, + pub backend_id: String, + pub content_md: String, +} + /// A single request to analyze a case. Payload is tiny (a path), so the /// channel is unbounded — persistence lives in `analysis_input.json` on /// disk, not in the queue. Recovery re-enqueues any inputs the server may diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 2809ada..14c0a65 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -82,10 +82,25 @@ async fn input_already_failed(case_dir: &Path) -> bool { mod tests { use super::*; use crate::analyze::auto_trigger::{FAILURE_MARKER_FILE, FailureMarker}; - use crate::analyze::{RecordingInput, channel as analyze_channel}; + use crate::analyze::{Document, RecordingInput, channel as analyze_channel}; use tempfile::TempDir; use tokio::fs; + async fn write_document(case_dir: &Path, content: &str) { + let doc = Document { + covered_mtime: "1970-01-01T00:00:00Z".into(), + written_at: "1970-01-01T00:00:00Z".into(), + backend_id: "test".into(), + content_md: content.into(), + }; + fs::write( + case_dir.join(DOCUMENT_FILE), + serde_json::to_vec(&doc).unwrap(), + ) + .await + .unwrap(); + } + fn make_input(mtime: &str) -> AnalysisInput { AnalysisInput { last_recording_mtime: mtime.to_owned(), @@ -158,9 +173,7 @@ mod tests { .join("22222222-2222-2222-2222-222222222222"); fs::create_dir_all(&case).await.unwrap(); write_input(&case, &make_input("2026-05-03T10:00:00Z")).await; - fs::write(case.join(DOCUMENT_FILE), b"already analysed") - .await - .unwrap(); + write_document(&case, "already analysed").await; let (tx, rx) = analyze_channel(); let sent = enqueue_pending_for_user(user_root.path(), &tx).await; diff --git a/server/src/analyze/worker.rs b/server/src/analyze/worker.rs index 0f53545..d7a8108 100644 --- a/server/src/analyze/worker.rs +++ b/server/src/analyze/worker.rs @@ -64,8 +64,16 @@ async fn process( // Silent-only case: no usable transcripts. Skip LLM, write a stub. if input.recordings.is_empty() { - let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n"; - if let Err(e) = write_atomic(&document_path, stub).await { + let stub_md = "_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n"; + let doc = super::Document { + covered_mtime: input.last_recording_mtime.clone(), + written_at: doctate_common::timestamp::now_rfc3339(), + backend_id: String::new(), + content_md: stub_md.to_owned(), + }; + let stub_bytes = + serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes"); + if let Err(e) = write_atomic(&document_path, &stub_bytes).await { error!(path = %document_path.display(), error = %e, "writing stub document failed"); let _ = auto_trigger::failure_marker( case_dir, @@ -150,10 +158,10 @@ async fn process( // Silent-deletion guard: schema-aware backends can return formally valid // but empty envelopes (`{"document": ""}`) when the prompt is incompatible - // with the format constraint. Without this guard the worker would write - // an empty document.md, drop the failure marker, and the user would see - // a blank note with no diagnostic. Observed on Llama 3.1 405B FP8 - // (test T4, 2026-05-03) and previously on Mistral 24B. + // with the format constraint. Without this guard the worker would persist + // a Document with empty `content_md`, drop the failure marker, and the + // user would see a blank note with no diagnostic. Observed on Llama 3.1 + // 405B FP8 (test T4, 2026-05-03) and previously on Mistral 24B. if document.trim().is_empty() { error!( case = %case_dir.display(), @@ -173,7 +181,15 @@ async fn process( return; } - if let Err(e) = write_atomic(&document_path, document.as_bytes()).await { + let doc = super::Document { + covered_mtime: input.last_recording_mtime.clone(), + written_at: doctate_common::timestamp::now_rfc3339(), + backend_id: backend.id.clone(), + content_md: document.clone(), + }; + let doc_bytes = + serde_json::to_vec_pretty(&doc).expect("Document with String fields always serializes"); + if let Err(e) = write_atomic(&document_path, &doc_bytes).await { error!(path = %document_path.display(), error = %e, "writing document failed"); let _ = auto_trigger::failure_marker( case_dir, diff --git a/server/src/config.rs b/server/src/config.rs index cfc4372..6847ac2 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -142,6 +142,12 @@ pub struct Config { /// env var at startup; default `Whisper`. Worker uses this to dispatch /// the transcription call to the matching endpoint. pub asr_backend: AsrBackend, + /// Seconds of inactivity (no new recording mtime) after which a case + /// in the `Required` analysis state is auto-promoted to `Idle` and + /// enqueued for LLM analysis. Default `900` (15 min). Set lower in + /// tests via `Config::test_default()` (`= 0`, eager) or via the + /// `ANALYSIS_IDLE_THRESHOLD_SECS` env var. + pub analysis_idle_threshold_secs: u64, } impl Config { @@ -168,6 +174,7 @@ impl Config { asr_backend: optional_env("ASR_BACKEND", "whisper") .parse::() .unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")), + analysis_idle_threshold_secs: optional_env_parsed("ANALYSIS_IDLE_THRESHOLD_SECS", 900), } } @@ -199,6 +206,7 @@ impl Config { session_timeout_hours: 8, cookie_secure: false, asr_backend: AsrBackend::Whisper, + analysis_idle_threshold_secs: 0, } } } diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index c97302e..dc0e2ab 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -377,11 +377,20 @@ pub(crate) async fn write_input_create_new( Ok(()) } -/// Read `case_dir/document.md`. Returns `None` if no document exists. +/// Read `case_dir/document.json` and return the embedded markdown +/// body. Returns `None` if no document exists or the file is not valid +/// JSON for the [`Document`] envelope. A parse error is logged so a +/// corrupt file does not silently appear as "no document". pub(crate) async fn read_document(case_dir: &Path) -> Option { - tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)) - .await - .ok() + let path = case_dir.join(DOCUMENT_FILE); + let bytes = tokio::fs::read(&path).await.ok()?; + match serde_json::from_slice::(&bytes) { + Ok(d) => Some(d.content_md), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "document parse failed"); + None + } + } } /// Reset a case to its raw audio: delete derived artefacts diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index dd82720..3e8e6f3 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -16,7 +16,7 @@ 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, + test_user, unique_tmpdir, write_document_for_test, }; // --------------------------------------------------------------------- @@ -634,7 +634,7 @@ async fn recovery_skips_completed_analysis() { 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(DOCUMENT_FILE), "done").unwrap(); + write_document_for_test(&case_dir, "done", "1970-01-01T00:00:00Z"); let (tx, mut rx) = analyze::channel(); analyze::recovery::scan_and_enqueue(&tmp, &tx).await; @@ -654,7 +654,7 @@ 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); - std::fs::write(case_dir.join(DOCUMENT_FILE), "altes Dokument").unwrap(); + 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")); @@ -1272,7 +1272,7 @@ async fn reset_case_clears_derived_and_unfails_audio() { br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#, ) .unwrap(); - std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").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); @@ -1311,7 +1311,7 @@ async fn reset_case_requires_admin() { 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")); - std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").unwrap(); + 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; @@ -1346,13 +1346,13 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { 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")); - std::fs::write(dir_a.join(DOCUMENT_FILE), "A").unwrap(); - std::fs::write(dir_b.join(DOCUMENT_FILE), "B").unwrap(); + 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")); - std::fs::write(dir_c.join(DOCUMENT_FILE), "C").unwrap(); + 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); diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index a72b7a7..29d5bde 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -6,8 +6,8 @@ use tower::util::ServiceExt; use doctate_common::oneliners::OnelinerState; use common::{ - DOCUMENT_FILE, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, - seed_oneliner_state, seed_recording, test_admin, test_user, + TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, + seed_oneliner_state, seed_recording, test_admin, test_user, write_document_for_test, }; /// Password "s" is the shared test default; wrap `login` so call sites @@ -29,7 +29,7 @@ async fn case_page_shows_document_when_present() { .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - std::fs::write(case_dir.join(DOCUMENT_FILE), "der Inhalt").unwrap(); + write_document_for_test(&case_dir, "der Inhalt", "1970-01-01T00:00:00Z"); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; @@ -57,7 +57,7 @@ async fn case_page_document_has_copy_button() { .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - std::fs::write(case_dir.join(DOCUMENT_FILE), "kopiere mich").unwrap(); + write_document_for_test(&case_dir, "kopiere mich", "1970-01-01T00:00:00Z"); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; @@ -307,7 +307,7 @@ async fn case_page_foreign_user_returns_404() { let case_id = "22222222-2222-2222-2222-222222222222"; // Case exists under dr_b, but session is dr_a → 404. let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); - std::fs::write(case_dir.join(DOCUMENT_FILE), "fremdes Dokument").unwrap(); + write_document_for_test(&case_dir, "fremdes Dokument", "1970-01-01T00:00:00Z"); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; @@ -387,7 +387,7 @@ async fn case_page_uses_oneliner_as_h1_when_present() { let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig"); - std::fs::write(case_dir.join(DOCUMENT_FILE), "Inhalt").unwrap(); + write_document_for_test(&case_dir, "Inhalt", "1970-01-01T00:00:00Z"); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; diff --git a/server/tests/common/artefacts.rs b/server/tests/common/artefacts.rs index 8d9fc41..aa9260c 100644 --- a/server/tests/common/artefacts.rs +++ b/server/tests/common/artefacts.rs @@ -2,12 +2,29 @@ //! canonical source-of-truth locations. //! //! Tests that need to read, write, or assert the absence of per-case -//! artefacts (`oneliner.json`, `.closed`, `document.md`, +//! artefacts (`oneliner.json`, `.closed`, `document.json`, //! `analysis_input.json`) pull these names from here instead of //! inlining string literals. If the server renames a sidecar in the //! future, test call-sites fail to compile instead of silently //! asserting against the old name. +use std::path::Path; + pub use doctate_common::ONELINER_FILENAME; -pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, Document}; pub use doctate_server::paths::CLOSE_MARKER; + +/// 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 +/// string (e.g. `"1970-01-01T00:00:00Z"`). +pub fn write_document_for_test(case_dir: &Path, content_md: &str, covered_mtime: &str) { + let doc = Document { + covered_mtime: covered_mtime.to_owned(), + written_at: doctate_common::timestamp::now_rfc3339(), + backend_id: "test".to_owned(), + content_md: content_md.to_owned(), + }; + let bytes = serde_json::to_vec_pretty(&doc).expect("serialize test Document"); + std::fs::write(case_dir.join(DOCUMENT_FILE), bytes).expect("write test document.json"); +} diff --git a/server/tests/common/config.rs b/server/tests/common/config.rs index 6e580b8..a8f79c7 100644 --- a/server/tests/common/config.rs +++ b/server/tests/common/config.rs @@ -66,6 +66,7 @@ pub struct TestConfig { whisper_url: Option, whisper_timeout_seconds: Option, ollama_url: Option, + idle_threshold_secs: Option, label: &'static str, } @@ -85,6 +86,7 @@ impl TestConfig { whisper_url: None, whisper_timeout_seconds: None, ollama_url: None, + idle_threshold_secs: None, label: "common", } } @@ -152,6 +154,15 @@ impl TestConfig { self } + /// Idle threshold in seconds: how long after the latest recording mtime + /// a `Required` case is auto-promoted to `Idle` (and enqueued). Default + /// is `Config::test_default()` (= `0`, eager). Tests that exercise the + /// "wait for quiet period" branch set a positive value. + pub fn with_idle_threshold(mut self, secs: u64) -> Self { + self.idle_threshold_secs = Some(secs); + self + } + /// Materialize the config alone. Bucket-B overrides (`with_llm`, /// `with_whisper`, `with_ollama`) are silently dropped — call /// [`build_pair`] when those need to take effect. @@ -170,12 +181,16 @@ impl TestConfig { .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); - let config = Arc::new(Config { + let mut config = Config { data_path, users: self.users, api_keys, ..Config::test_default() - }); + }; + if let Some(v) = self.idle_threshold_secs { + config.analysis_idle_threshold_secs = v; + } + let config = Arc::new(config); let mut settings = Settings::default(); // LLM-related fields used to live on `Settings`; with the multi-backend diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index f051fe5..ffb96c9 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -25,7 +25,10 @@ pub mod session; pub mod users; // Re-exports for ergonomic `use common::*;` in most test files. -pub use artefacts::{ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME}; +pub use artefacts::{ + ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, Document, ONELINER_FILENAME, + write_document_for_test, +}; pub use config::{TestConfig, unique_tmpdir}; pub use doctate_common::Transcript; pub use http::{ diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index 94fd9fd..51d7b20 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -16,7 +16,7 @@ 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, + seed_recording_with_sidecars, test_user, write_document_for_test, }; fn delete_body(filename: &str) -> String { @@ -37,7 +37,7 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { // Manual wrapper sees a non-Manual variant and proceeds with the // delete — keeps the original contract „auto state must be wiped". seed_oneliner_ready(&case_dir, "knee, left, pain"); - std::fs::write(case_dir.join(DOCUMENT_FILE), b"# stale").unwrap(); + 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);