//! Regression test: `PipelineState::heal_orphans_if_idle` must not block //! the request handler on Ollama. Before the fix, the function awaited a //! sequential `regenerate_missing_oneliners_for_user` loop inline, so the //! HTTP handler stayed pinned for N × LLM-latency. The fix detaches the //! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on //! `OnelinerHealBusy` to dedup concurrent page-loads. mod common; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use doctate_server::events; use doctate_server::gazetteer::Gazetteer; use doctate_server::{ AnalyzeBusy, OnelinerHealBusy, PipelineState, TranscribeBusy, WorkerBusy, analyze, transcribe, }; use serde_json::json; use tempfile::tempdir; use tokio::time::timeout; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use common::{ONELINER_FILENAME, TestConfig}; fn write_transcript(case_dir: &Path) { // Seed an m4a + metadata sidecar pair, matching the worker's // on-disk layout. The oneliner worker iterates `.m4a` files and // resolves the sidecar from the stem; a stray sidecar without an // m4a would be invisible to it. let stem = "2026-04-16T10-00-00Z"; std::fs::write(case_dir.join(format!("{stem}.m4a")), b"audio").unwrap(); common::seed_recording_meta( case_dir, stem, common::Transcript::Content { text: "Brustschmerz links, seit heute morgen.".to_owned(), }, None, ); } #[tokio::test] async fn heal_orphans_returns_fast_and_dedups_parallel_calls() { // Five cases with a transcript but no oneliner.json. Without the fix, // heal_orphans_if_idle would sequentially block on 5 × 200 ms = 1 s of // mock delay. With the fix, it must return in well under that window. let data = tempdir().unwrap(); let slug = "dr_test"; let user_root = data.path().join(slug); std::fs::create_dir_all(&user_root).unwrap(); for i in 0..5 { let case_dir = user_root.join(format!("case-{i:02}")); std::fs::create_dir_all(&case_dir).unwrap(); write_transcript(&case_dir); } // Ollama mock: 200 ms delay per chat call. `.expect(5)` asserts at // MockServer drop that exactly five requests arrived — dedup would // show up as 10 without the CAS guard. let mock = MockServer::start().await; Mock::given(method("POST")) .and(wm_path("/api/chat")) .respond_with( ResponseTemplate::new(200) .set_delay(Duration::from_millis(200)) .set_body_json(json!({ "message": { "content": "Brustschmerz links" } })), ) .expect(5) .mount(&mock) .await; let (_config, settings) = TestConfig::new() .with_data_path(data.path().to_path_buf()) .with_ollama(mock.uri()) .build_pair(); let vocab = Arc::new(Gazetteer::empty()); let events_tx = events::channel(); let http_client = reqwest::Client::new(); let (tx_a, _rx_a) = analyze::channel(); let (tx_t, _rx_t) = transcribe::channel(); let heal_busy: WorkerBusy = Arc::new(AtomicBool::new(false)); let pipeline = PipelineState { analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), analyze_tx: tx_a, transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), transcribe_tx: tx_t, oneliner_heal_busy: OnelinerHealBusy(heal_busy.clone()), oneliner_locks: doctate_server::oneliner_locks::OnelinerLocks::new(), }; // Two back-to-back heal invocations. The first wins the CAS and // spawns. The second must no-op. Both heal futures themselves must // finish in a fraction of the mock delay. let before = Instant::now(); timeout(Duration::from_millis(150), async { pipeline .heal_orphans_if_idle( &user_root, slug, &http_client, &settings, &vocab, &events_tx, ) .await; pipeline .heal_orphans_if_idle( &user_root, slug, &http_client, &settings, &vocab, &events_tx, ) .await; }) .await .expect("heal_orphans_if_idle must return without awaiting the LLM loop"); assert!( before.elapsed() < Duration::from_millis(150), "heal_orphans returned in {:?} — the inline regen is back", before.elapsed() ); // Spawn drops the busy flag on completion — poll until idle. let poll_start = Instant::now(); while heal_busy.load(Ordering::Acquire) { if poll_start.elapsed() > Duration::from_secs(5) { panic!("oneliner heal spawn did not finish within 5s"); } tokio::time::sleep(Duration::from_millis(25)).await; } // Every case now has an oneliner.json. for i in 0..5 { let p = user_root .join(format!("case-{i:02}")) .join(ONELINER_FILENAME); assert!(p.exists(), "oneliner.json missing for case-{i:02}"); } // Mock is dropped at end of scope → `.expect(5)` verifies no // double-firing. A failing expectation panics with the actual count. }