Files
doctate/server/tests/close_watermark_test.rs
T
Brummel 813fb896ff 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.
2026-04-22 12:09:46 +02:00

169 lines
5.9 KiB
Rust

//! Listen-relevante Mutationen (Close, Reopen, Bulk-Close) müssen den
//! pro-User Watermark bumpen. Sonst bleibt der ETag von
//! `/api/oneliners` stehen, jeder Poll liefert 304, und der Client
//! läuft dauerhaft mit einem Marker für einen Fall, den der Server
//! bereits geschlossen hat (ursprünglicher Bugreport: "Polymyalgia
//! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits
//! gelöscht).
mod common;
use std::path::Path;
use axum::http::StatusCode;
use doctate_common::BulkAction;
use tower::util::ServiceExt;
use common::{
TestConfig, csrf_form_post, get_with_api_key, get_with_api_key_if_none_match, header_opt,
login_with_csrf, paths, test_admin, test_user,
};
/// Create `<data_path>/<slug>/<case_id>/<ts>.m4a` so the case is
/// visible to `enumerate_recent_oneliners` (window defaults to 72h;
/// the fake mtime is "now" because write happens right now).
fn seed_case_with_recording(data_path: &Path, slug: &str, case_id: &str) {
let case_dir = common::seed_case(data_path, slug, case_id);
std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a"), b"fake audio").unwrap();
}
async fn capture_etag(
app: &axum::Router,
api_key: &str,
if_none_match: Option<&str>,
) -> (StatusCode, Option<String>) {
let req = match if_none_match {
Some(tag) => get_with_api_key_if_none_match(paths::ONELINERS_PATH, api_key, tag),
None => get_with_api_key(paths::ONELINERS_PATH, api_key),
};
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let etag = header_opt(&resp, "etag").map(str::to_owned);
(status, etag)
}
#[tokio::test]
async fn close_bumps_watermark() {
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let case_id = "550e8400-e29b-41d4-a716-446655440000";
seed_case_with_recording(&cfg.data_path, "dr_a", case_id);
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// First GET: capture baseline ETag (watermark empty → seeded on 0).
let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await;
assert_eq!(s1, StatusCode::OK);
let etag_before = etag_before.expect("server must set ETag");
// Close via the web handler.
let del = app
.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(
del.status().as_u16() / 100,
3,
"close should redirect (3xx)"
);
// Same If-None-Match now must NOT match — watermark was bumped.
let (s2, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
assert_eq!(
s2,
StatusCode::OK,
"watermark bump must invalidate cached ETag",
);
let etag_after = etag_after.expect("200 response must set ETag");
assert_ne!(
etag_after, etag_before,
"close handler must change the per-user ETag"
);
}
#[tokio::test]
async fn reopen_bumps_watermark() {
let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
let case_id = "660e8400-e29b-41d4-a716-446655440000";
seed_case_with_recording(&cfg.data_path, "dr_a", case_id);
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
// Close first so reopen has something to restore; grab the post-close ETag.
app.clone()
.oneshot(csrf_form_post(
paths::case_close(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
let (_, etag_after_close) = capture_etag(&app, "key-dr_a", None).await;
let etag_after_close = etag_after_close.expect("ETag must be set");
// Reopen.
let reopen = app
.clone()
.oneshot(csrf_form_post(
paths::case_reopen(case_id),
&cookie,
&csrf,
"",
))
.await
.unwrap();
assert_eq!(reopen.status().as_u16() / 100, 3, "reopen should redirect");
// Reopen must bump again → post-close ETag becomes stale.
let (s, etag_after_reopen) = capture_etag(&app, "key-dr_a", Some(&etag_after_close)).await;
assert_eq!(
s,
StatusCode::OK,
"reopen must change the ETag so clients re-fetch"
);
let etag_after_reopen = etag_after_reopen.expect("200 response must set ETag");
assert_ne!(etag_after_reopen, etag_after_close);
}
#[tokio::test]
async fn bulk_close_bumps_watermark() {
// Bulk actions are admin-only: UI hides them from non-admins and
// the handler rejects non-admin POSTs with 403.
let cfg = TestConfig::new().with_user(test_admin("dr_a")).build();
let case_a = "770e8400-e29b-41d4-a716-446655440000";
let case_b = "880e8400-e29b-41d4-a716-446655440000";
seed_case_with_recording(&cfg.data_path, "dr_a", case_a);
seed_case_with_recording(&cfg.data_path, "dr_a", case_b);
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await;
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={}&case_id={case_a}&case_id={case_b}",
BulkAction::Close
);
let bulk = app
.clone()
.oneshot(csrf_form_post(paths::BULK, &cookie, &csrf, &body))
.await
.unwrap();
assert_eq!(bulk.status().as_u16() / 100, 3, "bulk should redirect");
let (s, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
assert_eq!(s, StatusCode::OK, "bulk-close must bump the ETag");
let etag_after = etag_after.expect("200 response must set ETag");
assert_ne!(etag_after, etag_before);
}