refactor: dissolve doctate-client-core into doctate-desktop
Move the 9 client-core modules (case_store, case_update, config, footer_status, pending_cleanup, server_sync, snapshot_cache, startup, upload) into client-desktop/src/. Resolve the config.rs name collision by keeping the TOML schema in config.rs (was core) and renaming the platform-path wrapper to config_path.rs. Introduce client-desktop/src/lib.rs so the modules are reachable under the doctate-desktop:: crate path -- needed for Wear-OS doc anchors that mirror Rust symbols. Bin target stays as `doctate` via [[bin]] name in Cargo.toml. This is the first of four stages preparing the server-vs-clients build-world split. Workspace still has 3 members; directory layout unchanged. All 508 workspace tests pass.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
//! 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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user