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:
@@ -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 ~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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user