diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index a83abde..5c81e1c 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -180,6 +180,32 @@ impl CaseStore { Ok(()) } + /// Apply a doctor's manual oneliner override locally — used by the + /// PUT-handler call site (Desktop "Save" button) for an optimistic + /// UI update so the new text shows up before the next `/api/oneliners` + /// poll round-trips. Server-side persistence is the caller's job + /// (via [`crate::put_oneliner`]); this method only mirrors the + /// resulting state into the local cache and broadcasts. + /// + /// No-op if the marker does not exist locally (analogous to + /// [`Self::mark_synced`]). The `last_activity_at` field is left + /// untouched: a manual edit is metadata, not new patient activity. + pub async fn set_oneliner_local( + &self, + case_id: Uuid, + state: OnelinerState, + ) -> Result<(), CaseStoreError> { + let mut store_state = self.state.lock().await; + let Some(marker) = store_state.get_mut(&case_id) else { + return Ok(()); + }; + marker.oneliner = Some(state); + let marker_clone = marker.clone(); + write_marker_file(&self.dir, &marker_clone).await?; + self.broadcast(&store_state); + Ok(()) + } + /// Apply a server `/api/oneliners` response. For every entry in the /// snapshot: insert (with `synced_to_server = true`) or update the /// local marker (oneliner + last_activity_at from server timestamps, @@ -1034,4 +1060,63 @@ mod tests { assert_eq!(list[0].case_id, newer); assert_eq!(list[1].case_id, older); } + + #[tokio::test] + async fn set_oneliner_local_updates_existing_marker() { + let tmp = TempDir::new().unwrap(); + let (store, mut rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); + let id = Uuid::new_v4(); + store + .create_local(id, t("2026-04-18T10:00:00Z")) + .await + .unwrap(); + // Drain the broadcast emitted by `create_local` so the next + // `changed()` call observes the override-write specifically. + let _ = rx.borrow_and_update(); + + let manual = OnelinerState::Manual { + text: "doctor-edit".into(), + set_at: "2026-04-26T11:00:00Z".into(), + }; + store.set_oneliner_local(id, manual.clone()).await.unwrap(); + + // In-memory state reflects the override. + let list = store.list().await; + assert_eq!(list.len(), 1); + match &list[0].oneliner { + Some(OnelinerState::Manual { text, set_at }) => { + assert_eq!(text, "doctor-edit"); + assert_eq!(set_at, "2026-04-26T11:00:00Z"); + } + other => panic!("expected Manual, got {other:?}"), + } + + // Watch channel fired (broadcast). + assert!(rx.has_changed().unwrap_or(false)); + + // Marker file on disk reflects the override too — survives restart. + let path = tmp.path().join(format!("{id}.json")); + let bytes = tokio::fs::read(&path).await.unwrap(); + let on_disk: CaseMarker = serde_json::from_slice(&bytes).unwrap(); + assert!(matches!(on_disk.oneliner, Some(OnelinerState::Manual { .. }))); + } + + #[tokio::test] + async fn set_oneliner_local_no_op_when_marker_missing() { + let tmp = TempDir::new().unwrap(); + let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap(); + let unknown = Uuid::new_v4(); + + let result = store + .set_oneliner_local( + unknown, + OnelinerState::Manual { + text: "x".into(), + set_at: "2026-04-26T11:00:00Z".into(), + }, + ) + .await; + assert!(result.is_ok(), "no-op must not error"); + assert!(store.list().await.is_empty(), "no marker should be created"); + } } diff --git a/doctate-client-core/src/case_update.rs b/doctate-client-core/src/case_update.rs new file mode 100644 index 0000000..950a209 --- /dev/null +++ b/doctate-client-core/src/case_update.rs @@ -0,0 +1,212 @@ +//! HTTP client for `PUT /api/cases/{case_id}/oneliner` — send the +//! doctor's manual oneliner override to the server. +//! +//! Returns the persisted [`OnelinerState`] on success (always +//! `Manual { text, set_at }` if the server obeyed the contract). +//! Errors are split into terminal (operator must fix — wrong API key, +//! unknown case, validation refused) and transient (network blip / +//! 5xx, retry sensible) so the caller can surface the right message. + +use doctate_common::API_KEY_HEADER; +use doctate_common::join_url; +use doctate_common::oneliners::{ + ONELINER_OVERRIDE_PATH_TEMPLATE, OnelinerOverrideRequest, OnelinerState, +}; +use reqwest::StatusCode; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum PutOnelinerError { + /// Server rejected the request for a reason the user must address: + /// 401 (bad API key), 403 (no permission), 404 (unknown case), + /// 400/422 (validation failure). Surfacing the body lets the UI + /// show the server-side message. + #[error("HTTP {status}: {body}")] + Terminal { status: u16, body: String }, + /// Network error or 408/429/5xx — the caller may retry after a + /// backoff. The string is the most useful diagnostic available. + #[error("transient: {0}")] + Transient(String), + /// Server replied 200 but the body did not parse as + /// [`OnelinerState`]. Treated as terminal because there is no + /// obvious retry strategy: a body-shape mismatch is not a hiccup. + #[error("parse response: {0}")] + Parse(String), +} + +/// Fire a `PUT /api/cases/{case_id}/oneliner` request and return the +/// persisted state on success. The server sets `set_at` on the +/// returned variant — clients should not pre-populate it. +pub async fn put_oneliner( + http: &reqwest::Client, + server_url: &str, + api_key: &str, + case_id: Uuid, + text: &str, +) -> Result { + let path = ONELINER_OVERRIDE_PATH_TEMPLATE.replace("{case_id}", &case_id.to_string()); + let url = join_url(server_url, &path); + + let body = OnelinerOverrideRequest { + text: text.to_owned(), + }; + + let resp = match http + .put(&url) + .header(API_KEY_HEADER, api_key) + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(e) => return Err(PutOnelinerError::Transient(format!("network: {e}"))), + }; + + let status = resp.status(); + if status == StatusCode::OK { + match resp.json::().await { + Ok(s) => Ok(s), + Err(e) => Err(PutOnelinerError::Parse(format!("{e}"))), + } + } else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() { + let body = resp.text().await.unwrap_or_default(); + Err(PutOnelinerError::Transient(format!("HTTP {status}: {body}"))) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(PutOnelinerError::Terminal { + status: status.as_u16(), + body, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{header, method, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const TEST_API_KEY: &str = "test-key"; + + async fn mock_server_with(status: u16, body: serde_json::Value) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path_regex(r"^/api/cases/[0-9a-fA-F-]+/oneliner$")) + .and(header(API_KEY_HEADER, TEST_API_KEY)) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(&server) + .await; + server + } + + #[tokio::test] + async fn put_oneliner_happy_path_returns_manual_state() { + let server = mock_server_with( + 200, + json!({ + "kind": "manual", + "text": "55 J., Knieschmerz", + "set_at": "2026-04-26T12:00:00Z" + }), + ) + .await; + + let http = reqwest::Client::new(); + let case_id = Uuid::new_v4(); + let result = put_oneliner(&http, &server.uri(), TEST_API_KEY, case_id, "55 J., Knieschmerz") + .await + .expect("ok"); + match result { + OnelinerState::Manual { text, set_at } => { + assert_eq!(text, "55 J., Knieschmerz"); + assert_eq!(set_at, "2026-04-26T12:00:00Z"); + } + other => panic!("expected Manual, got {other:?}"), + } + } + + #[tokio::test] + async fn put_oneliner_401_is_terminal() { + let server = mock_server_with(401, json!({"error": "Unauthorized"})).await; + let http = reqwest::Client::new(); + + let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x") + .await + .expect_err("must err"); + match err { + PutOnelinerError::Terminal { status, .. } => assert_eq!(status, 401), + other => panic!("expected Terminal, got {other:?}"), + } + } + + #[tokio::test] + async fn put_oneliner_400_is_terminal() { + let server = mock_server_with(400, json!({"error": "empty text"})).await; + let http = reqwest::Client::new(); + + let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x") + .await + .expect_err("must err"); + assert!(matches!( + err, + PutOnelinerError::Terminal { status: 400, .. } + )); + } + + #[tokio::test] + async fn put_oneliner_404_is_terminal() { + let server = mock_server_with(404, json!({"error": "case not found"})).await; + let http = reqwest::Client::new(); + + let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x") + .await + .expect_err("must err"); + assert!(matches!( + err, + PutOnelinerError::Terminal { status: 404, .. } + )); + } + + #[tokio::test] + async fn put_oneliner_503_is_transient() { + let server = mock_server_with(503, json!({"error": "service unavailable"})).await; + let http = reqwest::Client::new(); + + let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x") + .await + .expect_err("must err"); + assert!(matches!(err, PutOnelinerError::Transient(_))); + } + + #[tokio::test] + async fn put_oneliner_429_is_transient() { + let server = mock_server_with(429, json!({"error": "too many requests"})).await; + let http = reqwest::Client::new(); + + let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x") + .await + .expect_err("must err"); + assert!(matches!(err, PutOnelinerError::Transient(_))); + } + + #[tokio::test] + async fn put_oneliner_network_error_is_transient() { + // Point at a URL nothing is listening on. + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap(); + let err = put_oneliner( + &http, + "http://127.0.0.1:1", // reserved port + TEST_API_KEY, + Uuid::new_v4(), + "x", + ) + .await + .expect_err("must err"); + assert!(matches!(err, PutOnelinerError::Transient(_))); + } +} diff --git a/doctate-client-core/src/lib.rs b/doctate-client-core/src/lib.rs index b55225b..feacdbd 100644 --- a/doctate-client-core/src/lib.rs +++ b/doctate-client-core/src/lib.rs @@ -14,6 +14,7 @@ //! - [`pending_cleanup`]: orphan reaper for the pending-upload directory pub mod case_store; +pub mod case_update; pub mod config; pub mod footer_status; pub mod pending_cleanup; @@ -23,6 +24,7 @@ pub mod startup; pub mod upload; pub use case_store::{CaseMarker, CaseStore, CaseStoreError}; +pub use case_update::{PutOnelinerError, put_oneliner}; pub use config::{Config, ConfigError, DEFAULT_POLL_INTERVAL_SECONDS}; pub use footer_status::{FooterStatus, pick_footer_status}; pub use pending_cleanup::cleanup_orphan_audio;