f438e972d4
Lift duplicated test-harness code (User factories, TestConfig builder, login/CSRF flow, form/json helpers, case-directory seeding, URL path builders) into `server/tests/common/` so future API changes touch one file instead of every integration test. Migrated batches: csrf_attack, auth, login, magic_link, security_headers (28 tests); analyze, case_page, delete_recording, close_watermark, retention_sweep (67 tests). All 95 tests still green. Net line delta across these 10 files: +1113 / -1896 (~780 lines removed), plus ~500 lines of new shared infrastructure under tests/common/.
500 lines
16 KiB
Rust
500 lines
16 KiB
Rust
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use tower::util::ServiceExt;
|
|
|
|
use doctate_common::oneliners::OnelinerState;
|
|
|
|
use common::{
|
|
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.md"), "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.md"), "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.md"), "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.md"), "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("<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"
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|