feat: Add web UI for manual oneliner overrides
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.
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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<Body
|
||||
/// 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 {
|
||||
@@ -66,7 +80,7 @@ fn build_app(cfg: Arc<doctate_server::config::Config>) -> 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
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user