//! 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 { 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) -> 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, 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()), } } // ===================================================================== // 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( ®en_dir, SLUG, &reqwest::Client::new(), ®en_settings, &vocab, ®en_events, ®en_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 /web/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 /web/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, "/web/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 )); }