Files
doctate/server/tests/case_page_test.rs
T
Brummel 6369ff1680 feat: Add admin flag to user and templates
Passes an `is_admin` flag to the `CaseRecordingsTemplate` and
`case_page.html`.
This flag determines whether the full case ID is displayed in the meta
section
for administrative users. It also influences the header display on the
case page
to prioritize the oneliner when available.
2026-04-19 17:33:29 +02:00

406 lines
13 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_server::config::{Config, User};
use tower::util::ServiceExt;
fn unique_tmp(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"doctate-case-page-{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(),
}
}
fn make_admin(slug: &str) -> User {
let mut u = make_user(slug);
u.role = "admin".into();
u
}
fn seed_oneliner(case_dir: &Path, text: &str) {
std::fs::write(case_dir.join("oneliner.txt"), text).unwrap();
}
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_with_llm(data_path: PathBuf) -> Arc<Config> {
config_with_llm_users(data_path, "http://unused".into(), vec![make_user("dr_a")])
}
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");
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn get_page(case_id: &str, cookie: &str, suffix: &str) -> Request<Body> {
Request::builder()
.uri(format!("/web/cases/{case_id}{suffix}"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
// ---------------------------------------------------------------------
// GET /web/cases/{id} — case page
// ---------------------------------------------------------------------
#[tokio::test]
async fn case_page_shows_document_when_present() {
let config = config_with_llm(unique_tmp("cp-doc"));
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"), "der Inhalt").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("der Inhalt"), "document body missing");
assert!(
body.contains(&format!("/web/cases/{case_id}/recordings")),
"recordings sub-page link missing"
);
}
#[tokio::test]
async fn case_page_document_has_copy_button() {
let config = config_with_llm(unique_tmp("cp-copy"));
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"), "kopiere mich").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(body.contains(r#"id="copy-btn""#), "copy button missing");
assert!(
body.contains(r#"id="doc-content""#),
"doc-content wrapper missing (JS depends on it)"
);
}
#[tokio::test]
async fn case_page_shows_analyze_button_when_transcribed_without_document() {
let config = config_with_llm(unique_tmp("cp-analyze"));
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("transkribierter Text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)),
"analyze form missing"
);
assert!(
!body.contains(r#"id="doc-content""#),
"doc-content must not appear without a document"
);
}
#[tokio::test]
async fn case_page_shows_llm_missing_hint_without_llm() {
let config = config_without_llm(unique_tmp("cp-llm"));
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 resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains("LLM-Analyse nicht konfiguriert"),
"expected llm-missing hint"
);
}
#[tokio::test]
async fn case_page_empty_case_shows_placeholder() {
let config = config_with_llm(unique_tmp("cp-empty"));
let case_id = "11111111-1111-1111-1111-111111111111";
seed_case(&config.data_path, "dr_a", case_id);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(
body.contains("Noch keine Aufnahmen vorhanden"),
"empty-case placeholder missing"
);
assert!(
body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)),
"delete form missing on empty case"
);
}
#[tokio::test]
async fn case_page_foreign_user_returns_404() {
let data_path = unique_tmp("cp-idor");
let users = vec![make_user("dr_a"), make_user("dr_b")];
let config = config_with_llm_users(data_path, "http://unused".into(), users);
let case_id = "22222222-2222-2222-2222-222222222222";
// Case exists under dr_b, but session is dr_a → 404.
let case_dir = seed_case(&config.data_path, "dr_b", case_id);
std::fs::write(case_dir.join("document.md"), "fremdes Dokument").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------
// GET /web/cases/{id}/recordings — recordings sub-page
// ---------------------------------------------------------------------
#[tokio::test]
async fn case_recordings_shows_all_recordings() {
let config = config_with_llm(unique_tmp("rec-list"));
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("erste Aufnahme"));
seed_recording(&case_dir, "11-00-00", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("2026-04-15T10-00-00Z.m4a"));
assert!(body.contains("2026-04-15T11-00-00Z.m4a"));
assert!(body.contains("erste Aufnahme"));
assert!(body.contains("Transkription ausstehend"));
}
#[tokio::test]
async fn case_recordings_back_link_targets_case_page() {
let config = config_with_llm(unique_tmp("rec-back"));
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", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(&format!(r#"href="/web/cases/{case_id}""#)),
"back link must target the case page, not /web/cases"
);
}
#[tokio::test]
async fn case_page_uses_oneliner_as_h1_when_present() {
let config = config_with_llm(unique_tmp("cp-h1"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_oneliner(&case_dir, "Herzkatheter unauffällig");
std::fs::write(case_dir.join("document.md"), "Inhalt").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
// H1 wraps the oneliner; the short case_id fallback must be gone.
assert!(
body.contains("<h1>") && body.contains("Herzkatheter unauffällig"),
"h1 should show the oneliner"
);
// The prominent meta block with the full UUID is admin-only.
assert!(
!body.contains(r#"<div class="meta">"#),
"non-admin must not see the full case_id meta block"
);
}
#[tokio::test]
async fn case_page_admin_sees_full_case_id_meta() {
let data_path = unique_tmp("cp-admin");
let users = vec![make_admin("dr_admin")];
let config = config_with_llm_users(data_path, "http://unused".into(), users);
let case_id = "11111111-1111-1111-1111-111111111111";
seed_case(&config.data_path, "dr_admin", case_id);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_admin").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(case_id),
"admin meta block should expose the full case_id"
);
}
#[tokio::test]
async fn case_recordings_breadcrumb_includes_oneliner() {
let config = config_with_llm(unique_tmp("rec-breadcrumb"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_oneliner(&case_dir, "Kurzer Befund");
seed_recording(&case_dir, "10-00-00", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
// Two separate breadcrumb links: back to the case list and back to the case page.
assert!(
body.contains(r#"href="/web/cases""#),
"breadcrumb must link back to the case list"
);
assert!(
body.contains(&format!(r#"href="/web/cases/{case_id}""#)),
"breadcrumb must link back to the case page"
);
assert!(
body.contains("Kurzer Befund"),
"breadcrumb must show the oneliner text"
);
}
#[tokio::test]
async fn case_recordings_has_no_action_buttons() {
let config = config_with_llm(unique_tmp("rec-ro"));
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 resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
assert!(
!body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)),
"recordings page must not expose the analyze action"
);
assert!(
!body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)),
"recordings page must not expose the delete action"
);
}