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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user