From b7e54db54ca276a7a1baceceff6f538068146a09 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 19 Apr 2026 17:11:29 +0200 Subject: [PATCH] Refactor case detail into a dedicated page This commit restructures the web interface for case details. The previous `/web/cases/{case_id}` route, which previously showed both the case summary and its recordings, has been split into two distinct pages: - `/web/cases/{case_id}`: This page now displays the overall case information, including the document if available. - `/web/cases/{case_id}/recordings`: This new page is dedicated to listing and displaying individual recordings within a case. This change improves the organization and clarity of the web UI, allowing for more focused views of case data. Additionally, the `case_detail.html` and `document.html` templates have been removed as their functionality is now handled by the new `case_page.html` and the upcoming document rendering logic. The `cases.html` template has also been removed, indicating a shift towards more granular page views. --- scripts/dictate.sh | 2 +- server/src/routes/case_actions.rs | 36 +--- server/src/routes/mod.rs | 11 +- server/src/routes/user_web.rs | 77 -------- server/src/routes/web.rs | 108 +--------- server/templates/case_detail.html | 89 --------- server/templates/cases.html | 61 ------ server/templates/document.html | 68 ------- server/templates/my_cases.html | 2 +- server/tests/case_page_test.rs | 317 ++++++++++++++++++++++++++++++ 10 files changed, 326 insertions(+), 445 deletions(-) delete mode 100644 server/templates/case_detail.html delete mode 100644 server/templates/cases.html delete mode 100644 server/templates/document.html create mode 100644 server/tests/case_page_test.rs diff --git a/scripts/dictate.sh b/scripts/dictate.sh index 5187075..862b410 100755 --- a/scripts/dictate.sh +++ b/scripts/dictate.sh @@ -83,7 +83,7 @@ show_case() { fi echo - echo "Browse: $SERVER_URL/web/" + echo "Browse: $SERVER_URL/web/cases" } # Record + upload for the given case, then display the case state. diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 7e275d2..30b6e49 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -2,9 +2,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; -use askama::Template; use axum::extract::State; -use axum::response::{Html, Redirect}; +use axum::response::Redirect; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tokio::io::AsyncWriteExt; @@ -22,13 +21,6 @@ use crate::error::AppError; use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker}; use crate::routes::user_web::locate_case_or_404; -#[derive(Template)] -#[template(path = "document.html")] -struct DocumentTemplate { - case_id: String, - content: String, -} - /// POST /web/cases/{case_id}/analyze /// /// Trigger an LLM analysis for the case. If no document exists yet, writes @@ -87,32 +79,6 @@ pub async fn handle_analyze_case( Ok(Redirect::to("/web/cases")) } -/// GET /web/cases/{case_id}/document -/// -/// Render the latest `document_v{N}.md` for the case. The handler scans the -/// case directory and picks the highest N — no symlink, no separate pointer. -pub async fn handle_document_view( - user: AuthenticatedWebUser, - State(config): State>, - CaseIdPath(case_id): CaseIdPath, -) -> Result, AppError> { - let user_root = config.data_path.join(&user.slug); - let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "document view").await?; - - let md = read_document(&case_dir) - .await - .ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?; - let content = crate::analyze::render::md_to_html(&md); - - DocumentTemplate { - case_id: case_id.to_string(), - content, - } - .render() - .map(Html) - .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) -} - /// Build an `AnalysisInput` for the given case directory. /// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching /// `.transcript.txt` for each, skips blank transcripts, and computes diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 1a4a71c..d14060e 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -28,7 +28,11 @@ pub fn api_router() -> Router { ) .route("/web/logout", post(login::handle_logout)) .route("/web/cases", get(user_web::handle_my_cases)) - .route("/web/cases/{case_id}", get(user_web::handle_case_detail)) + .route("/web/cases/{case_id}", get(user_web::handle_case_page)) + .route( + "/web/cases/{case_id}/recordings", + get(user_web::handle_case_recordings), + ) .route( "/web/cases/{case_id}/analyze", post(case_actions::handle_analyze_case), @@ -46,11 +50,6 @@ pub fn api_router() -> Router { post(case_actions::handle_undo_delete), ) .route("/web/cases/bulk", post(bulk::handle_bulk_action)) - .route( - "/web/cases/{case_id}/document", - get(case_actions::handle_document_view), - ) - .route("/web/", get(web::handle_case_list)) .route( "/web/audio/{user}/{case_id}/{filename}", get(web::handle_audio), diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 614de2a..2e1b96d 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -125,31 +125,9 @@ struct MyCasesTemplate { is_admin: bool, } -#[derive(Template)] -#[template(path = "case_detail.html")] -struct CaseDetailTemplate { - slug: String, - case_id: String, - case_id_short: String, - oneliner: Option, - recordings: Vec, - can_analyze: bool, - llm_missing: bool, - analyzing: bool, - has_document: bool, - /// True iff the transcribe worker is currently running. Gate for the - /// per-recording "Transkription läuft…" label — suppresses the lie when - /// a transcript is missing but no worker is active. - transcribe_busy: bool, - /// True iff the session user is an admin — toggles admin-only controls - /// (currently the Reset button). - is_admin: bool, -} - #[derive(Template)] #[template(path = "case_page.html")] struct CasePageTemplate { - slug: String, case_id: String, case_id_short: String, oneliner: Option, @@ -175,7 +153,6 @@ struct CaseRecordingsTemplate { oneliner: Option, recordings: Vec, transcribe_busy: bool, - is_admin: bool, } /// Flags derived from filesystem state + config. @@ -275,57 +252,6 @@ pub async fn handle_my_cases( .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } -pub async fn handle_case_detail( - user: AuthenticatedWebUser, - State(config): State>, - State(pipeline): State, - CaseIdPath(case_id): CaseIdPath, -) -> Result, AppError> { - // IDOR guard: case_dir must live under the session's user_slug. - let user_root = config.data_path.join(&user.slug); - pipeline.heal_orphans_if_idle(&user_root, &user.slug).await; - - let case_dir = locate_case_or_404( - &user_root, - &case_id, - &user.slug, - "case detail (possible IDOR probe)", - ) - .await?; - let case_id_str = case_id.to_string(); - info!(slug = %user.slug, case_id = %case_id, "case detail viewed"); - - let recordings = scan_recordings(&case_dir).await; - let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire); - let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire); - let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; - let case_id_short = case_id_str.chars().take(8).collect(); - let is_admin = user.is_admin(); - - CaseDetailTemplate { - slug: user.slug, - case_id: case_id_str, - case_id_short, - oneliner, - recordings, - can_analyze: flags.can_analyze, - llm_missing: flags.llm_missing, - analyzing: flags.analyzing, - has_document: flags.has_document, - transcribe_busy: t_busy, - is_admin, - } - .render() - .map(Html) - .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) -} - /// GET /web/cases/{case_id} /// /// Canonical case page. Renders the document inline if present; otherwise @@ -374,7 +300,6 @@ pub async fn handle_case_page( let is_admin = user.is_admin(); CasePageTemplate { - slug: user.slug, case_id: case_id_str, case_id_short, oneliner, @@ -424,7 +349,6 @@ pub async fn handle_case_recordings( let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire); let case_id_short = case_id_str.chars().take(8).collect(); - let is_admin = user.is_admin(); CaseRecordingsTemplate { slug: user.slug, @@ -433,7 +357,6 @@ pub async fn handle_case_recordings( oneliner, recordings, transcribe_busy: t_busy, - is_admin, } .render() .map(Html) diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index cacd7f5..a917ba3 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -1,16 +1,13 @@ use std::path::Path; use std::sync::Arc; -use askama::Template; use axum::body::Body; use axum::extract::{Path as AxumPath, State}; use axum::http::header; -use axum::response::{Html, Response}; -use tracing::warn; +use axum::response::Response; use crate::config::Config; use crate::error::AppError; -use crate::routes::user_web::any_document_exists; pub(crate) struct RecordingView { pub(crate) filename: String, @@ -20,31 +17,6 @@ pub(crate) struct RecordingView { pub(crate) failed: bool, } -struct CaseView { - user_slug: String, - case_id: String, - case_id_short: String, - has_document: bool, - most_recent: String, - oneliner: Option, - recordings: Vec, -} - -#[derive(Template)] -#[template(path = "cases.html")] -struct CasesTemplate { - cases: Vec, -} - -pub async fn handle_case_list(State(config): State>) -> Result, AppError> { - let cases = scan_cases(&config.data_path).await; - let template = CasesTemplate { cases }; - template - .render() - .map(Html) - .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) -} - pub async fn handle_audio( State(config): State>, AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>, @@ -86,84 +58,6 @@ fn validate_filename(filename: &str) -> Result<(), AppError> { Ok(()) } -/// Scan the data directory for all cases across all users. -/// Silently skips invalid entries so a single broken directory does not break the page. -async fn scan_cases(data_path: &Path) -> Vec { - let mut cases = Vec::new(); - - let mut users = match tokio::fs::read_dir(data_path).await { - Ok(r) => r, - Err(_) => return cases, // data_path may not exist yet - }; - - while let Ok(Some(user_entry)) = users.next_entry().await { - let user_slug = match user_entry.file_name().into_string() { - Ok(s) => s, - Err(_) => continue, - }; - if validate_user_slug(&user_slug).is_err() { - continue; - } - if !user_entry.path().is_dir() { - continue; - } - - let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await { - Ok(r) => r, - Err(_) => continue, - }; - - while let Ok(Some(case_entry)) = case_entries.next_entry().await { - let case_id = match case_entry.file_name().into_string() { - Ok(s) => s, - Err(_) => continue, - }; - if uuid::Uuid::parse_str(&case_id).is_err() { - warn!(case_id = %case_id, "Skipping non-UUID directory"); - continue; - } - if !case_entry.path().is_dir() { - continue; - } - - let case_path = case_entry.path(); - if crate::paths::is_deleted(&case_path).await { - continue; - } - let recordings = scan_recordings(&case_path).await; - if recordings.is_empty() { - continue; - } - - let most_recent = recordings - .last() - .map(|r| r.filename.clone()) - .unwrap_or_default(); - let case_id_short = case_id.chars().take(8).collect(); - let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let has_document = any_document_exists(&case_path).await; - - cases.push(CaseView { - user_slug: user_slug.clone(), - case_id, - case_id_short, - has_document, - most_recent, - oneliner, - recordings, - }); - } - } - - // Most recent case first. - cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); - cases -} - pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { let mut recordings = Vec::new(); let mut entries = match tokio::fs::read_dir(case_dir).await { diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html deleted file mode 100644 index 0bdc976..0000000 --- a/server/templates/case_detail.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - -Doctate — Fall {{ case_id_short }} - - - -
- -
-
-

Fall {{ case_id_short }} {% if has_document %}ausgewertet{% else %}offen{% endif %}

-{% match oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -
Noch kein Oneliner.
-{% endmatch %} -
{{ case_id }}
- -
-{% if analyzing %} -wird analysiert … -{% else if can_analyze && !has_document %} -
-{% else if llm_missing && !has_document %} -LLM-Analyse nicht konfiguriert -{% endif %} -{% if is_admin %} -
-{% endif %} -
-
- -

Aufnahmen ({{ recordings.len() }})

-{% for rec in recordings %} -
-
-{{ rec.filename }} - -
-{% if rec.failed %} -
Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.
-{% else %} -{% match rec.transcript %} -{% when Some with (t) %} -{% if t.is_empty() %} -
(stille Aufnahme)
-{% else %} -
{{ t }}
-{% endif %} -{% when None %} -{% if transcribe_busy %} -
Transkription läuft…
-{% else %} -
Transkription ausstehend.
-{% endif %} -{% endmatch %} -{% endif %} -
-{% endfor %} - - diff --git a/server/templates/cases.html b/server/templates/cases.html deleted file mode 100644 index 671b73d..0000000 --- a/server/templates/cases.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - -Doctate — Cases - - - -

Cases

-{% if cases.is_empty() %} -

No cases found.

-{% else %} -{% for case in cases %} -
-

{{ case.user_slug }} — {{ case.case_id_short }} ({% if case.has_document %}analyzed{% else %}in progress{% endif %})

-{% match case.oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -{% endmatch %} -{{ case.case_id }} -{% for rec in case.recordings %} -
-
-{{ rec.filename }} - -
-{% if rec.failed %} -
Transcription failed — rename away the .failed suffix to retry.
-{% else %} -{% match rec.transcript %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -
Transcription pending…
-{% endmatch %} -{% endif %} -
-{% endfor %} -
-{% endfor %} -{% endif %} - - diff --git a/server/templates/document.html b/server/templates/document.html deleted file mode 100644 index afa6a75..0000000 --- a/server/templates/document.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - -Doctate — Dokument - - - -
- -
-
-

Dokument

-
{{ case_id }}
-
- -
-
{{ content|safe }}
- - - - diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index b5793b2..058dfdf 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -67,7 +67,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
  • -{% if case.has_document %}{% else %}{% endif %} +
    {% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — kein Oneliner{% endmatch %}
    {{ case.recordings_count }} Aufnahmen{% if case.has_document %} — ausgewertet{% else %} — offen{% endif %}{% if case.analyzing %} wird analysiert{% endif %}
    {% if is_admin %}
    {{ case.case_id }}
    {% endif %} diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs new file mode 100644 index 0000000..8458943 --- /dev/null +++ b/server/tests/case_page_test.rs @@ -0,0 +1,317 @@ +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 config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { + let api_keys: HashMap = 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_with_llm_users(data_path, "http://unused".into(), vec![make_user("dr_a")]) +} + +fn config_without_llm(data_path: PathBuf) -> Arc { + let users = vec![make_user("dr_a")]; + let api_keys: HashMap = 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 { + 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_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" + ); +}