//! 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(_))); } }