Files
doctate/server/tests/delete_watermark_test.rs
T
Brummel a1ff410d1b Refactor ETag generation for oneliners API
Remove the per-user oneliner watermark, which was previously used to
generate ETags for the `/api/oneliners` endpoint. The watermark was
intended to track the last successful oneliner write for each user.

The ETag generation has been refactored to use a deterministic FNV-1a
hash. This hash is computed over a sorted list of tuples containing
`(case_id, last_recording_at_ns, oneliner_mtime_ns)` for all visible
cases. This approach ensures that the ETag changes whenever any
listen-relevant file system modification occurs, such as a new
recording, oneliner regeneration, soft-delete, undo, or
upload-auto-reopen. This new method is more robust and avoids the
complexity of managing per-user state.

The `OnelinerWatermark` type alias and its associated logic have been
removed from `AppState` and the relevant modules (`transcribe::worker`,
`transcribe::recovery`, `routes::oneliners`, `main`). New integration
tests have been added to verify that various delete operations (single
delete, undo delete, bulk delete) correctly trigger an ETag update,
ensuring cache invalidation.
2026-04-19 14:47:30 +02:00

251 lines
8.4 KiB
Rust

//! Listen-relevante Mutationen (Einzel-Delete, Undo, Bulk-Delete) 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 gelöscht hat (ursprünglicher Bugreport: "Polymyalgia
//! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits
//! gelöscht).
use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use doctate_common::constants::API_KEY_HEADER;
use doctate_common::oneliners::ONELINERS_PATH;
use doctate_server::config::{Config, User};
use tower::util::ServiceExt;
fn make_user(slug: &str, password_plain: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: bcrypt::hash(password_plain, 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
}
}
fn test_config_with_users(users: Vec<User>) -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-delete-wm-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let api_keys: HashMap<String, String> =
users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect();
Arc::new(Config {
data_path,
users,
api_keys,
..Config::test_default()
})
}
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
let s = v.to_str().ok()?;
if let Some(pair) = s.split(';').next()
&& pair.starts_with("session=")
{
return Some(pair.to_string());
}
}
None
}
fn login_request(slug: &str, password: &str) -> Request<Body> {
let body = format!("slug={slug}&password={password}");
Request::builder()
.method("POST")
.uri("/web/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
fn oneliners_request(api_key: &str, if_none_match: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
.method("GET")
.uri(ONELINERS_PATH)
.header(API_KEY_HEADER, api_key);
if let Some(tag) = if_none_match {
b = b.header(header::IF_NONE_MATCH, tag);
}
b.body(Body::empty()).unwrap()
}
/// Create `<data_path>/<slug>/<case_id>/<ts>.m4a` so the case is
/// visible to `enumerate_recent_oneliners` (window defaults to 16h;
/// the fake mtime is "now" because write happens right now).
fn seed_case(data_path: &std::path::Path, slug: &str, case_id: &str) {
let case_dir = data_path.join(slug).join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
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 resp = app
.clone()
.oneshot(oneliners_request(api_key, if_none_match))
.await
.unwrap();
let status = resp.status();
let etag = resp
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
(status, etag)
}
#[tokio::test]
async fn delete_bumps_watermark() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_id = "550e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_id);
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
// 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");
// Soft-delete via the web handler.
let del = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/delete"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
del.status().as_u16() / 100,
3,
"delete 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,
"delete handler must change the per-user ETag"
);
}
#[tokio::test]
async fn undo_delete_bumps_watermark() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_id = "660e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_id);
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
// Delete first so undo has something to restore; grab the post-delete ETag.
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/delete"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let (_, etag_after_delete) = capture_etag(&app, "key-dr_a", None).await;
let etag_after_delete = etag_after_delete.expect("ETag must be set");
// Undo.
let undo = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/undo-delete")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(undo.status().as_u16() / 100, 3, "undo should redirect");
// Undo must bump again → post-delete ETag becomes stale.
let (s, etag_after_undo) = capture_etag(&app, "key-dr_a", Some(&etag_after_delete)).await;
assert_eq!(
s,
StatusCode::OK,
"undo must change the ETag so clients re-fetch"
);
let etag_after_undo = etag_after_undo.expect("200 response must set ETag");
assert_ne!(etag_after_undo, etag_after_delete);
}
#[tokio::test]
async fn bulk_delete_bumps_watermark() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_a = "770e8400-e29b-41d4-a716-446655440000";
let case_b = "880e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_a);
seed_case(&data_path, "dr_a", case_b);
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await;
let etag_before = etag_before.expect("ETag must be set");
// Form-encoded bulk delete: action=delete&case_id=a&case_id=b.
let form = format!("action=delete&case_id={case_a}&case_id={case_b}");
let bulk = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie)
.header(
header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.body(Body::from(form))
.unwrap(),
)
.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-delete must bump the ETag");
let etag_after = etag_after.expect("200 response must set ETag");
assert_ne!(etag_after, etag_before);
}