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.
This commit is contained in:
2026-04-26 16:12:03 +02:00
parent c769abe3d5
commit d5e6c334c9
3 changed files with 299 additions and 0 deletions
+85
View File
@@ -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");
}
}
+212
View File
@@ -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<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(_)));
}
}
+2
View File
@@ -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;