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 /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, 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
)" ); assert!( body.contains(r#"datetime="2026-04-15T10:05:00Z""#), "failed_at not rendered as