a1ff410d1b
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.
421 lines
12 KiB
Rust
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);
|
|
}
|