Files
doctate/server/tests/oneliners_api_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

421 lines
12 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use filetime::FileTime;
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
const TEST_KEY: &str = "test-key-oneliners";
const TEST_SLUG: &str = "dr_oneliners";
fn test_config() -> (Arc<Config>, PathBuf) {
let data_path = std::env::temp_dir().join(format!(
"doctate-oneliners-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let config = Arc::new(Config {
data_path: data_path.clone(),
users: vec![User {
slug: TEST_SLUG.into(),
api_key: TEST_KEY.into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
}],
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
..Config::test_default()
});
(config, data_path)
}
async fn seed_case(
user_root: &Path,
case_id: &str,
m4a_age: Duration,
oneliner_text: Option<&str>,
) -> PathBuf {
let case_dir = user_root.join(TEST_SLUG).join(case_id);
tokio::fs::create_dir_all(&case_dir).await.unwrap();
// Write a dummy .m4a and rewind its mtime to simulate "case born N ago".
let m4a = case_dir.join("2026-04-18T10-00-00Z.m4a");
tokio::fs::write(&m4a, b"x").await.unwrap();
let target_mtime = SystemTime::now() - m4a_age;
filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap();
if let Some(text) = oneliner_text {
tokio::fs::write(case_dir.join("oneliner.txt"), text)
.await
.unwrap();
}
case_dir
}
async fn mark_deleted(case_dir: &Path) {
tokio::fs::write(case_dir.join(".deleted"), "{}")
.await
.unwrap();
}
fn get(uri: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri)
.header("X-API-Key", TEST_KEY)
.body(Body::empty())
.unwrap()
}
fn get_with_if_none_match(uri: &str, etag: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri)
.header("X-API-Key", TEST_KEY)
.header("If-None-Match", etag)
.body(Body::empty())
.unwrap()
}
async fn body_json(
response: axum::http::Response<axum::body::Body>,
) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn header_str(response: &axum::http::Response<axum::body::Body>, name: &str) -> String {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned()
}
#[tokio::test]
async fn returns_401_without_api_key() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/oneliners")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_user_dir_returns_200_empty_list() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let response = app.oneshot(get("/api/oneliners")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let etag = header_str(&response, "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);
assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn recent_case_with_oneliner_shows_up() {
let (config, dp) = test_config();
let case_id = "550e8400-e29b-41d4-a716-000000000001";
seed_case(&dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus")).await;
let app = doctate_server::create_router(config);
let response = app.oneshot(get("/api/oneliners")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["case_id"], case_id);
assert_eq!(arr[0]["oneliner"], "Kniegelenk re., V.a. Meniskus");
assert!(arr[0]["created_at"].is_string());
assert!(arr[0]["updated_at"].is_string());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn case_without_oneliner_shows_null() {
let (config, dp) = test_config();
seed_case(&dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None).await;
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["oneliner"].is_null());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn case_older_than_default_window_excluded() {
let (config, dp) = test_config();
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000003",
Duration::from_secs(20 * 3600), // 20 h old
Some("old case"),
)
.await;
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn case_outside_default_but_inside_custom_window_included() {
let (config, dp) = test_config();
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000004",
Duration::from_secs(20 * 3600),
Some("yesterday case"),
)
.await;
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 48);
assert_eq!(body["oneliners"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn hours_clamped_to_max() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=999999")).await.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 168);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn hours_zero_clamped_to_one() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners?hours=0")).await.unwrap()).await;
assert_eq!(body["window_hours"], 1);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn matching_if_none_match_returns_304() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let first = app.clone().oneshot(get("/api/oneliners")).await.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let etag = header_str(&first, "etag");
assert!(!etag.is_empty());
let second = app
.oneshot(get_with_if_none_match("/api/oneliners", &etag))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(header_str(&second, "etag"), etag);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn stale_if_none_match_returns_200() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(get_with_if_none_match(
"/api/oneliners",
"W/\"99999999-16\"",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn etag_differs_between_different_hours() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let r1 = app
.clone()
.oneshot(get("/api/oneliners?hours=1"))
.await
.unwrap();
let r16 = app
.oneshot(get("/api/oneliners?hours=16"))
.await
.unwrap();
let etag1 = header_str(&r1, "etag");
let etag16 = header_str(&r16, "etag");
assert_ne!(etag1, etag16);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn etag_for_one_hour_does_not_match_sixteen_hour_request() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let r1 = app
.clone()
.oneshot(get("/api/oneliners?hours=1"))
.await
.unwrap();
let etag1 = header_str(&r1, "etag");
// Send the hours=1 ETag against an hours=16 request — must NOT 304.
let r16 = app
.oneshot(get_with_if_none_match("/api/oneliners?hours=16", &etag1))
.await
.unwrap();
assert_eq!(r16.status(), StatusCode::OK);
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn deleted_case_is_excluded() {
let (config, dp) = test_config();
let case_dir = seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000005",
Duration::from_secs(60),
Some("deleted case"),
)
.await;
mark_deleted(&case_dir).await;
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
assert!(body["oneliners"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn multiple_cases_sorted_newest_first() {
let (config, dp) = test_config();
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000010",
Duration::from_secs(2 * 3600),
Some("older"),
)
.await;
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000011",
Duration::from_secs(30),
Some("newer"),
)
.await;
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["oneliner"], "newer");
assert_eq!(arr[1]["oneliner"], "older");
let _ = std::fs::remove_dir_all(&dp);
}
/// Regression guard: if a case was first created long ago but got a
/// follow-up recording today, it must sort ahead of a case whose single
/// (older) recording happens to be newer than the first one. Sorting
/// must use `last_recording_at`, not `created_at`.
#[tokio::test]
async fn sorts_by_last_recording_not_created() {
let (config, dp) = test_config();
// Case A: first recording 20h ago (old created_at), second 1 min ago
// (new last_recording_at).
let case_a = "550e8400-e29b-41d4-a716-000000000020";
let dir_a = dp.join(TEST_SLUG).join(case_a);
tokio::fs::create_dir_all(&dir_a).await.unwrap();
let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a");
tokio::fs::write(&old_m4a, b"x").await.unwrap();
filetime::set_file_mtime(
&old_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)),
)
.unwrap();
let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a");
tokio::fs::write(&fresh_m4a, b"x").await.unwrap();
filetime::set_file_mtime(
&fresh_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
)
.unwrap();
tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)")
.await
.unwrap();
// Case B: single recording 10h ago — created_at newer than A's, but
// last_recording_at older than A's fresh addendum.
seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000021",
Duration::from_secs(10 * 3600),
Some("B (single, middle-aged)"),
)
.await;
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["case_id"], case_a, "A must sort first");
assert!(arr[0]["last_recording_at"].is_string());
let _ = std::fs::remove_dir_all(&dp);
}