Files
doctate/server/tests/oneliner_override_test.rs
T
Brummel 76bf65be1a 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.
2026-05-05 20:01:07 +02:00

652 lines
22 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, csrf_form_post, form_post, login, login_with_csrf, paths, seed_case,
seed_oneliner_state, test_user, write_closed_marker,
};
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 {
build_app_with_events_tx(cfg, doctate_server::events::channel())
}
/// Variant of [`build_app`] that lets the caller pre-create the events
/// channel — needed when a test wants to subscribe BEFORE the request
/// fires, since the broadcast channel only retains messages for live
/// subscribers.
fn build_app_with_events_tx(
cfg: Arc<doctate_server::config::Config>,
events_tx: doctate_server::events::EventSender,
) -> AppState {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
AppState {
config: cfg,
settings: Arc::new(doctate_server::settings::Settings::default()),
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,
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. 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, settings) = TestConfig::new()
.with_label("oneliner-override-early-latch")
.with_user(test_user(SLUG))
.with_ollama(mock.uri())
.build_pair();
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();
common::seed_recording_meta(
&case_dir,
"2026-04-26T11-00-00Z",
common::Transcript::Content {
text: "Patient hat Fieber.".into(),
},
None,
);
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(),
&settings,
&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, settings) = TestConfig::new()
.with_label("oneliner-override-race")
.with_user(test_user(SLUG))
.with_ollama(mock.uri())
.build_pair();
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();
common::seed_recording_meta(
&case_dir,
"2026-04-26T11-00-00Z",
common::Transcript::Content {
text: "Patient klagt über Schwindel.".into(),
},
None,
);
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_settings = settings.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_settings,
&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:?}"),
}
}
// =====================================================================
// 9-15. Web-UI endpoint: POST /cases/{case_id}/oneliner
//
// Same disk semantics as the API PUT (both call `apply_manual_override`),
// but with browser auth: session cookie + CSRF form field. The closed-
// case test pins down the only intentional behavioural divergence —
// the web handler rejects closed cases via `locate_case_or_404`,
// whereas the API endpoint accepts them.
// =====================================================================
#[tokio::test]
async fn web_put_oneliner_writes_manual_state() {
let cfg = TestConfig::new()
.with_label("oneliner-web-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 store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=42+J.%2C+R%C3%BCckenschmerz",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["kind"], "manual");
assert_eq!(body["text"], "42 J., Rückenschmerz");
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();
assert!(
matches!(on_disk, OnelinerState::Manual { ref text, .. } if text == "42 J., Rückenschmerz")
);
}
#[tokio::test]
async fn web_put_oneliner_without_csrf_returns_403() {
let cfg = TestConfig::new()
.with_label("oneliner-web-no-csrf")
.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 cookie = login(&app, SLUG, "s").await;
let resp = app
.oneshot(form_post(
paths::case_oneliner_web(&case_id),
&cookie,
"text=x",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn web_put_oneliner_without_session_redirects_to_login() {
let cfg = TestConfig::new()
.with_label("oneliner-web-no-session")
.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));
// No Cookie header at all.
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(paths::case_oneliner_web(&case_id))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("text=x&csrf_token=any"))
.unwrap(),
)
.await
.unwrap();
// The CsrfForm extractor maps a missing session to a 302 redirect
// to /login (`AppError::Redirect`). Browsers follow it as a
// GET, which is exactly what we want — the user lands on the login
// page instead of staring at a 401.
assert_eq!(resp.status(), StatusCode::FOUND);
let loc = resp
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(loc, "/login");
}
#[tokio::test]
async fn web_put_oneliner_empty_text_returns_400() {
let cfg = TestConfig::new()
.with_label("oneliner-web-empty")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=%20%20%20",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_put_oneliner_unknown_case_returns_404() {
let cfg = TestConfig::new()
.with_label("oneliner-web-unknown")
.with_user(test_user(SLUG))
.build();
let unknown_case = Uuid::new_v4().to_string();
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&unknown_case),
&cookie,
&csrf,
"text=x",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn web_put_oneliner_on_closed_case_returns_404() {
let cfg = TestConfig::new()
.with_label("oneliner-web-closed")
.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);
write_closed_marker(&case_dir, "2026-04-25T10:00:00Z");
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=x",
))
.await
.unwrap();
// `locate_case_or_404` rejects closed cases — intentional asymmetry
// with the X-API-Key endpoint, which would have accepted it.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn web_put_oneliner_emits_event() {
let cfg = TestConfig::new()
.with_label("oneliner-web-event")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
// Pre-create the broadcast channel so we can subscribe BEFORE the
// request fires — broadcast channels do not buffer for absent
// subscribers, so a subscribe-after-emit would miss the event.
let events_tx = doctate_server::events::channel();
let mut events_rx = events_tx.subscribe();
let state = build_app_with_events_tx(cfg, events_tx);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=hello",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let evt = tokio::time::timeout(Duration::from_secs(1), events_rx.recv())
.await
.expect("no event within 1s")
.expect("event channel closed");
assert_eq!(evt.user_slug, SLUG);
assert_eq!(evt.case_id, case_id);
assert!(matches!(
evt.kind,
doctate_server::events::CaseEventKind::OnelinerUpdated
));
}