fix: Preserve manual oneliner across delete and reset paths
Consolidate the sticky-Manual rule for oneliner.json into a single helper paths::delete_oneliner_unless_manual that holds the per-case lock, reads the state, keeps OnelinerState::Manual, and removes any LLM-derived variant. Three previously-direct deletion paths now route through it: handle_delete_recording, reset_case_artefacts (admin single-reset), and bulk_reset (admin bulk action). Before this fix a clinician's manual override was wiped by a recording delete, letting the LLM auto-regen path overwrite it on the next view. Tests: 4 unit tests for the wrapper contract, integration tests for the recording-delete and case-reset endpoints (Manual preserved, Ready wiped), and a static-scan invariant that fails on direct remove_file(... ONELINER_FILENAME) calls outside paths.rs.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
//! Admin-only case reset endpoint: POST /web/cases/{case_id}/reset.
|
||||
//!
|
||||
//! Verifies the sticky-Manual rule for the reset path: a clinician-set
|
||||
//! `OnelinerState::Manual` must survive an admin-triggered reset, while
|
||||
//! all LLM-derived oneliner states (`Ready`, `Empty`, `Error`) are
|
||||
//! wiped as before. The wider „transcripts removed, .m4a.failed
|
||||
//! re-armed" contract is covered in `analyze_test.rs`.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::{StatusCode, header};
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use common::{
|
||||
ONELINER_FILENAME, TestConfig, csrf_form_post, login_with_csrf, paths, seed_case,
|
||||
seed_oneliner_ready, seed_oneliner_state, seed_recording, test_admin,
|
||||
};
|
||||
|
||||
fn cfg_admin(label: &'static str) -> std::sync::Arc<doctate_server::config::Config> {
|
||||
TestConfig::new()
|
||||
.with_label(label)
|
||||
.with_user(test_admin("dr_a"))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_case_preserves_manual_oneliner_override() {
|
||||
// Bug regression: an admin reset must NOT destroy a clinician's
|
||||
// manual oneliner override. The Manual variant is sticky for the
|
||||
// lifetime of the case.
|
||||
let cfg = cfg_admin("reset-manual");
|
||||
let case_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
let dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
seed_recording(&dir, "2026-04-26T10-00-00Z", Some("Knie links"));
|
||||
|
||||
let manual_text = "55 J., Knieschmerz links — manuell vom Arzt";
|
||||
seed_oneliner_state(
|
||||
&dir,
|
||||
&OnelinerState::Manual {
|
||||
text: manual_text.into(),
|
||||
set_at: "2026-04-26T12:00:00Z".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
||||
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(csrf_form_post(
|
||||
paths::case_reset(case_id),
|
||||
&cookie,
|
||||
&csrf,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers().get(header::LOCATION).unwrap(),
|
||||
&format!("/web/cases/{case_id}")
|
||||
);
|
||||
|
||||
// Transcript was wiped (the rest of the reset semantics still works).
|
||||
assert!(!dir.join("2026-04-26T10-00-00Z.transcript.txt").exists());
|
||||
// …but the Manual override is untouched.
|
||||
let bytes = std::fs::read(dir.join(ONELINER_FILENAME))
|
||||
.expect("oneliner.json must still exist after reset");
|
||||
let on_disk: OnelinerState =
|
||||
serde_json::from_slice(&bytes).expect("oneliner.json must still be valid JSON");
|
||||
match on_disk {
|
||||
OnelinerState::Manual { text, .. } => {
|
||||
assert_eq!(text, manual_text, "manual text must be byte-identical");
|
||||
}
|
||||
other => panic!("expected Manual after reset, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_case_removes_auto_oneliner() {
|
||||
// Positive counterpart: a `Ready` oneliner (LLM-derived auto state)
|
||||
// must still be wiped by reset — otherwise the wrapper would be
|
||||
// protecting too much.
|
||||
let cfg = cfg_admin("reset-auto");
|
||||
let case_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
let dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
seed_recording(&dir, "2026-04-26T10-00-00Z", Some("Knie links"));
|
||||
seed_oneliner_ready(&dir, "knee, left, pain");
|
||||
|
||||
let (app, store) = doctate_server::create_router_and_session_store(cfg);
|
||||
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(csrf_form_post(
|
||||
paths::case_reset(case_id),
|
||||
&cookie,
|
||||
&csrf,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
assert!(
|
||||
!dir.join(ONELINER_FILENAME).exists(),
|
||||
"Ready oneliner must be wiped by reset"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user