8b1f58ba23
Three UX polish items for the show-closed listing, grouped because
they are the same root problem in three surfaces:
* handle_reopen_case now redirects via resolve_return_path(&headers)
instead of hard-coding /web/cases, so a reopen from the
?show_closed=1 listing stays in that view. Matches the pattern
already used by analyze/reset/delete-recording.
* handle_close_case now redirects via the new helper
resolve_list_return_path(&headers), which wraps resolve_return_path
and additionally collapses a /web/cases/{uuid} detail path to
/web/cases while keeping the query string. A close from the detail
page previously would have tried to redirect back to the now-404
detail, or when triggered from ?show_closed=1 would have dropped
the query.
* Closed cases in my_cases.html now render the checkbox,
Analysieren/Neu analysieren and Reset controls in disabled state
instead of being hidden. Layout stays consistent with mixed-state
listings, and the user can see what actions would be available
after reopen. The handlers remain the authoritative guard via
locate_case_or_404 -> 404 for closed cases, so the HTML disabled
is a hint only.
Adds four unit tests for resolve_list_return_path and two integration
tests for the new redirect behaviour (close from listing with query,
close from detail with query).
1396 lines
47 KiB
Rust
1396 lines
47 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(),
|
|
}
|
|
}
|
|
|
|
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() {
|
|
let config = config_with_llm(unique_tmp("purge-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 _ = 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(unique_tmp("purge-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 _ = 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(unique_tmp("purge-3"), "http://unused".into());
|
|
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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_close_shares_one_closed_at_timestamp() {
|
|
let config = config_with_llm(unique_tmp("bulk-c"), "http://unused".into());
|
|
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() {
|
|
let config = config_with_llm(unique_tmp("bulk-a"), "http://unused".into());
|
|
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());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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;
|
|
}
|