diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 56b721b..36a9fe2 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -622,7 +622,13 @@ impl DoctateApp { }; let resp = ui.add(label); if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) { - ui.colored_label(egui::Color32::from_rgb(0, 110, 0), "✏"); + // Person-glyph marks doctor-authored titles. + // Replaces an earlier pencil icon: the pencil + // suggested an edit affordance, but on this row + // the marker is informational only — clicking + // the title text already opens the editor. + ui.colored_label(egui::Color32::GRAY, "👤") + .on_hover_text("manuell bearbeitet"); } if resp.clicked() { // Pre-fill the editor with the visible text diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 55001d9..eda1b6b 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -59,6 +59,10 @@ pub fn api_router() -> Router { "/web/cases/{case_id}/reset", post(case_actions::handle_reset_case), ) + .route( + "/web/cases/{case_id}/oneliner", + post(oneliner_override::handle_web_put_oneliner), + ) .route( "/web/cases/purge-closed", post(case_actions::handle_purge_closed), diff --git a/server/src/routes/oneliner_override.rs b/server/src/routes/oneliner_override.rs index 6a2b691..a820209 100644 --- a/server/src/routes/oneliner_override.rs +++ b/server/src/routes/oneliner_override.rs @@ -1,45 +1,65 @@ -//! `PUT /api/cases/{case_id}/oneliner` — doctor's manual oneliner override. +//! Doctor's manual oneliner override — two entry points sharing one core. //! -//! Writes [`OnelinerState::Manual`], which the transcribe worker's -//! `update_oneliner` treats as terminal: the manual text is sticky -//! across new recordings until another manual edit (or explicit reset) -//! replaces it. See plan +//! - `PUT /api/cases/{case_id}/oneliner` (X-API-Key) for native clients. +//! - `POST /web/cases/{case_id}/oneliner` (session + CSRF) for the +//! browser-based web UI. +//! +//! Both routes funnel into [`apply_manual_override`], which validates +//! the text, takes [`OnelinerLocks::lock_for`] for the duration of the +//! disk write, persists [`OnelinerState::Manual`], and emits an +//! `OnelinerUpdated` event so SSE listeners can reflect the change. +//! +//! `Manual` is treated as terminal by the transcribe worker's +//! `update_oneliner`: the manual text is sticky across new recordings +//! until another manual edit replaces it. See plan //! `plane-die-erweiterung-oneliner-override-pure-goblet.md` for the //! full concurrency rationale. -//! -//! Authentication: `X-API-Key` header, shared with `/api/upload` and -//! `/api/oneliners` (the GET counterpart). -//! -//! Concurrency: holds [`OnelinerLocks::lock_for`] for the duration of -//! the disk write. The worker's auto-regen path takes the same lock -//! around its post-LLM re-read-and-write sequence, so a doctor's PUT -//! arriving while the LLM is still thinking always wins. + +use std::path::Path; +use std::sync::Arc; use axum::Json; use axum::extract::State; use doctate_common::oneliners::{OnelinerOverrideRequest, OnelinerState}; +use serde::Deserialize; use tracing::info; -use crate::auth::AuthenticatedUser; +use crate::auth::{AuthenticatedUser, AuthenticatedWebUser}; use crate::case_id::CaseIdPath; +use crate::config::Config; +use crate::csrf::{CsrfForm, HasCsrfToken}; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; use crate::oneliner_locks::OnelinerLocks; use crate::paths; +use crate::routes::user_web::locate_case_or_404; /// Hard cap on accepted text length. A oneliner is a single sentence /// (typical 30–80 chars); 500 leaves comfortable headroom while /// preventing accidental dumps from a runaway client. const MAX_ONELINER_TEXT_LEN: usize = 500; -pub async fn handle_put_oneliner( - user: AuthenticatedUser, - State(locks): State, - State(events_tx): State, - CaseIdPath(case_id): CaseIdPath, - Json(req): Json, -) -> Result, AppError> { - let trimmed = req.text.trim(); +/// Persist a doctor's manual override for a case. Shared by the +/// X-API-Key API handler and the session-authenticated web handler. +/// +/// Validates and trims `raw_text`, takes the per-case lock for the +/// disk-write window (so a concurrent auto-regen cannot overwrite the +/// manual edit), writes [`OnelinerState::Manual`], logs, and emits +/// `OnelinerUpdated`. Returns the persisted state so the caller can +/// echo it back to the client. +/// +/// Pre-condition: `case_dir` must already exist. Both call sites check +/// for this — the API handler with `try_exists`, the web handler via +/// `locate_case_or_404` — so the helper itself stays focused on the +/// override semantics. +pub(crate) async fn apply_manual_override( + user_slug: &str, + case_dir: &Path, + raw_text: &str, + locks: &OnelinerLocks, + events_tx: &EventSender, +) -> Result { + let trimmed = raw_text.trim(); if trimmed.is_empty() { return Err(AppError::BadRequest( "oneliner text must not be empty".into(), @@ -51,12 +71,6 @@ pub async fn handle_put_oneliner( ))); } - let case_id_str = case_id.to_string(); - let case_dir = user.data_dir.join(&case_id_str); - if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { - return Err(AppError::NotFound(format!("case {case_id_str} not found"))); - } - let state = OnelinerState::Manual { text: trimmed.to_owned(), set_at: doctate_common::now_rfc3339(), @@ -65,22 +79,84 @@ pub async fn handle_put_oneliner( // RAII guard — released on function exit. Same lock the worker takes // for its re-check-then-write window, so a concurrent auto-regen // cannot overwrite this manual edit. - let _guard = locks.lock_for(&case_dir).await; - paths::write_oneliner_state(&case_dir, &state).await?; + let _guard = locks.lock_for(case_dir).await; + paths::write_oneliner_state(case_dir, &state).await?; + + let case_id = case_dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("?") + .to_owned(); info!( - user = %user.slug, - case_id = %case_id_str, + user = %user_slug, + case_id = %case_id, len = trimmed.chars().count(), "oneliner manual override set" ); events::emit( - &events_tx, - user.slug.clone(), - case_id_str, + events_tx, + user_slug.to_owned(), + case_id, CaseEventKind::OnelinerUpdated, ); + Ok(state) +} + +/// `PUT /api/cases/{case_id}/oneliner` — X-API-Key authenticated. +pub async fn handle_put_oneliner( + user: AuthenticatedUser, + State(locks): State, + State(events_tx): State, + CaseIdPath(case_id): CaseIdPath, + Json(req): Json, +) -> Result, AppError> { + let case_id_str = case_id.to_string(); + let case_dir = user.data_dir.join(&case_id_str); + if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { + return Err(AppError::NotFound(format!("case {case_id_str} not found"))); + } + + let state = apply_manual_override(&user.slug, &case_dir, &req.text, &locks, &events_tx).await?; + Ok(Json(state)) +} + +/// Form body for the browser-based web edit endpoint. CSRF token is +/// required; `text` carries the new oneliner content. +#[derive(Deserialize)] +pub struct OnelinerWebForm { + #[serde(default)] + pub text: String, + #[serde(default)] + pub csrf_token: String, +} + +impl HasCsrfToken for OnelinerWebForm { + fn csrf_token(&self) -> &str { + &self.csrf_token + } +} + +/// `POST /web/cases/{case_id}/oneliner` — session + CSRF authenticated. +/// +/// Mirrors [`handle_put_oneliner`] but uses the web auth stack so the +/// browser never has to expose an X-API-Key. Closed cases are rejected +/// at `locate_case_or_404` (consistent with analyze/close/reopen). +pub async fn handle_web_put_oneliner( + user: AuthenticatedWebUser, + State(config): State>, + State(locks): State, + State(events_tx): State, + CaseIdPath(case_id): CaseIdPath, + CsrfForm(form): CsrfForm, +) -> Result, AppError> { + let user_root = config.data_path.join(&user.slug); + let case_dir = + locate_case_or_404(&user_root, &case_id, &user.slug, "oneliner override").await?; + + let state = + apply_manual_override(&user.slug, &case_dir, &form.text, &locks, &events_tx).await?; Ok(Json(state)) } diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 4f254ac..b2b404f 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -69,6 +69,12 @@ struct UserCaseView { /// the server's, e.g. server on Bahamas, doctor in Germany). recorded_at_iso: String, oneliner: OnelinerDisplay, + /// True iff the persisted oneliner is `OnelinerState::Manual { .. }` + /// AND the displayed text actually shows that manual content (i.e. + /// not while we're rendering a Pending/Generating placeholder). The + /// template uses this to render a 👤 marker next to the title so the + /// doctor can tell at a glance which titles they themselves wrote. + oneliner_is_manual: bool, recordings_count: usize, analyzing: bool, has_document: bool, @@ -307,6 +313,9 @@ struct CasePageTemplate { case_id: String, case_id_short: String, oneliner: OnelinerDisplay, + /// Mirror of `UserCaseView::oneliner_is_manual`. See that field for + /// the suppression-during-progress rationale. + oneliner_is_manual: bool, /// RFC3339 UTC timestamp of the most recent recording. `None` iff the /// case has no recordings at all. Drives the datetime header under the /// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34". @@ -622,7 +631,7 @@ pub async fn handle_case_page( .map(|md| crate::analyze::render::md_to_html(&md)); let recordings = scan_recordings(&case_dir).await; - let oneliner = compute_oneliner_display(&case_dir, &recordings).await; + let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await; let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire); let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; @@ -648,6 +657,7 @@ pub async fn handle_case_page( case_id: case_id_str, case_id_short, oneliner, + oneliner_is_manual, recorded_at_iso, time_hms_utc, document_html, @@ -884,7 +894,7 @@ async fn compute_case_view( .filter(|r| !r.failed) .all(|r| r.transcript.has_file()); - let oneliner = compute_oneliner_display(case_path, &recordings).await; + let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await; let has_document = any_document_exists(case_path).await; let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await; @@ -913,6 +923,7 @@ async fn compute_case_view( time_hms_utc, recorded_at_iso, oneliner, + oneliner_is_manual, recordings_count, analyzing, has_document, @@ -961,14 +972,21 @@ async fn ready_text(case_dir: &Path) -> Option { } } -/// Compute the 5-state UI display for the oneliner. Identical logic to -/// what `compute_case_view` uses for the case list — extracted so the -/// case page can render the same states instead of a plain "Fall" -/// fallback. +/// Compute the 5-state UI display for the oneliner plus a flag +/// indicating manual authorship. Identical logic to what +/// `compute_case_view` uses for the case list — extracted so the case +/// page can render the same states instead of a plain "Fall" fallback. +/// +/// Returns `(display, is_manual)`. `is_manual` is `true` iff the +/// persisted state is `OnelinerState::Manual { .. }` AND the case is +/// in a stable state where that text is actually being shown +/// (i.e. not `Pending`/`Generating` — those override the persisted +/// content with progress placeholders, so the manual flag would be +/// misleading there). async fn compute_oneliner_display( case_dir: &Path, recordings: &[RecordingView], -) -> OnelinerDisplay { +) -> (OnelinerDisplay, bool) { let non_failed_count = recordings.iter().filter(|r| !r.failed).count(); let all_transcribed = non_failed_count > 0 && recordings @@ -976,8 +994,9 @@ async fn compute_oneliner_display( .filter(|r| !r.failed) .all(|r| r.transcript.has_file()); let (state, _) = paths::read_oneliner_state(case_dir).await; + let is_manual = matches!(state, Some(OnelinerState::Manual { .. })); - if non_failed_count == 0 { + let display = if non_failed_count == 0 { // All recordings failed — stable end state, no LLM will ever run. // Missing state collapses to Empty ("unbenannt"): no title is // expected, regardless of why. @@ -1007,7 +1026,17 @@ async fn compute_oneliner_display( None => OnelinerDisplay::Pending, Some(_) => OnelinerDisplay::Generating, } - } + }; + + // Suppress the manual marker while we're showing a progress + // placeholder ("transkribiere…", "generiere Titel…"): the displayed + // string isn't the manual text anyway, so a 👤 next to it would lie. + let show_manual = is_manual + && !matches!( + display, + OnelinerDisplay::Pending | OnelinerDisplay::Generating + ); + (display, show_manual) } #[cfg(test)] @@ -1021,6 +1050,7 @@ mod tests { time_hms_utc: String::new(), recorded_at_iso: String::new(), oneliner: OnelinerDisplay::Empty, + oneliner_is_manual: false, recordings_count: 1, analyzing: false, has_document: false, diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 5faefec..84200a9 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -42,6 +42,38 @@ h1 .delete-form { margin: 0 0 0 auto; } + .oneliner-edit { + cursor: pointer; + } + .oneliner-edit:hover .oneliner-text { + text-decoration: underline dotted; + text-underline-offset: 0.2em; + } + .oneliner-edit.editing { + cursor: default; + } + .oneliner-input { + font: inherit; + padding: 0.1em 0.3em; + min-width: 18em; + border: 1px solid #4a90e2; + border-radius: 3px; + background: white; + } + .oneliner-edit.saving .oneliner-input { + opacity: 0.6; + } + .oneliner-edit.error .oneliner-input { + border-color: #e24a4a; + background: #fff0f0; + } + .manual-marker { + font-size: 0.7em; + opacity: 0.55; + margin-left: 0.1em; + cursor: help; + vertical-align: middle; + } .case-time { color: #666; font-size: 1.05em; @@ -260,7 +292,15 @@

- {% call ol::render(oneliner) %} {% if has_failed_recording + {% if is_closed %} + {% call ol::render(oneliner) %} + {% else %} + + {% call ol::render(oneliner) %} + + {% endif %} + {% if oneliner_is_manual %}👤{% endif %} + {% if has_failed_recording %}Fehler{% else if has_document %}ausgewertet{% else %}offen{% endif @@ -558,5 +598,8 @@ }); })(); + diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 3fd2892..c9cfe3c 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -32,6 +32,7 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a .case-row .case-link:hover { text-decoration: underline; } .case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; } .case-row .recordings-link:hover { text-decoration: underline; } +.case-row .manual-marker { font-size: 0.9em; opacity: 0.55; margin-left: 0.15em; cursor: help; } .case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; } .case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; } .case-row .analysis.open .arrow { transform: rotate(90deg); } @@ -111,7 +112,7 @@ try { {% if is_admin %}
{% endif %}
{% match case.analysis_html %}{% when Some with (html) %}
{{ html|safe }}
{% when None %}{% endmatch %} diff --git a/server/templates/partials/oneliner_edit.js b/server/templates/partials/oneliner_edit.js new file mode 100644 index 0000000..13c94b3 --- /dev/null +++ b/server/templates/partials/oneliner_edit.js @@ -0,0 +1,76 @@ +// Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner +// .oneliner-text for an ; Enter POSTs to /web/cases/{id}/oneliner; +// Escape (or blur) cancels. The SSE-driven reload picks up the persisted +// state, so no DOM patching is needed on success. +(() => { + const handleClick = (host) => { + if (host.classList.contains('editing')) return; + const span = host.querySelector('.oneliner-text'); + if (!span) return; + const original = span.textContent.trim(); + host.classList.add('editing'); + const input = document.createElement('input'); + input.type = 'text'; + input.value = original; + input.maxLength = 500; + input.className = 'oneliner-input'; + span.replaceWith(input); + input.focus(); + input.select(); + + const restore = () => { + const restoreSpan = document.createElement('span'); + restoreSpan.className = 'oneliner-text'; + restoreSpan.textContent = original; + input.replaceWith(restoreSpan); + host.classList.remove('editing', 'saving', 'error'); + }; + + const submit = async () => { + const text = input.value.trim(); + if (text === '' || text === original) { restore(); return; } + host.classList.remove('error'); + host.classList.add('saving'); + const body = new URLSearchParams({ + text, + csrf_token: host.dataset.csrf, + }); + try { + const r = await fetch( + `/web/cases/${host.dataset.caseId}/oneliner`, + { method: 'POST', body, credentials: 'same-origin' } + ); + if (!r.ok) { + host.classList.remove('saving'); + host.classList.add('error'); + input.focus(); + return; + } + // SSE will trigger reload; keep input visible until then. + } catch (_) { + host.classList.remove('saving'); + host.classList.add('error'); + input.focus(); + } + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + else if (e.key === 'Escape') { e.preventDefault(); restore(); } + }); + input.addEventListener('blur', () => { + // Blur acts as cancel unless we're already saving (where blur + // happens implicitly when the SSE reload swaps the DOM) or in + // an error state (so the user can re-focus and retry). + if (host.classList.contains('saving') || host.classList.contains('error')) return; + restore(); + }); + }; + + document.addEventListener('click', (e) => { + const host = e.target.closest('.oneliner-edit'); + if (!host) return; + if (e.target.closest('a, button, input')) return; + handleClick(host); + }); +})(); diff --git a/server/tests/common/paths.rs b/server/tests/common/paths.rs index 8ff393a..64f0b44 100644 --- a/server/tests/common/paths.rs +++ b/server/tests/common/paths.rs @@ -61,6 +61,12 @@ pub fn recording_delete(case_id: &str) -> String { format!("/web/cases/{case_id}/recordings/delete") } +/// `POST /web/cases/{case_id}/oneliner` — session-authenticated manual +/// oneliner override (browser web UI). +pub fn case_oneliner_web(case_id: &str) -> String { + format!("/web/cases/{case_id}/oneliner") +} + /// `GET /web/magic?token={token}` — magic-link consumption. pub fn magic(token: &str) -> String { format!("/web/magic?token={token}") diff --git a/server/tests/oneliner_override_test.rs b/server/tests/oneliner_override_test.rs index 9d827f3..23ed6eb 100644 --- a/server/tests/oneliner_override_test.rs +++ b/server/tests/oneliner_override_test.rs @@ -34,7 +34,10 @@ use doctate_server::{ AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session, }; -use common::{TestConfig, body_json, seed_case, seed_oneliner_state, test_user}; +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"; @@ -54,6 +57,17 @@ fn put_request(case_id: &str, body: &str, api_key: Option<&str>) -> Request) -> 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 { @@ -66,7 +80,7 @@ fn build_app(cfg: Arc) -> AppState { 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(), + events_tx, http_client: reqwest::Client::new(), vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()), } @@ -400,3 +414,229 @@ async fn parallel_puts_serialize_via_mutex() { 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 + )); +}