Files
doctate/server/tests/case_page_test.rs
T
Brummel 9cc4dc6384 feat: Add admin view toggle to web UI
Introduces a toggle in the web UI that allows administrators to switch
between a standard view and an "admin view". This admin view exposes
additional administrative elements that are hidden in the standard view.

The toggle state is persisted in local storage, allowing the user's
preference to be remembered across sessions. Non-administrator users
will not see the toggle.
feat: Add admin view toggle to web UI

Introduce a client-side toggle for an "admin view" in the web UI. This
allows administrators
to selectively hide or show elements intended only for administrative
purposes. The toggle
state is persisted in local storage for a persistent user experience.

The implementation involves:
- Adding a checkbox element to the header of the case pages and my cases
  list.
- Using CSS to conditionally hide elements with the `admin-only` class
  when the admin view is off.
- Implementing JavaScript to manage the toggle's state, update local
  storage, and apply the
  `admin-view-off` class to the `<html>` element.
- Updating relevant templates (`case_page.html`, `case_recordings.html`,
  `my_cases.html`)
  to include the toggle and the `admin-only` class where appropriate.
- Adding unit tests to verify the visibility of the toggle for admins
  and non-admins.
2026-04-19 18:46:11 +02:00

444 lines
15 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_admin_view_toggle() {
let data_path = unique_tmp("cp-toggle");
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(r#"id="admin-view-toggle""#),
"admin must see the admin-view toggle checkbox"
);
}
#[tokio::test]
async fn case_page_non_admin_has_no_admin_view_toggle() {
let config = config_with_llm(unique_tmp("cp-no-toggle"));
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();
let body = body_string(resp).await;
assert!(
!body.contains(r#"id="admin-view-toggle""#),
"non-admin must not see the admin-view toggle"
);
}
#[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"
);
}