diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index d8337c4..d5b77a2 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -18,6 +18,7 @@ use doctate_client_core::{ PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState, pick_footer_status, run_startup_cleanup, write_sidecar, }; +use doctate_common::join_url; use doctate_common::oneliners::OnelinerState; use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; @@ -55,7 +56,15 @@ pub struct DoctateApp { /// for `AppState::Idle` on Stop (so the UI stays responsive), but we /// still need `case_id` + `recorded_at` when the recorder emits /// `Finished`. `None` when no flush is in progress. - finalizing: Option<(Uuid, String)>, + finalizing: Option, +} + +/// Enough recording identity to finalize a pending flush or emit an +/// upload event once the recorder signals `Finished`. +#[derive(Debug, Clone)] +struct RecordingContext { + case_id: Uuid, + recorded_at: String, } enum UiAction { @@ -273,7 +282,10 @@ impl DoctateApp { } // UI is ready for the next recording immediately — the finalizing // ffmpeg flush shows up as a footer spinner, not a blocking state. - self.finalizing = Some((case_id, recorded_at)); + self.finalizing = Some(RecordingContext { + case_id, + recorded_at, + }); self.state = AppState::Idle; } @@ -281,11 +293,11 @@ impl DoctateApp { let Some(cfg) = &self.config else { return; }; - let server_url = cfg.server_url.trim_end_matches('/').to_owned(); + let server_url = cfg.server_url.clone(); let api_key = cfg.api_key.clone(); let http = self.http_client.clone(); let return_to = format!("/web/cases/{case_id}"); - let fallback_url = format!("{server_url}{return_to}"); + let fallback_url = join_url(&server_url, &return_to); // Fire-and-forget on the runtime. Blocking the egui UI thread on // an HTTP round-trip would freeze the window for ~100–500ms. @@ -337,17 +349,20 @@ impl DoctateApp { // Prefer the explicit finalizing context (Stop-flow). Fall back // to AppState::Recording (recorder terminated before Stop — rare // but possible). - let (case_id, recorded_at) = if let Some((id, rec)) = self.finalizing.take() { - (id, rec) + let ctx = if let Some(ctx) = self.finalizing.take() { + ctx } else if let AppState::Recording { case_id, recorded_at, .. } = &self.state { - let pair = (*case_id, recorded_at.clone()); + let ctx = RecordingContext { + case_id: *case_id, + recorded_at: recorded_at.clone(), + }; self.state = AppState::Idle; - pair + ctx } else { warn!(state = ?self.state, "recorder finished in unexpected state"); self.recorder_events = None; @@ -355,6 +370,11 @@ impl DoctateApp { return; }; + let RecordingContext { + case_id, + recorded_at, + } = ctx; + let store = self.case_store.clone(); let now = time::OffsetDateTime::now_utc(); self.runtime.block_on(async move { diff --git a/client-desktop/src/magic_link.rs b/client-desktop/src/magic_link.rs index 678113d..60849c6 100644 --- a/client-desktop/src/magic_link.rs +++ b/client-desktop/src/magic_link.rs @@ -11,7 +11,7 @@ //! plain case URL if this fails — the doctor lands on the login page and //! signs in manually rather than seeing a dead button. -use doctate_common::API_KEY_HEADER; +use doctate_common::{API_KEY_HEADER, join_url}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -42,9 +42,8 @@ pub async fn build_magic_url( api_key: &str, return_to: &str, ) -> Result { - let base = server_url.trim_end_matches('/'); let resp = http - .post(format!("{base}/api/auth/magic-link")) + .post(join_url(server_url, "/api/auth/magic-link")) .header(API_KEY_HEADER, api_key) .json(&CreateRequest { return_to }) .send() @@ -55,7 +54,10 @@ pub async fn build_magic_url( } let parsed: CreateResponse = resp.json().await?; - Ok(format!("{base}/web/magic?token={}", parsed.token)) + Ok(join_url( + server_url, + &format!("/web/magic?token={}", parsed.token), + )) } #[cfg(test)] diff --git a/client-desktop/src/main.rs b/client-desktop/src/main.rs index d75aa86..6701a2a 100644 --- a/client-desktop/src/main.rs +++ b/client-desktop/src/main.rs @@ -16,6 +16,19 @@ use std::fs::{File, OpenOptions, TryLockError}; use crate::app::DoctateApp; use crate::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path}; +/// Unwrap a startup `Result` or print `context: {err}` to stderr and +/// `exit(1)`. Used for unrecoverable bootstrap failures before the +/// egui event loop runs — there is no UI to show an error in. +fn or_die(result: Result, context: &str) -> T { + match result { + Ok(v) => v, + Err(e) => { + eprintln!("{context}: {e}"); + std::process::exit(1); + } + } +} + fn main() -> eframe::Result { tracing_subscriber::fmt() .with_env_filter( @@ -34,33 +47,18 @@ fn main() -> eframe::Result { .expect("build tokio runtime"), ); - let pending = match pending_dir() { - Ok(p) => p, - Err(e) => { - eprintln!("cannot determine pending directory: {e}"); - std::process::exit(1); - } - }; + let pending = or_die(pending_dir(), "cannot determine pending directory"); // Single-instance guard. The lock is held by the `_lock_file` binding // for the entire `main` lifetime — OS releases it automatically on // process exit or crash, so a stale lock file after a crash is // harmless (the next launch re-acquires). let _lock_file = acquire_single_instance_lock(); - let cases = match cases_dir() { - Ok(p) => p, - Err(e) => { - eprintln!("cannot determine cases directory: {e}"); - std::process::exit(1); - } - }; - let cache_path = match snapshot_cache_path() { - Ok(p) => p, - Err(e) => { - eprintln!("cannot determine snapshot cache path: {e}"); - std::process::exit(1); - } - }; + let cases = or_die(cases_dir(), "cannot determine cases directory"); + let cache_path = or_die( + snapshot_cache_path(), + "cannot determine snapshot cache path", + ); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -84,13 +82,7 @@ fn main() -> eframe::Result { /// handle; dropping it (process exit) releases the lock. If another /// instance already holds it, prints a friendly message and exits. fn acquire_single_instance_lock() -> File { - let path = match lock_file_path() { - Ok(p) => p, - Err(e) => { - eprintln!("cannot determine lock file path: {e}"); - std::process::exit(1); - } - }; + let path = or_die(lock_file_path(), "cannot determine lock file path"); if let Some(parent) = path.parent() && let Err(e) = std::fs::create_dir_all(parent) { diff --git a/client-desktop/src/paths.rs b/client-desktop/src/paths.rs index fec4051..a95649b 100644 --- a/client-desktop/src/paths.rs +++ b/client-desktop/src/paths.rs @@ -5,45 +5,61 @@ use std::path::PathBuf; use directories::ProjectDirs; use thiserror::Error; +/// Directory name for pending (not-yet-uploaded) recordings inside the +/// data-local root. +const PENDING_DIR_NAME: &str = "pending"; + +/// Directory name for per-case marker files inside the data-local root. +const CASES_DIR_NAME: &str = "cases"; + +/// File name for the oneliners snapshot cache inside the data-local root. +const SNAPSHOT_CACHE_FILENAME: &str = "oneliners_cache.json"; + +/// File name for the single-instance advisory lock inside the data-local +/// root. +const LOCK_FILENAME: &str = "client.lock"; + #[derive(Debug, Error)] pub enum PathError { #[error("cannot determine OS data directory (is $HOME set?)")] NoDataDir, } +/// Resolve `{data_local_dir}/{rel}` under the doctate project scope, or +/// error if the OS won't give us a data directory at all. Every public +/// path helper here delegates to this so the `ProjectDirs::from(…)` +/// triple stays in one place. +fn project_path(rel: &str) -> Result { + ProjectDirs::from("", "", "doctate") + .map(|dirs| dirs.data_local_dir().join(rel)) + .ok_or(PathError::NoDataDir) +} + /// Directory where recordings wait for upload. Persists across app /// restarts and crashes. /// Linux: `~/.local/share/doctate/pending/` /// Windows: `%LOCALAPPDATA%\doctate\pending\` /// macOS: `~/Library/Application Support/doctate/pending/` pub fn pending_dir() -> Result { - ProjectDirs::from("", "", "doctate") - .map(|dirs| dirs.data_local_dir().join("pending")) - .ok_or(PathError::NoDataDir) + project_path(PENDING_DIR_NAME) } /// Directory holding one JSON marker per known case (locally created /// and/or server-synced). Persists across app restarts. /// Linux: `~/.local/share/doctate/cases/` pub fn cases_dir() -> Result { - ProjectDirs::from("", "", "doctate") - .map(|dirs| dirs.data_local_dir().join("cases")) - .ok_or(PathError::NoDataDir) + project_path(CASES_DIR_NAME) } /// File path for the last-successful `/api/oneliners` response. Feeds /// the UI at cold start before the first live poll completes. pub fn snapshot_cache_path() -> Result { - ProjectDirs::from("", "", "doctate") - .map(|dirs| dirs.data_local_dir().join("oneliners_cache.json")) - .ok_or(PathError::NoDataDir) + project_path(SNAPSHOT_CACHE_FILENAME) } /// File path for the single-instance advisory lock. Held by the running /// process for its entire lifetime; a second launch attempting to lock /// the same file gets `WouldBlock` and exits cleanly. pub fn lock_file_path() -> Result { - ProjectDirs::from("", "", "doctate") - .map(|dirs| dirs.data_local_dir().join("client.lock")) - .ok_or(PathError::NoDataDir) + project_path(LOCK_FILENAME) } diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index d79ce22..9f9ab1d 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -27,6 +27,7 @@ use doctate_common::constants::{ API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, UPLOAD_PATH, }; +use doctate_common::join_url; use doctate_common::oneliners::{ONELINERS_PATH, OnelinersResponse}; use reqwest::StatusCode; use tokio::sync::{mpsc, watch}; @@ -369,7 +370,7 @@ pub(crate) async fn post_upload( .text(FIELD_RECORDED_AT, upload.recorded_at.clone()) .part(FIELD_AUDIO, audio_part); - let url = format!("{}{}", config.server_url.trim_end_matches('/'), UPLOAD_PATH); + let url = join_url(&config.server_url, UPLOAD_PATH); let resp = match http .post(&url) @@ -406,11 +407,7 @@ pub(crate) async fn poll_once( cache: &SnapshotCache, etag: &mut Option, ) -> Result { - let url = format!( - "{}{}", - config.server_url.trim_end_matches('/'), - ONELINERS_PATH, - ); + let url = join_url(&config.server_url, ONELINERS_PATH); let mut req = http.get(&url).header(API_KEY_HEADER, &config.api_key); if let Some(tag) = etag.as_deref() { diff --git a/doctate-common/src/bulk.rs b/doctate-common/src/bulk.rs new file mode 100644 index 0000000..38105c5 --- /dev/null +++ b/doctate-common/src/bulk.rs @@ -0,0 +1,111 @@ +//! Shared vocabulary for the admin bulk-action endpoint (`POST +//! /web/cases/bulk`). +//! +//! The wire shape is `application/x-www-form-urlencoded` with an +//! `action=` field and one or more `case_id=` repetitions. +//! Keeping the set of accepted tokens in a single enum lets the server +//! handler match exhaustively and lets tests build request bodies +//! without string literals that can silently drift from the handler's +//! accepted values. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Which operation a bulk POST applies to every selected case. +/// +/// Serialized as lowercase `"close" | "analyze" | "reset"`. The server +/// handler [`BulkAction::from_str`]-parses the raw form value so an +/// unknown token produces a domain error ("Unbekannte Aktion") instead +/// of a serde/extractor error — preserving the HTTP contract across +/// refactors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BulkAction { + /// Mark each case closed (write `.closed` marker). + Close, + /// Queue each case for LLM analysis. + Analyze, + /// Drop derived artefacts (oneliner, document, analysis input) and + /// revert `.m4a.failed` to `.m4a` so transcription self-heal retries. + Reset, +} + +impl BulkAction { + /// Lowercase on-the-wire token. Intended for test request bodies + /// and redirect targets; the server's own deserializer goes through + /// [`FromStr`] to preserve the exact error shape. + pub const fn as_str(self) -> &'static str { + match self { + BulkAction::Close => "close", + BulkAction::Analyze => "analyze", + BulkAction::Reset => "reset", + } + } +} + +impl fmt::Display for BulkAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Returned by [`BulkAction::from_str`] on an unknown token. Carries +/// the offending input so the caller can log it without re-capturing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownBulkAction(pub String); + +impl fmt::Display for UnknownBulkAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown bulk action: {}", self.0) + } +} + +impl std::error::Error for UnknownBulkAction {} + +impl FromStr for BulkAction { + type Err = UnknownBulkAction; + + fn from_str(s: &str) -> Result { + match s { + "close" => Ok(BulkAction::Close), + "analyze" => Ok(BulkAction::Analyze), + "reset" => Ok(BulkAction::Reset), + other => Err(UnknownBulkAction(other.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_lowercase_tokens() { + for v in [BulkAction::Close, BulkAction::Analyze, BulkAction::Reset] { + assert_eq!(v, v.as_str().parse().unwrap()); + } + } + + #[test] + fn wire_tokens_are_stable() { + assert_eq!(BulkAction::Close.as_str(), "close"); + assert_eq!(BulkAction::Analyze.as_str(), "analyze"); + assert_eq!(BulkAction::Reset.as_str(), "reset"); + } + + #[test] + fn unknown_token_reports_input() { + let err = "foo".parse::().unwrap_err(); + assert_eq!(err.0, "foo"); + } + + #[test] + fn serde_matches_wire() { + let json = serde_json::to_string(&BulkAction::Analyze).unwrap(); + assert_eq!(json, "\"analyze\""); + let back: BulkAction = serde_json::from_str("\"reset\"").unwrap(); + assert_eq!(back, BulkAction::Reset); + } +} diff --git a/doctate-common/src/lib.rs b/doctate-common/src/lib.rs index 5c840f4..6afb778 100644 --- a/doctate-common/src/lib.rs +++ b/doctate-common/src/lib.rs @@ -1,14 +1,18 @@ pub mod ack; +pub mod bulk; pub mod constants; pub mod oneliners; pub mod recordings; pub mod timestamp; +pub mod url; pub use ack::{AckResponse, AckStatus}; +pub use bulk::{BulkAction, UnknownBulkAction}; pub use constants::{ API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, UPLOAD_PATH, }; -pub use oneliners::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse}; +pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse}; pub use recordings::TranscriptState; pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem}; +pub use url::join_url; diff --git a/doctate-common/src/url.rs b/doctate-common/src/url.rs new file mode 100644 index 0000000..f576d9a --- /dev/null +++ b/doctate-common/src/url.rs @@ -0,0 +1,55 @@ +//! Small URL-assembly helpers shared by every HTTP caller in the +//! workspace. +//! +//! All clients (desktop, server-internal, server-to-Ollama/Whisper) +//! end up building `{base_url}{path}` where `base_url` comes from +//! config and may or may not carry a trailing slash. The raw +//! `format!("{}{}", base.trim_end_matches('/'), path)` pattern was +//! duplicated in seven call sites; centralizing it here keeps any +//! future rule (enforce a leading slash on the path, tolerate +//! `http://host/` with a path segment, normalize percent-encoding) +//! in one place. + +/// Join a base URL with an absolute path, normalizing any trailing +/// slash on the base. The path is expected to start with `/`; no +/// validation is performed — callers already pass constants like +/// [`crate::UPLOAD_PATH`]. +/// +/// Examples: +/// - `join_url("http://h", "/a")` → `"http://h/a"` +/// - `join_url("http://h/", "/a")` → `"http://h/a"` +/// - `join_url("http://h//", "/a")` → `"http://h/a"` +pub fn join_url(base: &str, path: &str) -> String { + let mut out = String::with_capacity(base.len() + path.len()); + out.push_str(base.trim_end_matches('/')); + out.push_str(path); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_trailing_slash_on_base() { + assert_eq!(join_url("http://h", "/a"), "http://h/a"); + } + + #[test] + fn single_trailing_slash_on_base() { + assert_eq!(join_url("http://h/", "/a"), "http://h/a"); + } + + #[test] + fn multiple_trailing_slashes_on_base() { + assert_eq!(join_url("http://h///", "/a"), "http://h/a"); + } + + #[test] + fn nested_path() { + assert_eq!( + join_url("https://example.com/", "/api/upload"), + "https://example.com/api/upload" + ); + } +} diff --git a/server/src/analyze/llm.rs b/server/src/analyze/llm.rs index ac9942a..dbc39e0 100644 --- a/server/src/analyze/llm.rs +++ b/server/src/analyze/llm.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use doctate_common::join_url; use serde::{Deserialize, Serialize}; use tracing::debug; @@ -84,10 +85,7 @@ pub async fn chat_once( user_content: &str, timeout: Duration, ) -> Result { - let url = format!( - "{}/v1/chat/completions", - settings.base_url.trim_end_matches('/') - ); + let url = join_url(settings.base_url, "/v1/chat/completions"); debug!(%url, model = settings.model, "calling llm chat completions"); let body = ChatRequest { diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index 6b1db4f..3b2c82c 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -1,7 +1,9 @@ +use std::str::FromStr; use std::sync::Arc; use axum::extract::State; use axum::response::Redirect; +use doctate_common::BulkAction; use doctate_common::timestamp::now_rfc3339; use serde::Deserialize; use tracing::{info, warn}; @@ -55,8 +57,16 @@ pub async fn handle_bulk_action( let user_root = config.data_path.join(&user.slug); - match form.action.as_str() { - "analyze" => { + // Parse the wire token into the shared enum so the match below is + // exhaustive at compile time. Keep the exact "Unbekannte Aktion" + // error shape the handler returned before the enum existed. + let action = BulkAction::from_str(&form.action).map_err(|err| { + warn!(slug = %user.slug, action = %err.0, "bulk: unknown action"); + AppError::BadRequest("Unbekannte Aktion".into()) + })?; + + match action { + BulkAction::Analyze => { bulk_analyze( &config, &analyze_tx, @@ -67,12 +77,8 @@ pub async fn handle_bulk_action( ) .await } - "close" => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await, - "reset" => bulk_reset(&events_tx, &user.slug, &user_root, &form.case_ids).await, - other => { - warn!(slug = %user.slug, action = %other, "bulk: unknown action"); - return Err(AppError::BadRequest("Unbekannte Aktion".into())); - } + BulkAction::Close => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await, + BulkAction::Reset => bulk_reset(&events_tx, &user.slug, &user_root, &form.case_ids).await, } Ok(Redirect::to("/web/cases")) diff --git a/server/src/transcribe/ollama.rs b/server/src/transcribe/ollama.rs index 2785c5e..79a5de4 100644 --- a/server/src/transcribe/ollama.rs +++ b/server/src/transcribe/ollama.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use doctate_common::join_url; use serde::{Deserialize, Serialize}; use tracing::debug; @@ -100,7 +101,7 @@ pub async fn generate_oneliner( transcript: &str, timeout: Duration, ) -> Result { - let url = format!("{}/api/chat", ollama_url.trim_end_matches('/')); + let url = join_url(ollama_url, "/api/chat"); debug!(%url, model, "generating oneliner"); let body = ChatRequest { diff --git a/server/src/transcribe/whisper.rs b/server/src/transcribe/whisper.rs index d8fea3d..59e69b2 100644 --- a/server/src/transcribe/whisper.rs +++ b/server/src/transcribe/whisper.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::time::Duration; +use doctate_common::join_url; use reqwest::multipart::{Form, Part}; use tracing::debug; @@ -100,9 +101,8 @@ pub async fn transcribe( .filter(|s| !s.is_empty()) .unwrap_or("de"); let url = format!( - "{}/asr?output=txt&language={}", - whisper_url.trim_end_matches('/'), - language + "{}?output=txt&language={language}", + join_url(whisper_url, "/asr") ); debug!(%url, "posting to whisper"); diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 87de820..e0dca8b 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -7,14 +7,16 @@ use std::time::Duration; use axum::http::{StatusCode, header}; use tower::util::ServiceExt; +use doctate_common::BulkAction; use doctate_server::analyze; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use common::{ - TestConfig, csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, - test_admin, test_user, unique_tmpdir, + ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, + csrf_form_post, get_with_cookie, login_with_csrf, paths, seed_case, seed_recording, test_admin, + test_user, unique_tmpdir, }; // --------------------------------------------------------------------- @@ -217,7 +219,7 @@ async fn analyze_case_happy_path_writes_input_and_redirects() { "/web/cases" ); - let input_path = case_dir.join("analysis_input.json"); + let input_path = case_dir.join(ANALYSIS_INPUT_FILE); assert!(input_path.exists(), "analysis_input.json missing"); let raw = std::fs::read_to_string(&input_path).unwrap(); @@ -287,7 +289,7 @@ async fn analyze_case_skips_blank_transcript_from_recordings() { .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - let raw = std::fs::read_to_string(case_dir.join("analysis_input.json")).unwrap(); + let raw = std::fs::read_to_string(case_dir.join(ANALYSIS_INPUT_FILE)).unwrap(); let parsed: Value = serde_json::from_str(&raw).unwrap(); let recs = parsed["recordings"].as_array().unwrap(); assert_eq!(recs.len(), 2, "blank transcript must be filtered out"); @@ -310,7 +312,7 @@ async fn analyze_worker_writes_document_via_wiremock() { ] }); std::fs::write( - case_dir.join("analysis_input.json"), + case_dir.join(ANALYSIS_INPUT_FILE), serde_json::to_vec_pretty(&input).unwrap(), ) .unwrap(); @@ -352,7 +354,7 @@ async fn analyze_worker_writes_document_via_wiremock() { .unwrap(); // Poll for the document (worker is in a separate task). - let document_path = case_dir.join("document.md"); + let document_path = case_dir.join(DOCUMENT_FILE); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !document_path.exists() { if std::time::Instant::now() > deadline { @@ -389,7 +391,7 @@ async fn analyze_worker_normalizes_llm_output() { ] }); std::fs::write( - case_dir.join("analysis_input.json"), + case_dir.join(ANALYSIS_INPUT_FILE), serde_json::to_vec_pretty(&input).unwrap(), ) .unwrap(); @@ -442,7 +444,7 @@ async fn analyze_worker_normalizes_llm_output() { }) .unwrap(); - let document_path = case_dir.join("document.md"); + let document_path = case_dir.join(DOCUMENT_FILE); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !document_path.exists() { if std::time::Instant::now() > deadline { @@ -518,7 +520,7 @@ async fn recovery_enqueues_pending_analysis() { let tmp = unique_tmpdir("analyze-r1"); 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.json"), "{}").unwrap(); + std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); let (tx, mut rx) = analyze::channel(); analyze::recovery::scan_and_enqueue(&tmp, &tx).await; @@ -533,8 +535,8 @@ async fn recovery_skips_completed_analysis() { let tmp = unique_tmpdir("analyze-r2"); 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.json"), "{}").unwrap(); - std::fs::write(case_dir.join("document.md"), "done").unwrap(); + std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); + std::fs::write(case_dir.join(DOCUMENT_FILE), "done").unwrap(); let (tx, mut rx) = analyze::channel(); analyze::recovery::scan_and_enqueue(&tmp, &tx).await; @@ -554,7 +556,7 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { let cfg = cfg_llm("rn-5"); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document.md"), "altes Dokument").unwrap(); + std::fs::write(case_dir.join(DOCUMENT_FILE), "altes Dokument").unwrap(); seed_recording(&case_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme")); seed_recording(&case_dir, "2026-04-15T10-05-00Z", Some("zweite Aufnahme")); @@ -573,10 +575,10 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert!( - !case_dir.join("document.md").exists(), + !case_dir.join(DOCUMENT_FILE).exists(), "old document must be deleted before re-analysis" ); - let input_path = case_dir.join("analysis_input.json"); + let input_path = case_dir.join(ANALYSIS_INPUT_FILE); assert!(input_path.exists(), "analysis_input.json missing"); let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap(); @@ -615,7 +617,7 @@ async fn close_writes_marker_and_hides_case() { .unwrap(), "/web/cases" ); - let marker = case_dir.join(".closed"); + let marker = case_dir.join(CLOSE_MARKER); assert!(marker.exists(), ".closed marker missing"); let raw = std::fs::read_to_string(&marker).unwrap(); let parsed: Value = serde_json::from_str(&raw).unwrap(); @@ -675,7 +677,7 @@ async fn reopen_removes_marker_and_restores_case() { )) .await .unwrap(); - assert!(case_dir.join(".closed").exists()); + assert!(case_dir.join(CLOSE_MARKER).exists()); let resp = app .clone() @@ -689,7 +691,7 @@ async fn reopen_removes_marker_and_restores_case() { .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert!( - !case_dir.join(".closed").exists(), + !case_dir.join(CLOSE_MARKER).exists(), ".closed marker must be gone after reopen" ); @@ -919,7 +921,7 @@ async fn reopen_is_noop_on_open_case() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert!(!case_dir.join(".closed").exists()); + assert!(!case_dir.join(CLOSE_MARKER).exists()); } #[tokio::test] @@ -943,7 +945,7 @@ async fn purge_closed_removes_directory() { )) .await .unwrap(); - assert!(case_dir.join(".closed").exists()); + assert!(case_dir.join(CLOSE_MARKER).exists()); let resp = app .oneshot(csrf_form_post( @@ -991,7 +993,7 @@ async fn purge_requires_confirm_param() { case_dir.exists(), "case directory must survive purge without confirm=yes" ); - assert!(case_dir.join(".closed").exists()); + assert!(case_dir.join(CLOSE_MARKER).exists()); } #[tokio::test] @@ -1086,7 +1088,10 @@ async fn bulk_close_shares_one_closed_at_timestamp() { let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=close&case_id={case_a}&case_id={case_b}"); + let body = format!( + "action={}&case_id={case_a}&case_id={case_b}", + BulkAction::Close + ); let resp = app .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await @@ -1094,9 +1099,9 @@ async fn bulk_close_shares_one_closed_at_timestamp() { assert_eq!(resp.status(), StatusCode::SEE_OTHER); let m_a: Value = - serde_json::from_str(&std::fs::read_to_string(dir_a.join(".closed")).unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(dir_a.join(CLOSE_MARKER)).unwrap()).unwrap(); let m_b: Value = - serde_json::from_str(&std::fs::read_to_string(dir_b.join(".closed")).unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(dir_b.join(CLOSE_MARKER)).unwrap()).unwrap(); assert_eq!( m_a["closed_at"], m_b["closed_at"], "bulk-close must share one closed_at timestamp across its selection" @@ -1117,15 +1122,18 @@ async fn bulk_analyze_processes_each_selected_case() { let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=analyze&case_id={case_a}&case_id={case_b}"); + let body = format!( + "action={}&case_id={case_a}&case_id={case_b}", + BulkAction::Analyze + ); let resp = app .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert!(dir_a.join("analysis_input.json").exists()); - assert!(dir_b.join("analysis_input.json").exists()); + assert!(dir_a.join(ANALYSIS_INPUT_FILE).exists()); + assert!(dir_b.join(ANALYSIS_INPUT_FILE).exists()); } #[tokio::test] @@ -1133,9 +1141,9 @@ async fn recovery_skips_closed_cases() { let tmp = unique_tmpdir("analyze-rec-closed"); 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.json"), "{}").unwrap(); + std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); std::fs::write( - case_dir.join(".closed"), + case_dir.join(CLOSE_MARKER), r#"{"closed_at":"2026-04-15T10:00:00Z"}"#, ) .unwrap(); @@ -1162,12 +1170,12 @@ async fn reset_case_clears_derived_and_unfails_audio() { std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap(); // Derived artefacts the reset must wipe. std::fs::write( - dir.join("oneliner.json"), + dir.join(ONELINER_FILENAME), br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#, ) .unwrap(); - std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); - std::fs::write(dir.join("analysis_input.json"), "{}").unwrap(); + std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").unwrap(); + std::fs::write(dir.join(ANALYSIS_INPUT_FILE), "{}").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; @@ -1190,9 +1198,9 @@ async fn reset_case_clears_derived_and_unfails_audio() { assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists()); // Derived artefacts gone. assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists()); - assert!(!dir.join("oneliner.json").exists()); - assert!(!dir.join("document.md").exists()); - assert!(!dir.join("analysis_input.json").exists()); + assert!(!dir.join(ONELINER_FILENAME).exists()); + assert!(!dir.join(DOCUMENT_FILE).exists()); + assert!(!dir.join(ANALYSIS_INPUT_FILE).exists()); } #[tokio::test] @@ -1202,7 +1210,7 @@ async fn reset_case_requires_admin() { let case_id = "11111111-1111-1111-1111-111111111111"; let dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_recording(&dir, "2026-04-15T09-00-00Z", Some("hallo")); - std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); + std::fs::write(dir.join(DOCUMENT_FILE), "# Doc\n").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; @@ -1220,7 +1228,7 @@ async fn reset_case_requires_admin() { // Nothing touched. assert!(dir.join("2026-04-15T09-00-00Z.transcript.txt").exists()); - assert!(dir.join("document.md").exists()); + assert!(dir.join(DOCUMENT_FILE).exists()); } #[tokio::test] @@ -1237,19 +1245,22 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { let dir_b = seed_case(&cfg.data_path, "dr_a", case_b); seed_recording(&dir_a, "2026-04-15T10-00-00Z", Some("a")); seed_recording(&dir_b, "2026-04-15T10-05-00Z", Some("b")); - std::fs::write(dir_a.join("document.md"), "A").unwrap(); - std::fs::write(dir_b.join("document.md"), "B").unwrap(); + std::fs::write(dir_a.join(DOCUMENT_FILE), "A").unwrap(); + std::fs::write(dir_b.join(DOCUMENT_FILE), "B").unwrap(); // dr_b gets its own case to verify non-admin branch without clobbering dr_a. let case_c = "33333333-3333-3333-3333-333333333333"; let dir_c = seed_case(&cfg.data_path, "dr_b", case_c); seed_recording(&dir_c, "2026-04-15T11-00-00Z", Some("c")); - std::fs::write(dir_c.join("document.md"), "C").unwrap(); + std::fs::write(dir_c.join(DOCUMENT_FILE), "C").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); // Admin: bulk reset succeeds. let (cookie_admin, csrf_admin) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=reset&case_id={case_a}&case_id={case_b}"); + let body = format!( + "action={}&case_id={case_a}&case_id={case_b}", + BulkAction::Reset + ); let resp = app .clone() .oneshot(csrf_form_post( @@ -1261,14 +1272,14 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert!(!dir_a.join("document.md").exists()); - assert!(!dir_b.join("document.md").exists()); + assert!(!dir_a.join(DOCUMENT_FILE).exists()); + assert!(!dir_b.join(DOCUMENT_FILE).exists()); assert!(!dir_a.join("2026-04-15T10-00-00Z.transcript.txt").exists()); assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists()); // Non-admin: bulk reset is rejected, dr_b's case untouched. let (cookie_doctor, csrf_doctor) = login_with_csrf(&app, &store, "dr_b", "s").await; - let body = format!("action=reset&case_id={case_c}"); + let body = format!("action={}&case_id={case_c}", BulkAction::Reset); let resp = app .oneshot(csrf_form_post( paths::BULK, @@ -1279,7 +1290,7 @@ async fn bulk_reset_processes_multiple_cases_admin_only() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); - assert!(dir_c.join("document.md").exists()); + assert!(dir_c.join(DOCUMENT_FILE).exists()); assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").exists()); } @@ -1298,14 +1309,14 @@ async fn bulk_close_rejected_for_non_admin() { let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; - let body = format!("action=close&case_id={case_id}"); + let body = format!("action={}&case_id={case_id}", BulkAction::Close); let resp = app .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); // Close marker must NOT have been written. - assert!(!dir.join(".closed").exists()); + assert!(!dir.join(CLOSE_MARKER).exists()); } // --------------------------------------------------------------------- @@ -1331,7 +1342,7 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() { ] }); std::fs::write( - case_dir.join("analysis_input.json"), + case_dir.join(ANALYSIS_INPUT_FILE), serde_json::to_vec_pretty(&input).unwrap(), ) .unwrap(); @@ -1381,7 +1392,7 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() { }) .unwrap(); - let document_path = case_dir.join("document.md"); + let document_path = case_dir.join(DOCUMENT_FILE); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !document_path.exists() { if std::time::Instant::now() > deadline { diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index c49f260..f1253dc 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -6,7 +6,7 @@ use tower::util::ServiceExt; use doctate_common::oneliners::OnelinerState; use common::{ - TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, + DOCUMENT_FILE, TestConfig, body_string, get_with_cookie, paths, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording, test_admin, test_user, }; @@ -29,7 +29,7 @@ async fn case_page_shows_document_when_present() { .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document.md"), "der Inhalt").unwrap(); + 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; @@ -57,7 +57,7 @@ async fn case_page_document_has_copy_button() { .build(); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document.md"), "kopiere mich").unwrap(); + 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; @@ -175,7 +175,7 @@ async fn case_page_foreign_user_returns_404() { let case_id = "22222222-2222-2222-2222-222222222222"; // Case exists under dr_b, but session is dr_a → 404. let case_dir = seed_case(&cfg.data_path, "dr_b", case_id); - std::fs::write(case_dir.join("document.md"), "fremdes Dokument").unwrap(); + std::fs::write(case_dir.join(DOCUMENT_FILE), "fremdes Dokument").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; @@ -255,7 +255,7 @@ async fn case_page_uses_oneliner_as_h1_when_present() { let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); seed_oneliner_ready(&case_dir, "Herzkatheter unauffällig"); - std::fs::write(case_dir.join("document.md"), "Inhalt").unwrap(); + std::fs::write(case_dir.join(DOCUMENT_FILE), "Inhalt").unwrap(); let app = doctate_server::create_router(cfg); let cookie = login_dr_a(&app).await; diff --git a/server/tests/close_watermark_test.rs b/server/tests/close_watermark_test.rs index 96b2722..53a5ee0 100644 --- a/server/tests/close_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -11,6 +11,7 @@ mod common; use std::path::Path; use axum::http::StatusCode; +use doctate_common::BulkAction; use tower::util::ServiceExt; use common::{ @@ -149,7 +150,10 @@ async fn bulk_close_bumps_watermark() { let etag_before = etag_before.expect("ETag must be set"); // Form-encoded bulk close: action=close&case_id=a&case_id=b. - let body = format!("action=close&case_id={case_a}&case_id={case_b}"); + let body = format!( + "action={}&case_id={case_a}&case_id={case_b}", + BulkAction::Close + ); let bulk = app .clone() .oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body)) diff --git a/server/tests/common/artefacts.rs b/server/tests/common/artefacts.rs new file mode 100644 index 0000000..8d9fc41 --- /dev/null +++ b/server/tests/common/artefacts.rs @@ -0,0 +1,13 @@ +//! Re-exports of case-directory artefact filenames from their +//! canonical source-of-truth locations. +//! +//! Tests that need to read, write, or assert the absence of per-case +//! artefacts (`oneliner.json`, `.closed`, `document.md`, +//! `analysis_input.json`) pull these names from here instead of +//! inlining string literals. If the server renames a sidecar in the +//! future, test call-sites fail to compile instead of silently +//! asserting against the old name. + +pub use doctate_common::ONELINER_FILENAME; +pub use doctate_server::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; +pub use doctate_server::paths::CLOSE_MARKER; diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index 6fc7959..d8a1d69 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -16,6 +16,7 @@ // warning is a false positive of the per-crate visibility model. #![allow(unused_imports)] +pub mod artefacts; pub mod config; pub mod http; pub mod paths; @@ -24,6 +25,7 @@ pub mod session; pub mod users; // Re-exports for ergonomic `use common::*;` in most test files. +pub use artefacts::{ANALYSIS_INPUT_FILE, CLOSE_MARKER, DOCUMENT_FILE, ONELINER_FILENAME}; pub use config::{TestConfig, unique_tmpdir}; pub use http::{ body_bytes, body_json, body_string, csrf_form_post, form_post, get_with_api_key, diff --git a/server/tests/common/seed.rs b/server/tests/common/seed.rs index cbd9bb2..ed4fd1a 100644 --- a/server/tests/common/seed.rs +++ b/server/tests/common/seed.rs @@ -11,7 +11,9 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; +use doctate_common::ONELINER_FILENAME; use doctate_common::oneliners::OnelinerState; +use doctate_server::paths::CLOSE_MARKER; use filetime::FileTime; /// Create `///` and return its path. This is @@ -71,7 +73,7 @@ pub fn seed_oneliner_ready(case_dir: &Path, text: &str) { /// Persist an arbitrary [`OnelinerState`] as `oneliner.json`. pub fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) { let bytes = serde_json::to_vec(state).unwrap(); - std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap(); + std::fs::write(case_dir.join(ONELINER_FILENAME), bytes).unwrap(); } /// Write a `.closed` marker with a caller-chosen `closed_at` string. @@ -79,7 +81,7 @@ pub fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) { /// helper for that is [`past_rfc3339`]. pub fn write_closed_marker(case_dir: &Path, closed_at: &str) { let json = format!(r#"{{"closed_at":"{closed_at}"}}"#); - std::fs::write(case_dir.join(".closed"), json).unwrap(); + std::fs::write(case_dir.join(CLOSE_MARKER), json).unwrap(); } /// Format "now - age" as RFC3339 UTC. Used to mint historical diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index 76e93a2..56b5b7f 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -11,6 +11,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; +use doctate_common::BulkAction; use tower::util::ServiceExt; use common::{ @@ -70,7 +71,10 @@ async fn bulk_without_csrf_token_forbidden() { let app = doctate_server::create_router(cfg); let cookie = login(&app, "dr_admin", "s").await; - let body = "action=close&case_id=00000000-0000-0000-0000-000000000000"; + let body = format!( + "action={}&case_id=00000000-0000-0000-0000-000000000000", + BulkAction::Close + ); let resp = app .oneshot(form_post(paths::BULK, &cookie, body)) .await @@ -212,8 +216,10 @@ async fn bulk_with_wrong_csrf_token_forbidden() { // A 43-char alphanumeric that is structurally valid but does not // match the session's token. let wrong = "a".repeat(43); - let body = - format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}"); + let body = format!( + "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}", + BulkAction::Close + ); let resp = app .oneshot(form_post(paths::BULK, &cookie, body)) .await @@ -227,7 +233,10 @@ async fn bulk_with_empty_csrf_token_forbidden() { let app = doctate_server::create_router(cfg); let cookie = login(&app, "dr_admin", "s").await; - let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token="; + let body = format!( + "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=", + BulkAction::Close + ); let resp = app .oneshot(form_post(paths::BULK, &cookie, body)) .await @@ -249,7 +258,10 @@ async fn bulk_with_whitespace_padded_token_forbidden() { let cookie = login(&app, "dr_admin", "s").await; // Leading+trailing spaces around a value that *would* match post-trim. - let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20"; + let body = format!( + "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20", + BulkAction::Close + ); let resp = app .oneshot(form_post(paths::BULK, &cookie, body)) .await @@ -268,8 +280,10 @@ async fn bulk_with_injection_payload_returns_403_not_500() { let cookie = login(&app, "dr_admin", "s").await; // `' OR 1=1--` URL-encoded. - let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\ - &csrf_token=%27+OR+1%3D1--"; + let body = format!( + "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%27+OR+1%3D1--", + BulkAction::Close + ); let resp = app .oneshot(form_post(paths::BULK, &cookie, body)) .await diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index 3248f4b..f894d12 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -13,8 +13,8 @@ use axum::http::StatusCode; use tower::util::ServiceExt; use common::{ - TestConfig, csrf_form_post, login_with_csrf, paths, seed_case, seed_recording_with_sidecars, - test_user, + ANALYSIS_INPUT_FILE, DOCUMENT_FILE, ONELINER_FILENAME, TestConfig, csrf_form_post, + login_with_csrf, paths, seed_case, seed_recording_with_sidecars, test_user, }; fn delete_body(filename: &str) -> String { @@ -31,9 +31,9 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { let survivor = seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me"); // Derived artefacts the delete must also purge. - std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); - std::fs::write(case_dir.join("document.md"), b"# stale").unwrap(); - std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap(); + std::fs::write(case_dir.join(ONELINER_FILENAME), b"{}").unwrap(); + std::fs::write(case_dir.join(DOCUMENT_FILE), b"# stale").unwrap(); + std::fs::write(case_dir.join(ANALYSIS_INPUT_FILE), b"{}").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; @@ -79,15 +79,15 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() { // Derived artefacts are invalidated. assert!( - !case_dir.join("oneliner.json").exists(), + !case_dir.join(ONELINER_FILENAME).exists(), "oneliner.json must be cleared" ); assert!( - !case_dir.join("document.md").exists(), + !case_dir.join(DOCUMENT_FILE).exists(), "document.md must be cleared" ); assert!( - !case_dir.join("analysis_input.json").exists(), + !case_dir.join(ANALYSIS_INPUT_FILE).exists(), "analysis_input.json must be cleared" ); } @@ -137,7 +137,7 @@ async fn delete_recording_rejects_wrong_extension() { paths::recording_delete(case_id), &cookie, &csrf, - &delete_body("document.md"), + &delete_body(DOCUMENT_FILE), )) .await .unwrap(); @@ -191,7 +191,7 @@ async fn delete_last_recording_clears_case() { let case_id = "55555555-5555-5555-5555-555555555555"; let case_dir = seed_case(&cfg.data_path, "dr_a", case_id); let only = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "lonely"); - std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap(); + std::fs::write(case_dir.join(ONELINER_FILENAME), b"{}").unwrap(); let (app, store) = doctate_server::create_router_and_session_store(cfg); let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await; @@ -214,7 +214,7 @@ async fn delete_last_recording_clears_case() { ); assert!(!case_dir.join(&only).exists()); - assert!(!case_dir.join("oneliner.json").exists()); + assert!(!case_dir.join(ONELINER_FILENAME).exists()); } #[tokio::test] diff --git a/server/tests/failed_only_case_empty_oneliner_test.rs b/server/tests/failed_only_case_empty_oneliner_test.rs index 5fadef4..41ec805 100644 --- a/server/tests/failed_only_case_empty_oneliner_test.rs +++ b/server/tests/failed_only_case_empty_oneliner_test.rs @@ -30,7 +30,7 @@ use tempfile::tempdir; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use common::TestConfig; +use common::{ONELINER_FILENAME, TestConfig}; #[tokio::test] async fn failed_only_case_settles_to_empty_without_llm_call() { @@ -86,7 +86,7 @@ async fn failed_only_case_settles_to_empty_without_llm_call() { tokio::time::sleep(Duration::from_millis(25)).await; } - let path = case_dir.join("oneliner.json"); + let path = case_dir.join(ONELINER_FILENAME); assert!( path.exists(), "expected oneliner.json to be written for failed-only case" diff --git a/server/tests/oneliner_heal_decoupled_test.rs b/server/tests/oneliner_heal_decoupled_test.rs index 72d081c..ae817a3 100644 --- a/server/tests/oneliner_heal_decoupled_test.rs +++ b/server/tests/oneliner_heal_decoupled_test.rs @@ -23,7 +23,7 @@ use tokio::time::timeout; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use common::TestConfig; +use common::{ONELINER_FILENAME, TestConfig}; fn write_transcript(case_dir: &Path) { // Seed an m4a + transcript pair, matching the worker's on-disk @@ -123,7 +123,9 @@ async fn heal_orphans_returns_fast_and_dedups_parallel_calls() { // Every case now has an oneliner.json. for i in 0..5 { - let p = user_root.join(format!("case-{i:02}")).join("oneliner.json"); + let p = user_root + .join(format!("case-{i:02}")) + .join(ONELINER_FILENAME); assert!(p.exists(), "oneliner.json missing for case-{i:02}"); } diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 17aba48..a089262 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -9,8 +9,8 @@ use filetime::FileTime; use tower::util::ServiceExt; use common::{ - TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str, - seed_oneliner_ready, seed_oneliner_state, seed_recording_with_age, test_user, + CLOSE_MARKER, TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, + header_str, seed_oneliner_ready, seed_oneliner_state, seed_recording_with_age, test_user, test_user_with_window_hours, }; @@ -56,7 +56,7 @@ fn seed( } fn mark_deleted(case_dir: &Path) { - std::fs::write(case_dir.join(".closed"), "{}").unwrap(); + std::fs::write(case_dir.join(CLOSE_MARKER), "{}").unwrap(); } #[tokio::test] diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index adad96a..99e8340 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -12,8 +12,8 @@ use axum::http::StatusCode; use tower::util::ServiceExt; use common::{ - TestConfig, get_with_cookie, past_rfc3339, paths, seed_case, seed_recording_with_age, - test_user_with_retention, write_closed_marker, + CLOSE_MARKER, TestConfig, get_with_cookie, past_rfc3339, paths, seed_case, + seed_recording_with_age, test_user_with_retention, write_closed_marker, }; /// Trigger the sweep via its natural path: GET /web/cases. @@ -55,10 +55,10 @@ async fn auto_close_fires_when_last_recording_too_old() { let app = doctate_server::create_router(cfg); let cookie = common::login(&app, "dr_a", "s").await; - assert!(!case_dir.join(".closed").exists()); + assert!(!case_dir.join(CLOSE_MARKER).exists()); trigger_sweep(&app, &cookie).await; assert!( - case_dir.join(".closed").exists(), + case_dir.join(CLOSE_MARKER).exists(), "stale case must be auto-closed" ); } @@ -81,7 +81,7 @@ async fn auto_close_disabled_when_days_zero() { trigger_sweep(&app, &cookie).await; assert!( - !case_dir.join(".closed").exists(), + !case_dir.join(CLOSE_MARKER).exists(), "case must stay open when auto_close_days = 0" ); } @@ -104,7 +104,7 @@ async fn auto_close_respects_newest_recording() { trigger_sweep(&app, &cookie).await; assert!( - !case_dir.join(".closed").exists(), + !case_dir.join(CLOSE_MARKER).exists(), "recent addendum must keep the case open" ); } @@ -146,7 +146,7 @@ async fn auto_purge_disabled_when_days_zero() { case_dir.exists(), "closed case must survive when auto_delete_days = 0" ); - assert!(case_dir.join(".closed").exists()); + assert!(case_dir.join(CLOSE_MARKER).exists()); } #[tokio::test] @@ -174,7 +174,7 @@ async fn fresh_auto_close_is_not_immediately_purged() { "vacation: case directory must NOT be purged in the same sweep that auto-closed it" ); assert!( - case_dir.join(".closed").exists(), + case_dir.join(CLOSE_MARKER).exists(), "vacation: case must be auto-closed (but retained)" ); } diff --git a/server/tests/silent_case_empty_test.rs b/server/tests/silent_case_empty_test.rs index ea3a23c..e823f76 100644 --- a/server/tests/silent_case_empty_test.rs +++ b/server/tests/silent_case_empty_test.rs @@ -26,7 +26,7 @@ use tempfile::tempdir; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use common::TestConfig; +use common::{ONELINER_FILENAME, TestConfig}; /// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk /// shape of a recording that whisper classified as silence. @@ -90,7 +90,7 @@ async fn silent_only_case_settles_to_empty_without_llm_call() { // Oneliner is persisted as `Empty`. Without the fix, the file would // either not exist or be mid-generation. - let path = case_dir.join("oneliner.json"); + let path = case_dir.join(ONELINER_FILENAME); assert!( path.exists(), "expected oneliner.json to be written for silent-only case"