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
+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"