d5e6c334c9
Introduces `set_oneliner_local` to `CaseStore` for optimistic UI updates. Also adds `case_update` module with `put_oneliner` to handle server-side persistence.
213 lines
7.2 KiB
Rust
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(_)));
|
|
}
|
|
}
|