Files
doctate/server/tests/oneliner_override_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

403 lines
14 KiB
Rust

//! Integration tests for the manual oneliner override:
//! `PUT /api/cases/{case_id}/oneliner`.
//!
//! Covers:
//! - Status codes for happy path / auth / validation errors.
//! - The two correctness invariants of the override:
//! 1. Early-latch: once a `Manual` state exists on disk, the
//! worker's `update_oneliner` skips the LLM call entirely.
//! 2. Re-check-under-lock: a manual PUT that arrives while an
//! auto-regen is in flight wins — the post-LLM result is
//! dropped because the per-case mutex serialises the
//! read-modify-write of `oneliner.json`.
//! - Mutex-serialisation: parallel PUTs on the same case produce a
//! well-formed final state, never a torn write.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use serde_json::json;
use tower::util::ServiceExt;
use uuid::Uuid;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use doctate_common::API_KEY_HEADER;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use doctate_server::oneliner_locks::OnelinerLocks;
use doctate_server::{
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};
use common::{TestConfig, body_json, seed_case, seed_oneliner_state, test_user};
const SLUG: &str = "dr_a";
const API_KEY: &str = "key-dr_a";
fn put_request(case_id: &str, body: &str, api_key: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
.method("PUT")
.uri(format!("/api/cases/{case_id}/oneliner"))
.header(header::CONTENT_TYPE, "application/json");
if let Some(k) = api_key {
b = b.header(API_KEY_HEADER, k);
}
b.body(Body::from(body.to_owned())).unwrap()
}
/// Build a full `AppState` with the given config; returns both the
/// state (for direct access to `oneliner_locks`, `events_tx`, etc.)
/// and a router that wraps it.
fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
AppState {
config: cfg,
transcribe_tx,
analyze_tx,
session_store: web_session::new_store(),
magic_link_store: doctate_server::magic_link::new_store(),
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
oneliner_locks: OnelinerLocks::new(),
events_tx: doctate_server::events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
}
}
// =====================================================================
// 1. Happy path
// =====================================================================
#[tokio::test]
async fn put_writes_manual_state_to_disk() {
let cfg = TestConfig::new()
.with_label("oneliner-override-ok")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
let state = build_app(cfg);
let app = doctate_server::create_router_with_state(state);
let resp = app
.oneshot(put_request(
&case_id,
r#"{"text":"55 J., Knieschmerz li."}"#,
Some(API_KEY),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["kind"], "manual");
assert_eq!(body["text"], "55 J., Knieschmerz li.");
assert!(body["set_at"].as_str().is_some_and(|s| !s.is_empty()));
// On-disk state must match.
let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
.await
.expect("oneliner.json missing");
let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
match on_disk {
OnelinerState::Manual { text, .. } => {
assert_eq!(text, "55 J., Knieschmerz li.");
}
other => panic!("expected Manual, got {other:?}"),
}
}
// =====================================================================
// 2-5. Error paths
// =====================================================================
#[tokio::test]
async fn put_unknown_case_returns_404() {
let cfg = TestConfig::new()
.with_label("oneliner-override-404")
.with_user(test_user(SLUG))
.build();
let unknown_case = Uuid::new_v4().to_string();
let app = doctate_server::create_router_with_state(build_app(cfg));
let resp = app
.oneshot(put_request(&unknown_case, r#"{"text":"x"}"#, Some(API_KEY)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_without_api_key_returns_401() {
let cfg = TestConfig::new()
.with_label("oneliner-override-401")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
let resp = app
.oneshot(put_request(&case_id, r#"{"text":"x"}"#, None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn put_empty_text_returns_400() {
let cfg = TestConfig::new()
.with_label("oneliner-override-empty")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
// Whitespace-only also collapses to empty after trim.
let resp = app
.oneshot(put_request(
&case_id,
r#"{"text":" \t\n"}"#,
Some(API_KEY),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_too_long_text_returns_400() {
let cfg = TestConfig::new()
.with_label("oneliner-override-long")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
let big = "a".repeat(501);
let body = json!({ "text": big }).to_string();
let resp = app
.oneshot(put_request(&case_id, &body, Some(API_KEY)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// =====================================================================
// 6. Early-latch: pre-existing Manual blocks LLM call
// =====================================================================
#[tokio::test]
async fn early_latch_skips_llm_call_when_manual_set() {
// Mock-Ollama with hit counter. After the manual-latched run we
// expect zero invocations.
let mock = MockServer::start().await;
let hits = Arc::new(AtomicUsize::new(0));
let hits_for_mock = hits.clone();
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(move |_req: &wiremock::Request| {
hits_for_mock.fetch_add(1, Ordering::Relaxed);
ResponseTemplate::new(200).set_body_json(json!({
"message": {"content": "auto-generated"}
}))
})
.mount(&mock)
.await;
let cfg = TestConfig::new()
.with_label("oneliner-override-early-latch")
.with_user(test_user(SLUG))
.with_ollama(mock.uri())
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
// Pre-seed a Manual state directly on disk, simulating an earlier PUT.
seed_oneliner_state(
&case_dir,
&OnelinerState::Manual {
text: "manual-text".into(),
set_at: "2026-04-26T10:00:00Z".into(),
},
);
// Also seed a transcript so update_oneliner has content to pass to LLM
// (would call without latch).
std::fs::write(case_dir.join("2026-04-26T11-00-00Z.m4a"), b"audio-bytes").unwrap();
std::fs::write(
case_dir.join("2026-04-26T11-00-00Z.transcript.txt"),
"Patient hat Fieber.",
)
.unwrap();
let locks = OnelinerLocks::new();
let events_tx = doctate_server::events::channel();
let vocab = doctate_server::gazetteer::Gazetteer::empty();
transcribe::worker::update_oneliner(
&case_dir,
SLUG,
&reqwest::Client::new(),
&cfg,
&vocab,
&events_tx,
&locks,
)
.await;
assert_eq!(
hits.load(Ordering::Relaxed),
0,
"Manual override must skip LLM entirely"
);
// Disk still has the manual state — untouched.
let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
.await
.unwrap();
let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
assert!(matches!(on_disk, OnelinerState::Manual { .. }));
}
// =====================================================================
// 7. Race: manual PUT during in-flight LLM regen wins
// =====================================================================
#[tokio::test]
async fn re_check_drops_llm_result_when_manual_appears_during_regen() {
// Mock-Ollama with artificial delay so the test window is wide
// enough to inject a PUT before the LLM result lands.
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({
"message": {"content": "auto-from-llm"}
}))
.set_delay(Duration::from_millis(500)),
)
.mount(&mock)
.await;
let cfg = TestConfig::new()
.with_label("oneliner-override-race")
.with_user(test_user(SLUG))
.with_ollama(mock.uri())
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
// Seed transcript so update_oneliner enters the LLM branch.
std::fs::write(case_dir.join("2026-04-26T11-00-00Z.m4a"), b"audio-bytes").unwrap();
std::fs::write(
case_dir.join("2026-04-26T11-00-00Z.transcript.txt"),
"Patient klagt über Schwindel.",
)
.unwrap();
let locks = OnelinerLocks::new();
let events_tx = doctate_server::events::channel();
// Spawn the regen — it will wait ~500ms inside Mock-Ollama before
// reaching the re-check-under-lock branch.
let regen_cfg = cfg.clone();
let regen_locks = locks.clone();
let regen_events = events_tx.clone();
let regen_dir = case_dir.clone();
let regen = tokio::spawn(async move {
let vocab = doctate_server::gazetteer::Gazetteer::empty();
transcribe::worker::update_oneliner(
&regen_dir,
SLUG,
&reqwest::Client::new(),
&regen_cfg,
&vocab,
&regen_events,
&regen_locks,
)
.await;
});
// Give the regen a moment to enter the Ollama call, then write
// the manual override directly through the same lock — emulates
// the PUT handler.
tokio::time::sleep(Duration::from_millis(150)).await;
{
let _guard = locks.lock_for(&case_dir).await;
let manual = OnelinerState::Manual {
text: "doctor-wins".into(),
set_at: "2026-04-26T11:01:00Z".into(),
};
doctate_server::paths::write_oneliner_state(&case_dir, &manual)
.await
.unwrap();
}
regen.await.unwrap();
// Final state must be the manual one — the auto result was dropped.
let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
.await
.unwrap();
let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
match on_disk {
OnelinerState::Manual { text, .. } => assert_eq!(text, "doctor-wins"),
other => panic!("manual must win race, got {other:?}"),
}
}
// =====================================================================
// 8. Mutex serialises parallel PUTs
// =====================================================================
#[tokio::test]
async fn parallel_puts_serialize_via_mutex() {
let cfg = TestConfig::new()
.with_label("oneliner-override-parallel")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
// Two concurrent PUTs on the same case.
let (r1, r2) = tokio::join!(
app.clone()
.oneshot(put_request(&case_id, r#"{"text":"first"}"#, Some(API_KEY),)),
app.oneshot(put_request(&case_id, r#"{"text":"second"}"#, Some(API_KEY),)),
);
assert_eq!(r1.unwrap().status(), StatusCode::OK);
assert_eq!(r2.unwrap().status(), StatusCode::OK);
// Final on-disk state must be valid JSON of the Manual variant
// and contain exactly one of the two texts (no torn write, no
// mixed content).
let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
.await
.unwrap();
let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
match on_disk {
OnelinerState::Manual { text, .. } => {
assert!(
text == "first" || text == "second",
"expected one of the two texts, got {text:?}"
);
}
other => panic!("expected Manual, got {other:?}"),
}
}