mod common; use axum::http::StatusCode; use tower::util::ServiceExt; use doctate_common::oneliners::OnelinerState; use common::{ DOCUMENT_FILE, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording, test_admin, test_user, }; /// Password "s" is the shared test default; wrap `login` so call sites /// don't repeat the literal. async fn login_dr_a(app: &axum::Router) -> String { common::login(app, "dr_a", "s").await } // --------------------------------------------------------------------- // GET /web/cases/{id} — case page // --------------------------------------------------------------------- #[tokio::test] async fn case_page_shows_document_when_present() { let cfg = TestConfig::new() .with_label("cp-doc") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join(DOCUMENT_FILE), "der Inhalt").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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(&paths::case_recordings(case_id)), "recordings sub-page link missing" ); } #[tokio::test] async fn case_page_document_has_copy_button() { let cfg = TestConfig::new() .with_label("cp-copy") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); std::fs::write(case_dir.join(DOCUMENT_FILE), "kopiere mich").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("cp-analyze") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording( &case_dir, "2026-04-15T10-00-00Z", Some("transkribierter Text"), ); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))), "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 cfg = TestConfig::new() .with_label("cp-llm") .with_user(test_user("dr_a")) .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("Text")); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("cp-empty") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; seed_case(&cfg.data_path, "dr_a", case_id); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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="{}""#, paths::case_close(case_id))), "close form missing on empty case" ); } #[tokio::test] async fn case_page_foreign_user_returns_404() { let cfg = TestConfig::new() .with_label("cp-idor") .with_user(test_user("dr_a")) .with_user(test_user("dr_b")) .with_llm("http://unused") .build(); let case_id = "22222222-2222-2222-2222-222222222222"; // Case exists under dr_b, but session is dr_a → 404. let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); std::fs::write(case_dir.join(DOCUMENT_FILE), "fremdes Dokument").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("rec-list") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); seed_recording(&case_dir, "2026-04-15T11-00-00Z", None); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .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 cfg = TestConfig::new() .with_label("rec-back") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-15T10-00-00Z", None); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( body.contains(&format!(r#"href="{}""#, paths::case_detail(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 cfg = TestConfig::new() .with_label("cp-h1") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig"); std::fs::write(case_dir.join(DOCUMENT_FILE), "Inhalt").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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("

") && 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#"
"#), "non-admin must not see the full case_id meta block" ); } /// A case with only silent recordings (empty 0-byte transcript) and a /// persisted `OnelinerState::Empty` must render the "unbenannt" variant /// of the oneliner, never "generiere Titel …". Regression guard for /// the bug where the UI's `compute_oneliner_display` collapsed /// `TranscriptState::Silent` onto "transcribed" and then chose /// `Generating` because no state was persisted — the combination left /// cases stuck on "generiere Titel …" forever. #[tokio::test] async fn case_page_silent_only_shows_empty_not_generating() { let cfg = TestConfig::new() .with_label("cp-silent") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "33333333-3333-3333-3333-333333333333"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); // Some("") → 0-byte transcript sidecar — whisper-as-silence output. seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("")); seed_oneliner_state( &case_dir, &OnelinerState::Empty { generated_at: "2026-04-21T11:00:00Z".into(), }, ); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( !body.contains("generiere Titel"), "silent-only case must not be stuck on 'generiere Titel …'" ); assert!( body.contains("unbenannt"), "expected oneliner to render as 'unbenannt' for Empty state" ); } #[tokio::test] async fn case_page_falls_back_to_generic_title_for_empty_state() { // An `Empty` state (LLM deliberately returned no medical content) is // not a text; the H1 must fall back to the generic "Fall" label, not // leak the kind string or crash the template. let cfg = TestConfig::new() .with_label("cp-empty-gen") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "22222222-2222-2222-2222-222222222222"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_state( &case_dir, &OnelinerState::Empty { generated_at: "2026-04-18T10:00:00Z".into(), }, ); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!(body.contains("Fall"), "expected generic 'Fall' fallback"); assert!( !body.contains("\"kind\""), "must not leak JSON kind discriminator into HTML" ); } #[tokio::test] async fn case_page_admin_sees_admin_view_toggle() { let cfg = TestConfig::new() .with_label("cp-toggle") .with_user(test_admin("dr_admin")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; seed_case(&cfg.data_path, "dr_admin", case_id); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_admin", "s").await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("cp-no-toggle") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; seed_case(&cfg.data_path, "dr_a", case_id); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("cp-admin") .with_user(test_admin("dr_admin")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; seed_case(&cfg.data_path, "dr_admin", case_id); let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_admin", "s").await; let resp = app .oneshot(get_with_cookie(paths::case_detail(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 cfg = TestConfig::new() .with_label("rec-breadcrumb") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_ready(&case_dir, "Kurzer Befund"); seed_recording(&case_dir, "2026-04-15T10-00-00Z", None); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .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(&format!(r#"href="{}""#, paths::CASES)), "breadcrumb must link back to the case list" ); assert!( body.contains(&format!(r#"href="{}""#, paths::case_detail(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 cfg = TestConfig::new() .with_label("rec-ro") .with_user(test_user("dr_a")) .with_llm("http://unused") .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("Text")); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; let resp = app .oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie)) .await .unwrap(); let body = body_string(resp).await; assert!( !body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))), "recordings page must not expose the analyze action" ); assert!( !body.contains(&format!(r#"action="{}""#, paths::case_close(case_id))), "recordings page must not expose the close action" ); }