refactor: consolidate bulk actions, URL assembly, and case-artefact filenames

Pulls three pattern groups into shared helpers so a rename or wire-format
tweak edits one file instead of 10+:

- BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset"
  string literals that were duplicated between the bulk handler and 3
  attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion"
  error shape; the handler match is now exhaustive over the enum.
- join_url helper in doctate-common absorbs 7 identical
  trim_end_matches+format! call sites across client-core, client-desktop,
  server/transcribe (ollama, whisper), and server/analyze (llm).
- server/tests/common/artefacts.rs re-exports ONELINER_FILENAME
  (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE
  (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so
  test files reference the canonical name instead of inlining literals.

Also lifts client-desktop local duplication:
- paths.rs: project_path helper collapses 4 identical ProjectDirs chains
- main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks
- app.rs: named RecordingContext struct replaces (Uuid, String) tuple
  at 3 sites around the ffmpeg-flush finalization path

Verification: 397 tests pass (baseline was 389; +8 for new unit tests on
BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
This commit is contained in:
2026-04-22 12:09:46 +02:00
parent af3377a6bc
commit 813fb896ff
25 changed files with 416 additions and 166 deletions
+28 -8
View File
@@ -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<RecordingContext>,
}
/// 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 ~100500ms.
@@ -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 {
+6 -4
View File
@@ -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<String, MagicLinkError> {
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)]
+20 -28
View File
@@ -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<T, E: std::fmt::Display>(result: Result<T, E>, 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)
{
+28 -12
View File
@@ -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<PathBuf, PathError> {
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<PathBuf, PathError> {
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<PathBuf, PathError> {
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<PathBuf, PathError> {
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<PathBuf, PathError> {
ProjectDirs::from("", "", "doctate")
.map(|dirs| dirs.data_local_dir().join("client.lock"))
.ok_or(PathError::NoDataDir)
project_path(LOCK_FILENAME)
}
+3 -6
View File
@@ -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<String>,
) -> Result<PollOutcome, PollError> {
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() {
+111
View File
@@ -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=<token>` field and one or more `case_id=<uuid>` 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<Self, Self::Err> {
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::<BulkAction>().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);
}
}
+5 -1
View File
@@ -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;
+55
View File
@@ -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"
);
}
}
+2 -4
View File
@@ -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<String, LlmError> {
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 {
+14 -8
View File
@@ -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"))
+2 -1
View File
@@ -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<String, OllamaError> {
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 {
+3 -3
View File
@@ -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");
+59 -48
View File
@@ -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 {
+5 -5
View File
@@ -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;
+5 -1
View File
@@ -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))
+13
View File
@@ -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;
+2
View File
@@ -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,
+4 -2
View File
@@ -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 `<data_path>/<slug>/<case_id>/` 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
+21 -7
View File
@@ -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
+11 -11
View File
@@ -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]
@@ -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"
+4 -2
View File
@@ -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}");
}
+3 -3
View File
@@ -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]
+8 -8
View File
@@ -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)"
);
}
+2 -2
View File
@@ -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"