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:
2026-04-19 14:47:30 +02:00
parent 5e8d2f8e58
commit a1ff410d1b
11 changed files with 425 additions and 80 deletions
+54 -1
View File
@@ -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);
}