1182f4817e
Move admin checks from individual bulk actions to the main handler for `bulk.rs` and `case_actions.rs`. This consolidates the authorization logic for these sensitive operations. Additionally, refine the HTML template to conditionally render bulk action UI elements and the purge form only when the user is an admin. This ensures that non-admin users do not see or have access to these administrative functions. Update tests to reflect these changes, ensuring that non-admin users are correctly rejected for these actions and that the UI is properly hidden.
1485 lines
50 KiB
Rust
1485 lines
50 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use doctate_server::analyze;
|
|
use doctate_server::config::{Config, User};
|
|
use serde_json::{Value, json};
|
|
use tower::util::ServiceExt;
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Fixture helpers
|
|
// ---------------------------------------------------------------------
|
|
|
|
fn unique_tmp(label: &str) -> PathBuf {
|
|
std::env::temp_dir().join(format!(
|
|
"doctate-close-{label}-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
))
|
|
}
|
|
|
|
fn make_user(slug: &str) -> User {
|
|
User {
|
|
slug: slug.into(),
|
|
api_key: format!("key-{slug}"),
|
|
web_password: bcrypt::hash("s", 4).unwrap(),
|
|
role: "doctor".into(),
|
|
whisper: Default::default(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
preview_lines: 2,
|
|
}
|
|
}
|
|
|
|
fn make_admin(slug: &str) -> User {
|
|
let mut u = make_user(slug);
|
|
u.role = "admin".into();
|
|
u
|
|
}
|
|
|
|
fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc<Config> {
|
|
config_with_llm_users(data_path, llm_url, vec![make_user("dr_a")])
|
|
}
|
|
|
|
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
|
|
let api_keys: HashMap<String, String> = users
|
|
.iter()
|
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
|
.collect();
|
|
Arc::new(Config {
|
|
data_path,
|
|
users,
|
|
api_keys,
|
|
llm_url,
|
|
llm_api_key: "test-key".into(),
|
|
llm_model: "test-model".into(),
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
fn config_without_llm(data_path: PathBuf) -> Arc<Config> {
|
|
let users = vec![make_user("dr_a")];
|
|
let api_keys: HashMap<String, String> = users
|
|
.iter()
|
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
|
.collect();
|
|
Arc::new(Config {
|
|
data_path,
|
|
users,
|
|
api_keys,
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
|
let dir = data_path.join(slug).join(case_id);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) {
|
|
let filename = format!("2026-04-15T{ts_hms}Z.m4a");
|
|
std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap();
|
|
if let Some(text) = transcript {
|
|
let tx_name = format!("2026-04-15T{ts_hms}Z.transcript.txt");
|
|
std::fs::write(case_dir.join(tx_name), text).unwrap();
|
|
}
|
|
}
|
|
|
|
async fn login(app: axum::Router, slug: &str) -> String {
|
|
let body = format!("slug={slug}&password=s");
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/login")
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
|
let s = v.to_str().unwrap();
|
|
if let Some(pair) = s.split(';').next()
|
|
&& pair.starts_with("session=")
|
|
{
|
|
return pair.to_string();
|
|
}
|
|
}
|
|
panic!("no session cookie after login");
|
|
}
|
|
|
|
fn analyze_request(case_id: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/analyze"))
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
fn close_request(case_id: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/close"))
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
fn reopen_request(case_id: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/reopen"))
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
fn purge_closed_request(cookie: &str, confirm: Option<&str>) -> Request<Body> {
|
|
let body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default();
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/purge-closed")
|
|
.header(header::COOKIE, cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// HTTP-level precondition tests
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn analyze_without_cookie_redirects_to_login() {
|
|
let config = config_with_llm(unique_tmp("a"), "http://unused".into());
|
|
let app = doctate_server::create_router(config);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/analyze"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_foreign_case_returns_404() {
|
|
let config = config_with_llm(unique_tmp("b"), "http://unused".into());
|
|
// Seed a case owned by someone else.
|
|
let foreign_case = "22222222-2222-2222-2222-222222222222";
|
|
let foreign_dir = config.data_path.join("dr_other").join(foreign_case);
|
|
std::fs::create_dir_all(&foreign_dir).unwrap();
|
|
seed_recording(&foreign_dir, "10-00-00", Some("text"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(foreign_case, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_invalid_uuid_returns_400() {
|
|
let config = config_with_llm(unique_tmp("c"), "http://unused".into());
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request("not-a-uuid", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_without_recordings_returns_400() {
|
|
let config = config_with_llm(unique_tmp("d"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
seed_case(&config.data_path, "dr_a", case_id);
|
|
// No .m4a files.
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_with_missing_transcript_returns_400() {
|
|
let config = config_with_llm(unique_tmp("e"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
seed_recording(&case_dir, "10-05-00", None); // still transcribing
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_without_llm_returns_503() {
|
|
let config = config_without_llm(unique_tmp("f"));
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// HTTP-level happy path + race
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_happy_path_writes_input_and_redirects() {
|
|
let config = config_with_llm(unique_tmp("g"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("Patient klagt über Knie."));
|
|
seed_recording(
|
|
&case_dir,
|
|
"10-05-00",
|
|
Some("Korrektur: links, nicht rechts."),
|
|
);
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"/web/cases"
|
|
);
|
|
|
|
let input_path = case_dir.join("analysis_input.json");
|
|
assert!(input_path.exists(), "analysis_input.json missing");
|
|
|
|
let raw = std::fs::read_to_string(&input_path).unwrap();
|
|
let parsed: Value = serde_json::from_str(&raw).unwrap();
|
|
let recs = parsed["recordings"].as_array().unwrap();
|
|
assert_eq!(recs.len(), 2);
|
|
assert_eq!(recs[0]["recorded_at"], "2026-04-15T10:00:00Z");
|
|
assert_eq!(recs[0]["text"], "Patient klagt über Knie.");
|
|
assert_eq!(recs[1]["recorded_at"], "2026-04-15T10:05:00Z");
|
|
assert!(parsed["last_recording_mtime"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_second_time_returns_409() {
|
|
let config = config_with_llm(unique_tmp("h"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("text"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let first = app
|
|
.clone()
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), StatusCode::SEE_OTHER);
|
|
|
|
let second = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn analyze_case_skips_blank_transcript_from_recordings() {
|
|
let config = config_with_llm(unique_tmp("i"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("echt"));
|
|
seed_recording(&case_dir, "10-05-00", Some(" ")); // blank
|
|
seed_recording(&case_dir, "10-10-00", Some("auch echt"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
let raw = std::fs::read_to_string(case_dir.join("analysis_input.json")).unwrap();
|
|
let parsed: Value = serde_json::from_str(&raw).unwrap();
|
|
let recs = parsed["recordings"].as_array().unwrap();
|
|
assert_eq!(recs.len(), 2, "blank transcript must be filtered out");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Worker + wiremock integration
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn analyze_worker_writes_document_via_wiremock() {
|
|
let tmp = unique_tmp("w");
|
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
let input = json!({
|
|
"last_recording_mtime": "2026-04-15T10:00:00Z",
|
|
"recordings": [
|
|
{ "recorded_at": "2026-04-15T10:00:00Z", "text": "Patient mit Knie." }
|
|
]
|
|
});
|
|
std::fs::write(
|
|
case_dir.join("analysis_input.json"),
|
|
serde_json::to_vec_pretty(&input).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let mock = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"choices": [{
|
|
"message": { "content": "Zusammenfassung: Knie-Beschwerden." }
|
|
}]
|
|
})))
|
|
.mount(&mock)
|
|
.await;
|
|
|
|
let config = config_with_llm(tmp.clone(), mock.uri());
|
|
let (tx, rx) = analyze::channel();
|
|
let client = reqwest::Client::new();
|
|
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
));
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.unwrap();
|
|
|
|
// Poll for the document (worker is in a separate task).
|
|
let document_path = case_dir.join("document.md");
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while !document_path.exists() {
|
|
if std::time::Instant::now() > deadline {
|
|
panic!("document.md not written within 5s");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&document_path).unwrap();
|
|
assert!(
|
|
content.contains("Knie-Beschwerden"),
|
|
"unexpected content: {content}"
|
|
);
|
|
|
|
drop(tx);
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
|
}
|
|
|
|
/// The analyze worker must pass the LLM output through the Gazetteer
|
|
/// as a post-AI normalization filter. The mock LLM is primed to return
|
|
/// "Zerebrum" (an edit-distance-1 variant of the vocab entry
|
|
/// "Cerebrum"). The persisted `document.md` must contain the canonical
|
|
/// "Cerebrum", not the LLM's drift.
|
|
#[tokio::test]
|
|
async fn analyze_worker_normalizes_llm_output() {
|
|
let tmp = unique_tmp("g");
|
|
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
let input = json!({
|
|
"last_recording_mtime": "2026-04-15T10:00:00Z",
|
|
"recordings": [
|
|
{ "recorded_at": "2026-04-15T10:00:00Z", "text": "Patient mit Blutung im Zerebrum." }
|
|
]
|
|
});
|
|
std::fs::write(
|
|
case_dir.join("analysis_input.json"),
|
|
serde_json::to_vec_pretty(&input).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Vocab directory with a single anatomy entry.
|
|
let vocab_dir = unique_tmp("vg");
|
|
std::fs::create_dir_all(&vocab_dir).unwrap();
|
|
std::fs::write(vocab_dir.join("anatomy.txt"), "Cerebrum\n").unwrap();
|
|
let vocab = Arc::new(
|
|
doctate_server::gazetteer::Gazetteer::load_dir(&vocab_dir)
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
assert_eq!(vocab.len(), 1);
|
|
|
|
// Mock LLM returns the *uncorrected* form — we're proving the
|
|
// gazetteer rewrites it on the way out of the analyze worker.
|
|
let mock = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"choices": [{
|
|
"message": { "content": "Patient mit Blutung im Zerebrum." }
|
|
}]
|
|
})))
|
|
.mount(&mock)
|
|
.await;
|
|
|
|
let config = config_with_llm(tmp.clone(), mock.uri());
|
|
let (tx, rx) = analyze::channel();
|
|
let client = reqwest::Client::new();
|
|
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
));
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.unwrap();
|
|
|
|
let document_path = case_dir.join("document.md");
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while !document_path.exists() {
|
|
if std::time::Instant::now() > deadline {
|
|
panic!("document.md not written within 5s");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
|
|
// The persisted document must show the canonical form even though
|
|
// the LLM returned the drift form.
|
|
let document = std::fs::read_to_string(&document_path).unwrap();
|
|
assert_eq!(document, "Patient mit Blutung im Cerebrum.");
|
|
|
|
// Sanity: one LLM call happened.
|
|
let requests = mock.received_requests().await.unwrap();
|
|
assert_eq!(requests.len(), 1, "expected exactly one LLM request");
|
|
|
|
drop(tx);
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
|
}
|
|
|
|
/// Manual debug utility — not part of the default test set.
|
|
/// Usage:
|
|
/// DRY_CASE_DIR=/path/to/case cargo test --test analyze_test replace_real_case_dry -- --ignored --nocapture
|
|
///
|
|
/// Prints each transcript in a given case dir side-by-side with its
|
|
/// gazetteer-normalized version. Useful to eyeball false-positives /
|
|
/// false-negatives on real cases.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn replace_real_case_dry() {
|
|
let vocab_dir = std::env::var("DRY_VOCAB_DIR")
|
|
.unwrap_or_else(|_| "/home/brummel/dev/doctate/server/vocab".into());
|
|
let case_dir = std::env::var("DRY_CASE_DIR").unwrap_or_else(|_| {
|
|
"/home/brummel/dev/doctate/tmpdata/dr_mueller/e817d8b8-43eb-436a-8364-76b80a1e627b".into()
|
|
});
|
|
|
|
let vocab = doctate_server::gazetteer::Gazetteer::load_dir(Path::new(&vocab_dir))
|
|
.await
|
|
.expect("load vocab");
|
|
println!("Gazetteer entries: {}", vocab.len());
|
|
|
|
let mut names: Vec<_> = std::fs::read_dir(&case_dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok().map(|e| e.path()))
|
|
.filter(|p| {
|
|
p.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(|s| s.ends_with(".transcript.txt"))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
names.sort();
|
|
|
|
for path in names {
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
let replaced = vocab.replace(&text);
|
|
println!("\n=== {} ===", path.file_name().unwrap().to_string_lossy());
|
|
println!("--- original ---\n{text}");
|
|
println!("--- replaced ---\n{replaced}");
|
|
if replaced == text {
|
|
println!("[no replacements applied]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Recovery
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn recovery_enqueues_pending_analysis() {
|
|
let tmp = unique_tmp("r1");
|
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap();
|
|
|
|
let (tx, mut rx) = analyze::channel();
|
|
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
|
|
|
|
let job = rx.try_recv().expect("expected one job");
|
|
assert_eq!(job.case_dir, case_dir);
|
|
assert!(rx.try_recv().is_err(), "no further jobs expected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_skips_completed_analysis() {
|
|
let tmp = unique_tmp("r2");
|
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap();
|
|
std::fs::write(case_dir.join("document.md"), "done").unwrap();
|
|
|
|
let (tx, mut rx) = analyze::channel();
|
|
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
|
|
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"completed analysis must not re-enqueue"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Re-analysis path: same handler, version derived from existing documents
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn analyze_deletes_old_document_and_writes_fresh_input() {
|
|
let config = config_with_llm(unique_tmp("rn-5"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
std::fs::write(case_dir.join("document.md"), "altes Dokument").unwrap();
|
|
seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme"));
|
|
seed_recording(&case_dir, "10-05-00", Some("zweite Aufnahme"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(analyze_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
assert!(
|
|
!case_dir.join("document.md").exists(),
|
|
"old document must be deleted before re-analysis"
|
|
);
|
|
let input_path = case_dir.join("analysis_input.json");
|
|
assert!(input_path.exists(), "analysis_input.json missing");
|
|
let parsed: Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap();
|
|
assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Close / Reopen
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn close_writes_marker_and_hides_case() {
|
|
let config = config_with_llm(unique_tmp("close-1"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"/web/cases"
|
|
);
|
|
let marker = case_dir.join(".closed");
|
|
assert!(marker.exists(), ".closed marker missing");
|
|
let raw = std::fs::read_to_string(&marker).unwrap();
|
|
let parsed: Value = serde_json::from_str(&raw).unwrap();
|
|
assert!(
|
|
parsed["closed_at"].is_string(),
|
|
"closed_at must be a string"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn closed_case_returns_404_on_detail() {
|
|
let config = config_with_llm(unique_tmp("close-2"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/web/cases/{case_id}"))
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopen_removes_marker_and_restores_case() {
|
|
let config = config_with_llm(unique_tmp("reopen-1"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
// Close, then reopen via the explicit endpoint.
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert!(case_dir.join(".closed").exists());
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(reopen_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert!(
|
|
!case_dir.join(".closed").exists(),
|
|
".closed marker must be gone after reopen"
|
|
);
|
|
|
|
// Case is addressable again at the detail route — no lingering state.
|
|
let detail = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/web/cases/{case_id}"))
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(detail.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn show_closed_query_includes_closed_cases() {
|
|
let config = config_with_llm(unique_tmp("show-closed"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("a closed one"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
// Close first.
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Default listing must NOT show it.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/cases")
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = String::from_utf8(
|
|
axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap()
|
|
.to_vec(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!body.contains(case_id),
|
|
"default /web/cases must hide closed cases"
|
|
);
|
|
|
|
// With show_closed=1 it must appear, with the closed badge.
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/cases?show_closed=1")
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = String::from_utf8(
|
|
axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap()
|
|
.to_vec(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
body.contains(case_id),
|
|
"show_closed=1 must list closed cases"
|
|
);
|
|
assert!(
|
|
body.contains("geschlossen"),
|
|
"closed badge must render in show_closed mode"
|
|
);
|
|
assert!(
|
|
body.contains("reopen"),
|
|
"reopen button must be rendered for closed cases"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn closed_case_detail_with_show_closed_returns_200() {
|
|
let config = config_with_llm(unique_tmp("show-detail"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("x"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Without query → 404 (covered by closed_case_returns_404_on_detail).
|
|
// With ?show_closed=1 → 200 with the reopen form.
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/web/cases/{case_id}?show_closed=1"))
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = String::from_utf8(
|
|
axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap()
|
|
.to_vec(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
body.contains(&format!("/web/cases/{case_id}/reopen")),
|
|
"detail page of a closed case must offer a reopen form"
|
|
);
|
|
assert!(
|
|
!body.contains(&format!("/web/cases/{case_id}/close\"")),
|
|
"detail page of a closed case must NOT offer a close form"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn close_redirects_back_to_referer_query() {
|
|
// Close from the show-closed listing must keep ?show_closed=1 so
|
|
// the user stays in the view they were in.
|
|
let config = config_with_llm(unique_tmp("close-ref-q"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/close"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::REFERER, "/web/cases?show_closed=1")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"/web/cases?show_closed=1"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn close_from_detail_redirects_to_list_preserving_query() {
|
|
// Close from /web/cases/{uuid}?show_closed=1 must NOT redirect back
|
|
// to the detail page (it 404s after the close). The uuid segment
|
|
// is stripped; the query survives so the user lands in show-closed.
|
|
let config = config_with_llm(unique_tmp("close-ref-det"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/close"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(
|
|
header::REFERER,
|
|
format!("/web/cases/{case_id}?show_closed=1"),
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"/web/cases?show_closed=1",
|
|
"close from detail must redirect to the LIST with the query intact"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopen_redirects_back_to_referer() {
|
|
// User reopens a case from the show-closed listing. The redirect
|
|
// must preserve `?show_closed=1` so the user stays in that view
|
|
// instead of being ejected back to the default open-only listing.
|
|
let config = config_with_llm(unique_tmp("reopen-ref"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/reopen"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::REFERER, "/web/cases?show_closed=1")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get(header::LOCATION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"/web/cases?show_closed=1",
|
|
"reopen must redirect back to the referer path (preserving the query)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopen_is_noop_on_open_case() {
|
|
let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app.oneshot(reopen_request(case_id, &cookie)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert!(!case_dir.join(".closed").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn purge_closed_removes_directory() {
|
|
// purge-closed is admin-only (matches the UI, which hides the bar).
|
|
let config = config_with_llm_users(
|
|
unique_tmp("purge-1"),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert!(case_dir.join(".closed").exists());
|
|
|
|
let resp = app
|
|
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert!(
|
|
!case_dir.exists(),
|
|
"case directory must be removed after purge"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn purge_requires_confirm_param() {
|
|
let config = config_with_llm_users(
|
|
unique_tmp("purge-2"),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(purge_closed_request(&cookie, None))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
assert!(
|
|
case_dir.exists(),
|
|
"case directory must survive purge without confirm=yes"
|
|
);
|
|
assert!(case_dir.join(".closed").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn purge_closed_keeps_open_cases() {
|
|
let config = config_with_llm_users(
|
|
unique_tmp("purge-3"),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let closed_id = "11111111-1111-1111-1111-111111111111";
|
|
let open_id = "22222222-2222-2222-2222-222222222222";
|
|
let dir_closed = seed_case(&config.data_path, "dr_a", closed_id);
|
|
let dir_open = seed_case(&config.data_path, "dr_a", open_id);
|
|
seed_recording(&dir_closed, "10-00-00", Some("a"));
|
|
seed_recording(&dir_open, "10-00-00", Some("b"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(closed_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert!(!dir_closed.exists(), "closed case must be purged");
|
|
assert!(dir_open.exists(), "open case must survive the purge");
|
|
}
|
|
|
|
/// Regression guard: purge-closed is destructive and admin-only. A
|
|
/// non-admin POST must be rejected with 403 and leave the closed case
|
|
/// directory intact.
|
|
#[tokio::test]
|
|
async fn purge_closed_rejected_for_non_admin() {
|
|
// Default `make_user` is role=doctor.
|
|
let config = config_with_llm(unique_tmp("purge-nonadmin"), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&case_dir, "10-00-00", Some("ok"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
// Close the case first so there is something for purge to target.
|
|
let _ = app
|
|
.clone()
|
|
.oneshot(close_request(case_id, &cookie))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(purge_closed_request(&cookie, Some("yes")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
assert!(case_dir.exists(), "non-admin must not be able to purge");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_close_shares_one_closed_at_timestamp() {
|
|
// Bulk actions are admin-only since the UI hides the selection
|
|
// from non-admins; the handler enforces the same gate.
|
|
let config = config_with_llm_users(
|
|
unique_tmp("bulk-c"),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let case_a = "11111111-1111-1111-1111-111111111111";
|
|
let case_b = "22222222-2222-2222-2222-222222222222";
|
|
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
|
|
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
|
|
seed_recording(&dir_a, "10-00-00", Some("a"));
|
|
seed_recording(&dir_b, "10-05-00", Some("b"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let body = format!("action=close&case_id={case_a}&case_id={case_b}");
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
let m_a: Value =
|
|
serde_json::from_str(&std::fs::read_to_string(dir_a.join(".closed")).unwrap()).unwrap();
|
|
let m_b: Value =
|
|
serde_json::from_str(&std::fs::read_to_string(dir_b.join(".closed")).unwrap()).unwrap();
|
|
assert_eq!(
|
|
m_a["closed_at"], m_b["closed_at"],
|
|
"bulk-close must share one closed_at timestamp across its selection"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_analyze_processes_each_selected_case() {
|
|
// Bulk analyze is admin-only (UI + handler).
|
|
let config = config_with_llm_users(
|
|
unique_tmp("bulk-a"),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let case_a = "11111111-1111-1111-1111-111111111111";
|
|
let case_b = "22222222-2222-2222-2222-222222222222";
|
|
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
|
|
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
|
|
seed_recording(&dir_a, "10-00-00", Some("a"));
|
|
seed_recording(&dir_b, "10-05-00", Some("b"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let body = format!("action=analyze&case_id={case_a}&case_id={case_b}");
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
|
|
assert!(dir_a.join("analysis_input.json").exists());
|
|
assert!(dir_b.join("analysis_input.json").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_skips_closed_cases() {
|
|
let tmp = unique_tmp("rec-closed");
|
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap();
|
|
std::fs::write(
|
|
case_dir.join(".closed"),
|
|
r#"{"closed_at":"2026-04-15T10:00:00Z"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let (tx, mut rx) = analyze::channel();
|
|
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
|
|
|
|
assert!(rx.try_recv().is_err(), "closed case must not be enqueued");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Admin-only reset
|
|
// ---------------------------------------------------------------------
|
|
|
|
fn reset_request(case_id: &str, cookie: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/reset"))
|
|
.header(header::COOKIE, cookie)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_case_clears_derived_and_unfails_audio() {
|
|
let tmp = unique_tmp("reset-admin");
|
|
let config = config_with_llm_users(
|
|
tmp.clone(),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a")],
|
|
);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
|
|
// Good recording with transcript.
|
|
seed_recording(&dir, "09-00-00", Some("hallo"));
|
|
// A failed recording: raw audio written, but filename carries `.failed`.
|
|
std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap();
|
|
// Derived artefacts the reset must wipe.
|
|
std::fs::write(
|
|
dir.join("oneliner.json"),
|
|
br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
|
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases");
|
|
|
|
// Audio preserved, .failed renamed to .m4a.
|
|
assert!(dir.join("2026-04-15T09-00-00Z.m4a").exists());
|
|
assert!(dir.join("2026-04-15T09-05-00Z.m4a").exists());
|
|
assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists());
|
|
// Derived artefacts gone.
|
|
assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
|
|
assert!(!dir.join("oneliner.json").exists());
|
|
assert!(!dir.join("document.md").exists());
|
|
assert!(!dir.join("analysis_input.json").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_case_requires_admin() {
|
|
let tmp = unique_tmp("reset-nonadmin");
|
|
// Default `make_user` has role=doctor, not admin.
|
|
let config = config_with_llm(tmp.clone(), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&dir, "09-00-00", Some("hallo"));
|
|
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
|
|
// Nothing touched.
|
|
assert!(dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
|
|
assert!(dir.join("document.md").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_reset_processes_multiple_cases_admin_only() {
|
|
let tmp = unique_tmp("bulk-reset");
|
|
let config = config_with_llm_users(
|
|
tmp.clone(),
|
|
"http://unused".into(),
|
|
vec![make_admin("dr_a"), make_user("dr_b")],
|
|
);
|
|
let case_a = "11111111-1111-1111-1111-111111111111";
|
|
let case_b = "22222222-2222-2222-2222-222222222222";
|
|
let dir_a = seed_case(&config.data_path, "dr_a", case_a);
|
|
let dir_b = seed_case(&config.data_path, "dr_a", case_b);
|
|
seed_recording(&dir_a, "10-00-00", Some("a"));
|
|
seed_recording(&dir_b, "10-05-00", Some("b"));
|
|
std::fs::write(dir_a.join("document.md"), "A").unwrap();
|
|
std::fs::write(dir_b.join("document.md"), "B").unwrap();
|
|
// dr_b gets its own case to verify non-admin branch without clobbering dr_a.
|
|
let case_c = "33333333-3333-3333-3333-333333333333";
|
|
let dir_c = seed_case(&config.data_path, "dr_b", case_c);
|
|
seed_recording(&dir_c, "11-00-00", Some("c"));
|
|
std::fs::write(dir_c.join("document.md"), "C").unwrap();
|
|
|
|
let app = doctate_server::create_router(config);
|
|
|
|
// Admin: bulk reset succeeds.
|
|
let cookie_admin = login(app.clone(), "dr_a").await;
|
|
let body = format!("action=reset&case_id={case_a}&case_id={case_b}");
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie_admin)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
assert!(!dir_a.join("document.md").exists());
|
|
assert!(!dir_b.join("document.md").exists());
|
|
assert!(!dir_a.join("2026-04-15T10-00-00Z.transcript.txt").exists());
|
|
assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists());
|
|
|
|
// Non-admin: bulk reset is rejected, dr_b's case untouched.
|
|
let cookie_doctor = login(app.clone(), "dr_b").await;
|
|
let body = format!("action=reset&case_id={case_c}");
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie_doctor)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
assert!(dir_c.join("document.md").exists());
|
|
assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").exists());
|
|
}
|
|
|
|
/// Regression guard: the bulk route handles *all* actions (including
|
|
/// `close`) and is admin-only. A non-admin POST must be rejected with
|
|
/// 403 and the case must remain open. Matches the UI which no longer
|
|
/// renders the bulk bar for non-admins.
|
|
#[tokio::test]
|
|
async fn bulk_close_rejected_for_non_admin() {
|
|
let tmp = unique_tmp("bulk-close-nonadmin");
|
|
// Default `make_user` is role=doctor.
|
|
let config = config_with_llm(tmp.clone(), "http://unused".into());
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let dir = seed_case(&config.data_path, "dr_a", case_id);
|
|
seed_recording(&dir, "10-00-00", Some("a"));
|
|
|
|
let app = doctate_server::create_router(config);
|
|
let cookie = login(app.clone(), "dr_a").await;
|
|
|
|
let body = format!("action=close&case_id={case_id}");
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
// Close marker must NOT have been written.
|
|
assert!(!dir.join(".closed").exists());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Ollama-style (no-auth) provider
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// Proves the analysis pipeline works against an OpenAI-compatible endpoint
|
|
/// that expects **no** Authorization header — i.e. Ollama's `/v1` surface.
|
|
/// Guard mock (registered first, so wiremock picks it up for any request that
|
|
/// actually carries the header) must stay at `.expect(0)`; happy mock takes
|
|
/// all no-auth calls. This catches any future regression where the client
|
|
/// accidentally sends `Authorization: Bearer `.
|
|
#[tokio::test]
|
|
async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
|
let tmp = unique_tmp("ollama");
|
|
let case_dir = tmp.join("dr_a/22222222-2222-2222-2222-222222222222");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
|
|
let input = json!({
|
|
"last_recording_mtime": "2026-04-16T10:00:00Z",
|
|
"recordings": [
|
|
{ "recorded_at": "2026-04-16T10:00:00Z", "text": "Test." }
|
|
]
|
|
});
|
|
std::fs::write(
|
|
case_dir.join("analysis_input.json"),
|
|
serde_json::to_vec_pretty(&input).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let mock = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(wiremock::matchers::header_exists("authorization"))
|
|
.respond_with(ResponseTemplate::new(500))
|
|
.expect(0)
|
|
.mount(&mock)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"choices": [{ "message": { "content": "# Doc via Ollama" } }]
|
|
})))
|
|
.expect(1)
|
|
.mount(&mock)
|
|
.await;
|
|
|
|
// Ollama-style config: explicitly empty api_key. Inline because
|
|
// `config_with_llm` hard-codes "test-key" for hosted-provider tests.
|
|
let users = vec![make_user("dr_a")];
|
|
let api_keys: HashMap<String, String> = users
|
|
.iter()
|
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
|
.collect();
|
|
let config = Arc::new(Config {
|
|
data_path: tmp.clone(),
|
|
users,
|
|
api_keys,
|
|
llm_url: mock.uri(),
|
|
llm_api_key: String::new(),
|
|
llm_model: "llama3.3:70b".into(),
|
|
..Config::test_default()
|
|
});
|
|
|
|
let (tx, rx) = analyze::channel();
|
|
let client = reqwest::Client::new();
|
|
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
let handle = tokio::spawn(analyze::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
));
|
|
|
|
tx.send(analyze::AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.unwrap();
|
|
|
|
let document_path = case_dir.join("document.md");
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while !document_path.exists() {
|
|
if std::time::Instant::now() > deadline {
|
|
panic!("document.md not written within 5s");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&document_path).unwrap();
|
|
assert!(content.contains("Ollama"), "unexpected content: {content}");
|
|
|
|
drop(tx);
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
|
}
|