Files
doctate/server/tests/failed_only_case_empty_oneliner_test.rs
T
Brummel c769abe3d5 feat: Add per-case dir oneliner locks
Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.

The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.

This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
  error conditions, early latching, race conditions, and parallel PUTs.
2026-04-26 16:05:01 +02:00

104 lines
3.9 KiB
Rust

//! Regression test: a case whose recordings are all `.m4a.failed`
//! (permanent transcription failures — ffmpeg corrupt audio or Whisper
//! 4xx) must produce an `OnelinerState::Empty` automatically, so the UI
//! has a durable terminal state instead of relying on the on-render
//! `compute_oneliner_display` fallback.
//!
//! The historical bug path: `.m4a.failed`-only cases were skipped by the
//! recovery scan (`has_any_transcript` looked for `*.transcript.txt`
//! only), so `update_oneliner` never ran, so no `oneliner.json` was
//! written. The UI masked the symptom with its `non_failed_count == 0`
//! fallback — but a future refactor of that fallback would regress the
//! case. The fix makes `has_any_transcript` count `.m4a.failed` too, so
//! `update_oneliner` runs, sees no content transcripts, and settles the
//! case to `Empty` through the same terminal branch used for silent-only
//! cases.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use doctate_common::oneliners::OnelinerState;
use doctate_server::events;
use doctate_server::gazetteer::Gazetteer;
use doctate_server::{
AnalyzeBusy, OnelinerHealBusy, PipelineState, TranscribeBusy, WorkerBusy, analyze, transcribe,
};
use tempfile::tempdir;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{ONELINER_FILENAME, TestConfig};
#[tokio::test]
async fn failed_only_case_settles_to_empty_without_llm_call() {
let data = tempdir().unwrap();
let slug = "dr_test";
let user_root = data.path().join(slug);
let case_dir = user_root.join("case-failed");
std::fs::create_dir_all(&case_dir).unwrap();
// Seed: only a `.m4a.failed` file. No transcript, no plain .m4a.
std::fs::write(case_dir.join("2026-04-15T20-30-00Z.m4a.failed"), b"x").unwrap();
// Ollama must NOT be called — nothing to summarize for a failed-only
// case. `.expect(0)` fails the test (on MockServer drop) if any
// request arrived.
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/api/chat"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let config = TestConfig::new()
.with_data_path(data.path().to_path_buf())
.with_ollama(mock.uri())
.build();
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(),
};
pipeline
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
.await;
// Heal spawn drops the busy flag on completion — poll until idle.
let start = std::time::Instant::now();
while heal_busy.load(Ordering::Acquire) {
if start.elapsed() > Duration::from_secs(5) {
panic!("oneliner heal did not finish within 5s");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
let path = case_dir.join(ONELINER_FILENAME);
assert!(
path.exists(),
"expected oneliner.json to be written for failed-only case"
);
let bytes = std::fs::read(&path).unwrap();
let state: OnelinerState = serde_json::from_slice(&bytes).unwrap();
assert!(
matches!(state, OnelinerState::Empty { .. }),
"expected OnelinerState::Empty, got {state:?}"
);
// Mock.drop() runs here — `.expect(0)` panics if Ollama was touched.
}