813fb896ff
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.
169 lines
5.9 KiB
Rust
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);
|
|
}
|