Files
doctate/server/tests/case_page_test.rs
T
Brummel 0308245524 feat(web): fuse copy + close into one control on the case page
A finished case is handled in one gesture: a text button below the
document copies the document text to the clipboard and then closes the
case, landing back on /cases. Replaces the icon-only trash-can close
that sat in the H1 title row.

Behaviour:
- Open case with a document: "Kopieren + Schließen" below the document.
  The submit handler awaits the clipboard write inside the user gesture,
  then submits the close form; a FAILED copy aborts the close and reveals
  an inline error, so a finished case never disappears from the default
  list while the clipboard is empty.
- Open case without a document: a bare "Schließen" button (nothing to
  copy) keeps the close affordance the removed trash-can used to provide.
- Closed case: neither button renders; the reopen affordance is
  untouched.

Frontend-only. No Rust route/handler change: POST /cases/{id}/close
already strips the case-page Referer to /cases, so "back to list" needs
no new server work. The two-path clipboard routine (async Clipboard API
+ textarea/execCommand fallback) is extracted into a shared copyDocText()
used by both the standalone icon copy button (kept, copy-without-close)
and the new combined control — one source, no drift.

Tests (RED-first, server/tests/case_page_test.rs): open+document renders
the copy-close form with the close action and CSRF token; closed case
hides it; the H1 trash-can is gone on an open case; the documentless
open case keeps a bare Schließen. Full server suite green, clippy clean,
fmt no-op. The JS submit-handler ordering (copy-before-navigate,
abort-on-fail) has no Rust render harness — same inherent layer gap as
the pre-existing #copy-btn — and is verified manually.

Covers the case-page half of #14; the per-row case-list copy+close is
deferred.

refs #14
2026-06-01 14:35:17 +02:00

965 lines
34 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, write_closed_marker,
write_document_for_test,
};
/// 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 /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);
write_document_for_test(&case_dir, "der Inhalt", "1970-01-01T00:00:00Z");
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);
write_document_for_test(&case_dir, "kopiere mich", "1970-01-01T00:00:00Z");
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, settings) = TestConfig::new()
.with_label("cp-analyze")
.with_user(test_user("dr_a"))
.with_llm("http://unused")
.build_pair();
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_with_settings(cfg, settings);
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"
);
}
/// Bug regression: a previous LLM run that failed (e.g. transient HTTP
/// transport error to the inference endpoint) leaves a `.analysis_failed.json`
/// marker on disk. With no `document.md` yet, the existing "Neu analysieren"
/// button — which lives inside the document-rendered branch — is invisible,
/// and the auto-trigger refuses to retry as long as the marker matches the
/// current input. The case page must therefore surface a recovery banner
/// that exposes the reason and a retry form, otherwise the user is stuck.
///
/// Doctor variant: only the default-backend "Erneut versuchen" button is
/// shown; per-backend choice is admin-only (covered by the sibling test).
#[tokio::test]
async fn case_page_shows_failure_banner_with_retry_when_marker_present() {
let (cfg, settings) = TestConfig::new()
.with_label("cp-fail-banner")
.with_user(test_user("dr_a"))
.with_llm("http://unused")
.build_pair();
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"),
);
// Seed the marker exactly as the analyze worker would on a transport
// failure. `last_recording_mtime` is intentionally close to (but not
// exactly equal to) the freshly-seeded recording's mtime — the banner
// path reads the marker unconditionally, so equality is irrelevant
// here; we only need the file to exist and parse.
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout to https://example.invalid/v1/chat/completions","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.unwrap();
let app = doctate_server::create_router_with_settings(cfg, settings);
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(r#"class="failure-banner""#),
"failure-banner div missing"
);
assert!(
body.contains("Analyse fehlgeschlagen"),
"user-facing failure title missing"
);
assert!(
body.contains("connect timeout"),
"raw reason text not surfaced (expected inside <details>)"
);
assert!(
body.contains(r#"datetime="2026-04-15T10:05:00Z""#),
"failed_at not rendered as <time datetime=...> for browser locale formatting"
);
assert!(
body.contains(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
"retry form must POST to the analyze endpoint"
);
assert!(
body.contains(">Erneut versuchen</button>"),
"default retry button missing (expected 'Erneut versuchen')"
);
assert!(
!body.contains("Erneut mit "),
"per-backend retry buttons must not be shown to non-admins"
);
assert!(
!body.contains("Wird analysiert"),
"the analyzing placeholder must not coexist with the failure banner"
);
}
/// Admin variant of the failure-banner test: in addition to the default
/// "Erneut versuchen" button shown to every user, an admin sees one
/// "Erneut mit <backend>" button per configured backend, letting them
/// re-analyse with a different model when one is misbehaving. Pins the
/// `{% if is_admin %}` gate in `case_page.html` so a future refactor
/// can't silently expose backend choice to all users (matches the
/// `analyze` handler's 403 guard) or remove it from admins.
#[tokio::test]
async fn case_page_failure_banner_admin_can_choose_backend() {
let (cfg, settings) = TestConfig::new()
.with_label("cp-fail-banner-admin")
.with_user(test_admin("dr_a"))
.with_llm("http://unused")
.build_pair();
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"),
);
std::fs::write(
case_dir.join(".analysis_failed.json"),
r#"{"last_recording_mtime":"2026-04-15T10:00:00Z","reason":"llm: connect timeout","failed_at":"2026-04-15T10:05:00Z"}"#,
)
.unwrap();
let app = doctate_server::create_router_with_settings(cfg, settings);
let cookie = common::login(&app, "dr_a", "s").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(r#"class="failure-banner""#),
"failure-banner div missing for admin"
);
assert!(
body.contains(">Erneut versuchen</button>"),
"default retry button must remain visible for admin too"
);
assert!(
body.contains("Erneut mit "),
"admin must see at least one 'Erneut mit <backend>' button"
);
}
#[tokio::test]
#[ignore = "post-refactor any_backend_available() reads IONOS_API_KEY which TestConfig pre-sets; cannot be unset for one test in a shared process"]
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"
);
assert!(
body.contains(r#">Schließen</button>"#),
"documentless open case must offer a bare Schließen button"
);
}
#[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);
write_document_for_test(&case_dir, "fremdes Dokument", "1970-01-01T00:00:00Z");
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 /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"));
}
/// Property: the recordings sub-page must honour `?show_closed=1` exactly
/// like the case detail page does. Closing a case only sets a `.closed`
/// marker — the `.m4a` recordings stay on disk — so a user who opted into
/// viewing closed cases must still be able to open the recordings list.
///
/// Regression for issue #17: `handle_case_recordings` resolves the case via
/// `locate_case_or_404`, which returns `None` for any case carrying the
/// `.closed` marker, yielding 404 `{"error":"Case not found"}` and an
/// apparently empty page — even when `show_closed=1` is requested.
#[tokio::test]
async fn case_recordings_honours_show_closed_for_closed_case() {
let cfg = TestConfig::new()
.with_label("rec-closed")
.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"));
// Close the case: only a marker is written; the recording stays put.
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await;
let url = format!("{}?show_closed=1", paths::case_recordings(case_id));
let resp = app.oneshot(get_with_cookie(&url, &cookie)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"recordings page must render for a closed case under show_closed=1, not 404",
);
let body = body_string(resp).await;
assert!(
body.contains("2026-04-15T10-00-00Z.m4a"),
"recording still on disk must be listed for the closed case"
);
}
#[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 /cases"
);
}
/// Property: a closed case is reachable by direct navigation WITHOUT
/// `?show_closed=1`. The `.closed` marker is a discovery filter for the case
/// list, not an access gate — so deep-linking to a closed case, or following
/// the recordings page's back link (which carries no flag), must render the
/// case page, not 404. Regression for issue #17.
#[tokio::test]
async fn case_page_renders_for_closed_case_without_show_closed() {
let cfg = TestConfig::new()
.with_label("cp-closed-noflag")
.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("ein Text"));
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
let app = doctate_server::create_router(cfg);
let cookie = login_dr_a(&app).await;
// No ?show_closed=1 on the URL — the deep-link / back-link path.
let resp = app
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"closed case page must render on direct access, not 404",
);
let body = body_string(resp).await;
assert!(
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
"closed case page must show the reopen action (proves the closed view rendered)"
);
}
/// Property: a closed case is read-only. Closing means "done", so the
/// mutating affordances (analyze, reset) are withheld even though the case
/// page still renders for viewing. Only reopen stays — it's how the closed
/// state is left.
#[tokio::test]
async fn case_page_hides_mutation_actions_when_closed() {
let cfg = TestConfig::new()
.with_label("cp-closed-ro")
.with_user(test_admin("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("ein Text"));
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
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(&format!(r#"action="{}""#, paths::case_analyze(case_id))),
"closed case must not expose the analyze action"
);
assert!(
!body.contains(&format!(r#"action="{}""#, paths::case_reset(case_id))),
"closed case must not expose the reset action"
);
assert!(
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
"closed case must still offer reopen"
);
}
/// Property: the recordings sub-page is reachable for a closed case WITHOUT
/// `?show_closed=1` (same discovery-gate reasoning as the case page).
#[tokio::test]
async fn case_recordings_renders_for_closed_case_without_show_closed() {
let cfg = TestConfig::new()
.with_label("rec-closed-noflag")
.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"));
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
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,
"closed case recordings must render on direct access, not 404",
);
let body = body_string(resp).await;
assert!(
body.contains("2026-04-15T10-00-00Z.m4a"),
"recording on disk must be listed for the closed case"
);
}
/// Property: an OPEN case's recordings page exposes the per-recording delete
/// form. Locks the positive side so the closed-case hiding below cannot
/// silently regress into hiding delete everywhere.
#[tokio::test]
async fn case_recordings_shows_delete_when_open() {
let cfg = TestConfig::new()
.with_label("rec-del-open")
.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="{}/delete""#,
paths::case_recordings(case_id)
)),
"open recordings page must expose the delete action"
);
}
/// Property: a closed case is read-only — the per-recording delete form is
/// withheld on the recordings sub-page, mirroring the case page hiding its
/// mutating actions.
#[tokio::test]
async fn case_recordings_hides_delete_when_closed() {
let cfg = TestConfig::new()
.with_label("rec-del-closed")
.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"));
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
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(&format!(
r#"action="{}/delete""#,
paths::case_recordings(case_id)
)),
"closed recordings page must not expose the delete action"
);
}
#[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");
write_document_for_test(&case_dir, "Inhalt", "1970-01-01T00:00:00Z");
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"
);
}
/// Property: an open case WITH a document renders the combined
/// "Kopieren + Schließen" control below the document — a single form
/// that POSTs the close action and carries the CSRF token.
#[tokio::test]
async fn case_page_open_document_has_copy_close_button() {
let cfg = TestConfig::new()
.with_label("cp-copyclose")
.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);
write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");
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(r#"id="copy-close-form""#),
"combined copy-close form missing"
);
assert!(
body.contains("Kopieren + Schließen"),
"copy-close button label missing"
);
// The combined form must POST the close action...
let form_start = body
.find(r#"id="copy-close-form""#)
.expect("copy-close form present");
let rest = &body[form_start..];
let form_end = rest.find("</form>").expect("copy-close form closes");
let form_html = &rest[..form_end];
assert!(
form_html.contains(&format!(r#"action="{}""#, paths::case_close(case_id))),
"copy-close form must POST the close action"
);
// ...and carry the CSRF token inside that same form.
assert!(
form_html.contains(r#"name="csrf_token""#),
"copy-close form must carry the CSRF token"
);
}
/// Guard: a CLOSED case (even with a document, rendered read-only) shows
/// no copy-close control — closing an already-closed case is impossible.
/// The reopen affordance is the only state-changing control.
#[tokio::test]
async fn case_page_closed_document_hides_copy_close() {
let cfg = TestConfig::new()
.with_label("cp-closed-copyclose")
.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);
write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");
write_closed_marker(&case_dir, "2026-04-15T12:00:00Z");
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(r#"id="copy-close-form""#),
"closed case must not expose the copy-close control"
);
assert!(
body.contains(&format!(r#"action="{}""#, paths::case_reopen(case_id))),
"closed case must still offer reopen"
);
}
/// Property: the icon-only trash-can close button in the H1 title row is
/// gone — closing now happens via the text control below the document.
/// (The closed-case reopen affordance in the same slot is untouched.)
#[tokio::test]
async fn case_page_open_case_has_no_trashcan_close() {
let cfg = TestConfig::new()
.with_label("cp-no-trash")
.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);
write_document_for_test(&case_dir, "fertiger Brief", "1970-01-01T00:00:00Z");
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(r#"title="Fall schließen""#),
"the H1 trash-can close button must be gone"
);
}