Files
doctate/doctate-client-core/src/case_update.rs
T
Brummel d5e6c334c9 feat: Add function to update oneliner locally
Introduces `set_oneliner_local` to `CaseStore` for optimistic UI
updates.
Also adds `case_update` module with `put_oneliner` to handle server-side
persistence.
2026-04-26 16:12:03 +02:00

213 lines
7.2 KiB
Rust

//! 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<OnelinerState, PutOnelinerError> {
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::<OnelinerState>().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(_)));
}
}