03736b6993
Adds a new POST endpoint for web-based oneliner overrides and updates the UI to allow manual editing. The person icon replaces the pencil icon for doctor-authored titles, signifying manual authorship rather than editability.
643 lines
22 KiB
Rust
643 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,
|
|
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 = 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(
|
|
®en_dir,
|
|
SLUG,
|
|
&reqwest::Client::new(),
|
|
®en_cfg,
|
|
&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
|
|
));
|
|
}
|