diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 44302bd..8db4ac7 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -4,7 +4,7 @@ use tracing::{info, warn}; use super::{AnalyzeJob, AnalyzeSender}; -/// Walk `data_path/*/open/*/analysis_input_v*.json` and enqueue every input +/// Walk `data_path/*/*/analysis_input_v*.json` and enqueue every input /// that does not yet have a matching `document_v*.md` sibling. /// /// Intended to run once at startup. Send is synchronous on the unbounded @@ -33,8 +33,7 @@ async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> { }; while let Ok(Some(user_entry)) = users.next_entry().await { - let open_dir = user_entry.path().join("open"); - let mut cases = match tokio::fs::read_dir(&open_dir).await { + let mut cases = match tokio::fs::read_dir(user_entry.path()).await { Ok(r) => r, Err(_) => continue, }; diff --git a/server/src/auth.rs b/server/src/auth.rs index 4838d8b..20d0318 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -59,8 +59,7 @@ where .ok_or(AppError::Unauthorized)?; let data_dir = config.data_path.join(slug); - tokio::fs::create_dir_all(data_dir.join("open")).await?; - tokio::fs::create_dir_all(data_dir.join("done")).await?; + tokio::fs::create_dir_all(&data_dir).await?; Ok(Self { slug: slug.clone(), diff --git a/server/src/lib.rs b/server/src/lib.rs index a07d03c..7f8f37a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod config; pub mod error; pub mod models; +pub mod paths; pub mod routes; pub mod transcribe; pub mod web_session; diff --git a/server/src/paths.rs b/server/src/paths.rs new file mode 100644 index 0000000..b3e2535 --- /dev/null +++ b/server/src/paths.rs @@ -0,0 +1,10 @@ +use std::path::{Path, PathBuf}; + +/// Absolute on-disk location of a case directory. +/// +/// Single source of truth for the layout `///`. +/// Use everywhere instead of inlining `.join(slug).join(case_id)` so the +/// layout can evolve in one place. +pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { + data_path.join(slug).join(case_id) +} diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 07270ec..00730fe 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -45,13 +45,14 @@ pub async fn handle_close_case( )); } - // Close only acts on cases in open/. A case in done/ is already finalized. let user_root = config.data_path.join(&user.slug); - let case_dir = user_root.join("open").join(&case_id); - if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { - warn!(slug = %user.slug, case_id = %case_id, "close: case not found in open/"); - return Err(AppError::NotFound("Case not found".into())); - } + let case_dir = match locate_case(&user_root, &case_id).await { + Some(p) => p, + None => { + warn!(slug = %user.slug, case_id = %case_id, "close: case not found"); + return Err(AppError::NotFound("Case not found".into())); + } + }; let input_path = case_dir.join("analysis_input_v1.json"); if tokio::fs::try_exists(&input_path).await.unwrap_or(false) { @@ -113,11 +114,13 @@ pub async fn handle_reanalyze_case( } let user_root = config.data_path.join(&user.slug); - let case_dir = user_root.join("open").join(&case_id); - if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { - warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found in open/"); - return Err(AppError::NotFound("Case not found".into())); - } + let case_dir = match locate_case(&user_root, &case_id).await { + Some(p) => p, + None => { + warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found"); + return Err(AppError::NotFound("Case not found".into())); + } + }; let current_version = find_latest_document(&case_dir) .await @@ -166,8 +169,8 @@ pub async fn handle_document_view( .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; let user_root = config.data_path.join(&user.slug); - let (case_dir, _status) = match locate_case(&user_root, &case_id).await { - Some(v) => v, + let case_dir = match locate_case(&user_root, &case_id).await { + Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "document view: case not found"); return Err(AppError::NotFound("Case not found".into())); diff --git a/server/src/routes/upload.rs b/server/src/routes/upload.rs index 389b457..611eaf1 100644 --- a/server/src/routes/upload.rs +++ b/server/src/routes/upload.rs @@ -107,27 +107,13 @@ pub async fn handle_upload( } /// Determine where to store the audio file for this case. -/// Creates the directory if it's a new case. -async fn resolve_case_dir(data_dir: &Path, case_id: &str) -> Result { - let open_dir = data_dir.join("open").join(case_id); - if open_dir.exists() { - return Ok(open_dir); +/// Creates the directory if it's a new case. `user_root` is the per-user +/// root (`//`); cases live flat directly underneath. +async fn resolve_case_dir(user_root: &Path, case_id: &str) -> Result { + let dir = user_root.join(case_id); + if !dir.exists() { + tokio::fs::create_dir_all(&dir).await?; + info!(case_id = %case_id, "New case created"); } - - let done_dir = data_dir.join("done").join(case_id); - if done_dir.exists() { - // Late upload for a closed case — remove .remove marker if present. - let remove_marker = done_dir.join(".remove"); - if remove_marker.exists() { - tokio::fs::remove_file(&remove_marker).await?; - info!(case_id = %case_id, "Removed .remove marker due to late upload"); - } - return Ok(done_dir); - } - - // New case — create directory under open/. - tokio::fs::create_dir_all(&open_dir).await?; - info!(case_id = %case_id, "New case created"); - - Ok(open_dir) + Ok(dir) } diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2a84137..9398f94 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -14,7 +14,6 @@ use crate::routes::web::{scan_recordings, RecordingView}; struct UserCaseView { case_id: String, case_id_short: String, - status: String, most_recent: String, oneliner: Option, recordings: Vec, @@ -26,8 +25,7 @@ struct UserCaseView { #[template(path = "my_cases.html")] struct MyCasesTemplate { slug: String, - open: Vec, - done: Vec, + cases: Vec, } #[derive(Template)] @@ -36,7 +34,6 @@ struct CaseDetailTemplate { slug: String, case_id: String, case_id_short: String, - status: String, oneliner: Option, recordings: Vec, can_close: bool, @@ -46,10 +43,10 @@ struct CaseDetailTemplate { can_reanalyze: bool, } -/// Four mutually exclusive flags derived from filesystem state + config. -/// Priority order (for UI rendering): `has_document`, then `analyzing`, -/// then `can_close`, then `llm_missing`. If none is true, the case is -/// still receiving recordings or has zero transcripts; no action surfaced. +/// Flags derived from filesystem state + config. Priority order +/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`, +/// then `llm_missing`. If none is true, the case is still receiving +/// recordings or has zero transcripts; no action surfaced. struct CaseFlags { has_document: bool, analyzing: bool, @@ -60,7 +57,6 @@ struct CaseFlags { async fn compute_flags( case_dir: &Path, - status: &str, recordings: &[RecordingView], llm_configured: bool, is_admin: bool, @@ -75,7 +71,7 @@ async fn compute_flags( let all_transcribed = !non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some()); - let transcribed_stage = status == "open" && all_transcribed && !has_document && !analyzing; + let transcribed_stage = !has_document && !analyzing && all_transcribed; CaseFlags { has_document, @@ -88,7 +84,7 @@ async fn compute_flags( /// True iff the case directory contains at least one `document_v{N}.md`. /// We don't care about N here — any version means "Ausgewertet" for the UI. -async fn any_document_exists(case_dir: &Path) -> bool { +pub(crate) async fn any_document_exists(case_dir: &Path) -> bool { let mut entries = match tokio::fs::read_dir(case_dir).await { Ok(r) => r, Err(_) => return false, @@ -107,11 +103,10 @@ pub async fn handle_my_cases( user: AuthenticatedWebUser, State(config): State>, ) -> Result, AppError> { - let (open, done) = scan_user_cases(&config.data_path, &user.slug).await; + let cases = scan_user_cases(&config.data_path, &user.slug).await; MyCasesTemplate { slug: user.slug, - open, - done, + cases, } .render() .map(Html) @@ -128,14 +123,14 @@ pub async fn handle_case_detail( // IDOR guard: case_dir must live under the session's user_slug. let user_root = config.data_path.join(&user.slug); - let (case_dir, status) = match locate_case(&user_root, &case_id).await { - Some(v) => v, + let case_dir = match locate_case(&user_root, &case_id).await { + Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)"); return Err(AppError::NotFound("Case not found".into())); } }; - info!(slug = %user.slug, case_id = %case_id, status = %status, "case detail viewed"); + 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")) @@ -146,7 +141,6 @@ pub async fn handle_case_detail( let flags = compute_flags( &case_dir, - &status, &recordings, config.llm_configured(), user.is_admin(), @@ -158,7 +152,6 @@ pub async fn handle_case_detail( slug: user.slug, case_id, case_id_short, - status, oneliner, recordings, can_close: flags.can_close, @@ -172,90 +165,76 @@ pub async fn handle_case_detail( .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } -/// Locate a case directory under `/{open|done}/`. -/// Returns the path and the status bucket it was found in. -pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> { - for status in ["open", "done"] { - let p = user_root.join(status).join(case_id); - if tokio::fs::try_exists(&p).await.unwrap_or(false) { - return Some((p, status.to_string())); - } +/// Locate a case directory under `//`. Returns `None` +/// if the directory does not exist (treated as a 404 / IDOR probe by callers). +pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option { + let p = user_root.join(case_id); + if tokio::fs::try_exists(&p).await.unwrap_or(false) { + Some(p) + } else { + None } - None } -/// Scan only the given user's cases. Returns (open, done), each sorted -/// with most recent first. -async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Vec) { +/// Scan all of the given user's cases. Returned vec is sorted by most +/// recent recording filename (lexicographic ≈ chronological since +/// timestamps are ISO-8601), newest first. +async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec { let user_root = data_path.join(slug); - let mut open = Vec::new(); - let mut done = Vec::new(); + let mut cases = Vec::new(); - for status in ["open", "done"] { - let status_dir = user_root.join(status); - let mut entries = match tokio::fs::read_dir(&status_dir).await { - Ok(r) => r, + let mut entries = match tokio::fs::read_dir(&user_root).await { + Ok(r) => r, + Err(_) => return cases, + }; + + while let Ok(Some(case_entry)) = entries.next_entry().await { + let case_id = match case_entry.file_name().into_string() { + Ok(s) => s, Err(_) => continue, }; - - while let Ok(Some(case_entry)) = 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 recordings = scan_recordings(&case_entry.path()).await; - if recordings.is_empty() { - continue; - } - - let case_path = case_entry.path(); - 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()); - - // Only the two flags the list UI needs are computed here — - // `can_close`/`llm_missing` live on the detail page. - let has_document = any_document_exists(&case_path).await; - let analyzing = !has_document - && tokio::fs::try_exists(case_path.join("analysis_input_v1.json")) - .await - .unwrap_or(false); - - let view = UserCaseView { - case_id, - case_id_short, - status: status.to_string(), - most_recent, - oneliner, - recordings, - analyzing, - has_document, - }; - - if status == "open" { - open.push(view); - } else { - done.push(view); - } + 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(); + 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; + let analyzing = !has_document + && tokio::fs::try_exists(case_path.join("analysis_input_v1.json")) + .await + .unwrap_or(false); + + cases.push(UserCaseView { + case_id, + case_id_short, + most_recent, + oneliner, + recordings, + analyzing, + has_document, + }); } - open.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); - done.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); - (open, done) + cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); + cases } diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 5e25029..0a5b7f2 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -10,6 +10,7 @@ use tracing::warn; use crate::config::Config; use crate::error::AppError; +use crate::routes::user_web::any_document_exists; pub(crate) struct RecordingView { pub(crate) filename: String, @@ -23,7 +24,7 @@ struct CaseView { user_slug: String, case_id: String, case_id_short: String, - status: String, + has_document: bool, most_recent: String, oneliner: Option, recordings: Vec, @@ -55,17 +56,10 @@ pub async fn handle_audio( .map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?; validate_filename(&filename)?; - let base = &config.data_path; - let open_path = base.join(&user).join("open").join(&case_id).join(&filename); - let done_path = base.join(&user).join("done").join(&case_id).join(&filename); - - let file_path = if tokio::fs::try_exists(&open_path).await.unwrap_or(false) { - open_path - } else if tokio::fs::try_exists(&done_path).await.unwrap_or(false) { - done_path - } else { + let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&filename); + if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) { return Err(AppError::NotFound("Audio file not found".into())); - }; + } let bytes = tokio::fs::read(&file_path).await?; @@ -116,52 +110,51 @@ async fn scan_cases(data_path: &Path) -> Vec { continue; } - for status in ["open", "done"] { - let status_dir = user_entry.path().join(status); - let mut case_entries = match tokio::fs::read_dir(&status_dir).await { - Ok(r) => r, + 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, }; - - 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 recordings = scan_recordings(&case_entry.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_entry.path().join("oneliner.txt")) - .await - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - cases.push(CaseView { - user_slug: user_slug.clone(), - case_id, - case_id_short, - status: status.to_string(), - most_recent, - oneliner, - recordings, - }); + 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(); + 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, + }); } } diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index 6d4b819..e941c49 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -4,7 +4,7 @@ use tracing::{info, warn}; use super::{TranscribeJob, TranscribeSender}; -/// Walk `data_path/*/open/*/*.m4a` and enqueue every recording that does not +/// Walk `data_path/*/*/*.m4a` and enqueue every recording that does not /// yet have a `.transcript.txt` sibling. /// /// Intended to run once at startup (spawn as a task — sends may back-pressure @@ -29,7 +29,7 @@ pub async fn scan_and_enqueue(data_path: &Path, tx: &TranscribeSender) { } } -/// Walks `$data_path//open//*.m4a` and returns `(slug, audio_path)` +/// Walks `$data_path///*.m4a` and returns `(slug, audio_path)` /// tuples for every recording without a `.transcript.txt` sibling. The slug /// comes from the top-level user directory name — that's the only ownership /// signal after a restart (auth context is gone). @@ -45,8 +45,7 @@ async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> { let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else { continue; }; - let open_dir = user_entry.path().join("open"); - let mut cases = match tokio::fs::read_dir(&open_dir).await { + let mut cases = match tokio::fs::read_dir(user_entry.path()).await { Ok(r) => r, Err(_) => continue, }; diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 91ae6ef..2f61db0 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -33,7 +33,7 @@ header form { margin: 0; }
-

Fall {{ case_id_short }} {{ status }}

+

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

{% match oneliner %} {% when Some with (t) %}
{{ t }}
diff --git a/server/templates/cases.html b/server/templates/cases.html index 0a8ff20..671b73d 100644 --- a/server/templates/cases.html +++ b/server/templates/cases.html @@ -26,8 +26,8 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1

No cases found.

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

{{ case.user_slug }} — {{ case.case_id_short }} ({{ case.status }})

+
+

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

{% match case.oneliner %} {% when Some with (t) %}
{{ t }}
diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 6c5b8b2..dc07de7 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -11,8 +11,8 @@ header form { margin: 0; } .case:hover { background: #f6f6f6; } .case h2 { margin: 0 0 0.3em 0; font-size: 1em; } .case small { color: #666; font-family: monospace; } -.status-open { border-left: 4px solid #4a90e2; } -.status-done { border-left: 4px solid #7ed321; } +.case.open { border-left: 4px solid #4a90e2; } +.case.done { border-left: 4px solid #7ed321; } .oneliner { font-size: 1em; color: #333; margin: 0.2em 0; } section { margin: 1.5em 0; } section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; } @@ -29,31 +29,12 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
-

Offen ({{ open.len() }})

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

Keine offenen Fälle.

+

Fälle ({{ cases.len() }})

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

Keine Fälle.

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

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}ausgewertet{% else if case.analyzing %}wird analysiert{% endif %}

-{% match case.oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -{% endmatch %} -{{ case.most_recent }} -
-{% endfor %} -{% endif %} -
- -
-

Abgeschlossen ({{ done.len() }})

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

Keine abgeschlossenen Fälle.

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

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}ausgewertet{% else if case.analyzing %}wird analysiert{% endif %}

{% match case.oneliner %} {% when Some with (t) %} diff --git a/server/tests/close_case_test.rs b/server/tests/close_case_test.rs index f8cdc06..52e48c2 100644 --- a/server/tests/close_case_test.rs +++ b/server/tests/close_case_test.rs @@ -79,7 +79,7 @@ fn config_without_llm(data_path: PathBuf) -> Arc { } fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { - let dir = data_path.join(slug).join("open").join(case_id); + let dir = data_path.join(slug).join(case_id); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -298,7 +298,7 @@ async fn close_case_skips_blank_transcript_from_recordings() { #[tokio::test] async fn analyze_worker_writes_document_via_wiremock() { let tmp = unique_tmp("w"); - let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); let input = json!({ @@ -363,7 +363,7 @@ async fn analyze_worker_writes_document_via_wiremock() { #[tokio::test] async fn recovery_enqueues_pending_analysis() { let tmp = unique_tmp("r1"); - let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap(); @@ -379,7 +379,7 @@ async fn recovery_enqueues_pending_analysis() { #[tokio::test] async fn recovery_skips_completed_analysis() { let tmp = unique_tmp("r2"); - let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap(); std::fs::write(case_dir.join("document_v1.md"), "done").unwrap(); @@ -566,7 +566,7 @@ async fn reanalyze_second_time_returns_409() { #[tokio::test] async fn reanalyze_worker_writes_document_v2_via_wiremock() { let tmp = unique_tmp("rn-w"); - let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("document_v1.md"), "alte Version").unwrap(); diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index 4c81775..522b20e 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -124,8 +124,8 @@ async fn cases_with_valid_cookie_shows_only_own_cases() { make_user("dr_b", "s"), ]); // Seed fixtures: dr_a has an open case, dr_b has one too. - let case_a = config.data_path.join("dr_a/open/11111111-1111-1111-1111-111111111111"); - let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); + let case_a = config.data_path.join("dr_a/11111111-1111-1111-1111-111111111111"); + let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_a).unwrap(); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); @@ -159,7 +159,7 @@ async fn case_detail_of_foreign_case_returns_404() { make_user("dr_a", "s"), make_user("dr_b", "s"), ]); - let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); + let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 37cce96..7c488e5 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -212,23 +212,18 @@ async fn recovery_enqueues_only_pending_recordings() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - // User 1, open case with two .m4a — one pending, one already transcribed. - let case_a = root.join("dr_a/open/aaaa"); + // User 1, case with two .m4a — one pending, one already transcribed. + let case_a = root.join("dr_a/aaaa"); std::fs::create_dir_all(&case_a).unwrap(); std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap(); std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap(); std::fs::write(case_a.join("2026-04-11T10-00-00Z.transcript.txt"), b"done").unwrap(); - // User 2, open case with one pending .m4a. - let case_b = root.join("dr_b/open/bbbb"); + // User 2, case with one pending .m4a. + let case_b = root.join("dr_b/bbbb"); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap(); - // done/ cases must be ignored (not re-transcribed). - let case_c = root.join("dr_a/done/cccc"); - std::fs::create_dir_all(&case_c).unwrap(); - std::fs::write(case_c.join("2026-04-09T10-00-00Z.m4a"), b"x").unwrap(); - let (tx, mut rx) = transcribe::channel(); scan_and_enqueue(root, &tx).await; drop(tx); // close channel so the loop below terminates @@ -275,7 +270,7 @@ async fn worker_renames_audio_to_failed_on_whisper_error() { // Real case dir with a real m4a fixture so ffmpeg remux succeeds. let tmp = tempfile::tempdir().unwrap(); - let case_dir = tmp.path().join("dr_test/open/aaaa"); + let case_dir = tmp.path().join("dr_test/aaaa"); std::fs::create_dir_all(&case_dir).unwrap(); let audio = case_dir.join("2026-04-13T10-30-00Z.m4a"); std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index c09f723..87a683e 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -100,7 +100,7 @@ async fn upload_creates_new_case() { // Verify file on disk let file = data_path - .join("dr_test/open") + .join("dr_test") .join(case_id) .join("2026-04-13T10-30-00Z.m4a"); assert!(file.exists(), "Audio file should exist at {file:?}"); @@ -242,7 +242,7 @@ async fn upload_second_recording_same_case() { assert_eq!(response.status(), StatusCode::OK); // Both files should exist - let case_dir = data_path.join("dr_test/open").join(case_id); + let case_dir = data_path.join("dr_test").join(case_id); assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists()); assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists()); @@ -250,82 +250,3 @@ async fn upload_second_recording_same_case() { let _ = std::fs::remove_dir_all(&data_path); } -#[tokio::test] -async fn upload_late_recording_to_done_case() { - let config = test_config(); - let data_path = config.data_path.clone(); - let app = doctate_server::create_router(config); - - let case_id = "770e8400-e29b-41d4-a716-446655440000"; - - // Pre-create a done/ case directory (simulating a closed case). - let done_dir = data_path.join("dr_test/done").join(case_id); - std::fs::create_dir_all(&done_dir).unwrap(); - - let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording"); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/upload") - .header("X-API-Key", "test-key-123") - .header( - "Content-Type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - // File should be in done/, not open/ - assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists()); - assert!(!data_path.join("dr_test/open").join(case_id).exists()); - - // Cleanup - let _ = std::fs::remove_dir_all(&data_path); -} - -#[tokio::test] -async fn upload_removes_remove_marker_on_late_upload() { - let config = test_config(); - let data_path = config.data_path.clone(); - let app = doctate_server::create_router(config); - - let case_id = "880e8400-e29b-41d4-a716-446655440000"; - - // Pre-create a done/ case with .remove marker. - let done_dir = data_path.join("dr_test/done").join(case_id); - std::fs::create_dir_all(&done_dir).unwrap(); - std::fs::write(done_dir.join(".remove"), b"").unwrap(); - assert!(done_dir.join(".remove").exists()); - - let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording"); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/upload") - .header("X-API-Key", "test-key-123") - .header( - "Content-Type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - // .remove marker should be gone, audio should be there - assert!(!done_dir.join(".remove").exists()); - assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists()); - - // Cleanup - let _ = std::fs::remove_dir_all(&data_path); -} diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 9123edc..373f2d8 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -63,7 +63,7 @@ async fn web_index_shows_cases() { let case1 = "550e8400-e29b-41d4-a716-446655440000"; let case2 = "660e8400-e29b-41d4-a716-446655440000"; - let open_case = data_path.join("dr_test/open").join(case1); + let open_case = data_path.join("dr_test").join(case1); std::fs::create_dir_all(&open_case).unwrap(); std::fs::write( open_case.join("2026-04-13T10-30-00Z.m4a"), @@ -71,7 +71,7 @@ async fn web_index_shows_cases() { ) .unwrap(); - let done_case = data_path.join("dr_test/done").join(case2); + let done_case = data_path.join("dr_test").join(case2); std::fs::create_dir_all(&done_case).unwrap(); std::fs::write( done_case.join("2026-04-12T09-00-00Z.m4a"), @@ -125,7 +125,7 @@ async fn web_audio_serves_file() { let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440000"; - let case_dir = data_path.join("dr_test/open").join(case_id); + let case_dir = data_path.join("dr_test").join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); let audio_bytes = b"fake audio content for test";