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.
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -128,7 +128,8 @@ async fn empty_user_dir_returns_200_empty_list() {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let etag = header_str(&response, "etag");
|
||||
assert!(etag.starts_with("W/\"0-16\""), "etag was {etag:?}");
|
||||
assert!(etag.starts_with("W/\""), "etag was {etag:?}");
|
||||
assert!(etag.ends_with("-16\""), "etag was {etag:?}");
|
||||
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["window_hours"], 16);
|
||||
|
||||
@@ -288,10 +288,7 @@ async fn worker_renames_audio_to_failed_on_whisper_error() {
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
let watermark: doctate_server::OnelinerWatermark = std::sync::Arc::new(
|
||||
tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||
);
|
||||
transcribe::worker::run(rx, config, client, busy, vocab, watermark).await;
|
||||
transcribe::worker::run(rx, config, client, busy, vocab).await;
|
||||
|
||||
// Audio must have been renamed so recovery skips it next time.
|
||||
assert!(!audio.exists(), "original .m4a still present");
|
||||
@@ -340,10 +337,7 @@ async fn transcribe_worker_normalizes_whisper_output() {
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let watermark: doctate_server::OnelinerWatermark = std::sync::Arc::new(
|
||||
tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||
);
|
||||
transcribe::worker::run(rx, config, client, busy, vocab, watermark).await;
|
||||
transcribe::worker::run(rx, config, client, busy, vocab).await;
|
||||
|
||||
let transcript_path = case_dir.join("2026-04-13T10-30-00Z.transcript.txt");
|
||||
assert!(transcript_path.exists(), "transcript missing");
|
||||
|
||||
@@ -284,7 +284,9 @@ async fn upload_reopens_soft_deleted_case() {
|
||||
|
||||
// Soft-delete the case by writing the marker directly — simulates
|
||||
// the user hitting the delete button in the web UI without having
|
||||
// to drive the /web/cases/{id}/delete handler here.
|
||||
// to drive the /web/cases/{id}/delete handler here. Direct write
|
||||
// also means the watermark is NOT bumped here; that way the next
|
||||
// GET /api/oneliners pins down the pre-reopen ETag cleanly.
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
let marker = DeleteMarker {
|
||||
batch: uuid::Uuid::new_v4(),
|
||||
@@ -293,9 +295,32 @@ async fn upload_reopens_soft_deleted_case() {
|
||||
write_delete_marker(&case_dir, &marker).await.unwrap();
|
||||
assert!(case_dir.join(DELETE_MARKER).exists());
|
||||
|
||||
// Capture the ETag before the reopen.
|
||||
let etag_before = {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/oneliners")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
resp.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.expect("server must set ETag")
|
||||
};
|
||||
|
||||
// Second upload — must auto-reopen.
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
@@ -321,6 +346,34 @@ async fn upload_reopens_soft_deleted_case() {
|
||||
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
||||
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
|
||||
|
||||
// Auto-reopen must bump the watermark: sending the pre-reopen
|
||||
// If-None-Match must return 200 (not 304) so clients who were
|
||||
// offline during the reopen notice it on their next poll.
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/oneliners")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header("If-None-Match", &etag_before)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"auto-reopen must invalidate the pre-reopen ETag"
|
||||
);
|
||||
let etag_after = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.expect("server must set ETag");
|
||||
assert_ne!(etag_after, etag_before);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user