Files
doctate/server/tests/oneliners_api_test.rs
T
Brummel 3218fc62bd feat: server-authoritative case window via users.toml
The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.
2026-04-21 16:48:01 +02:00

483 lines
15 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_common::oneliners::OnelinerState;
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(),
retention: Default::default(),
window_hours: 72,
}],
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
..Config::test_default()
});
(config, data_path)
}
fn test_config_with_window_hours(hours: u32) -> (Arc<Config>, PathBuf) {
let (cfg, dp) = test_config();
// Safe because `test_config` just created the Arc and handed it
// back with refcount == 1.
let mut cfg =
Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique"));
cfg.users[0].window_hours = hours;
(Arc::new(cfg), dp)
}
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 {
write_ready_state(&case_dir, text).await;
}
case_dir
}
async fn write_ready_state(case_dir: &Path, text: &str) {
let state = OnelinerState::Ready {
text: text.to_owned(),
generated_at: "2026-04-18T10:00:00Z".into(),
};
let bytes = serde_json::to_vec(&state).unwrap();
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
.await
.unwrap();
}
async fn write_state(case_dir: &Path, state: &OnelinerState) {
let bytes = serde_json::to_vec(state).unwrap();
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
.await
.unwrap();
}
async fn mark_deleted(case_dir: &Path) {
tokio::fs::write(case_dir.join(".closed"), "{}")
.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("-72\""), "etag was {etag:?}");
let body = body_json(response).await;
assert_eq!(body["window_hours"], 72);
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"]["kind"], "ready");
assert_eq!(arr[0]["oneliner"]["text"], "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(80 * 3600), // 80 h old — outside the 72 h default
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);
}
/// Demonstrates that the visibility decision lives in users.toml:
/// the same 40 h-old case is hidden under window_hours = 16 and
/// visible under window_hours = 48.
#[tokio::test]
async fn case_visibility_follows_user_window_hours() {
let case_id = "550e8400-e29b-41d4-a716-000000000004";
let age = Duration::from_secs(40 * 3600);
let (config_small, dp_small) = test_config_with_window_hours(16);
seed_case(&dp_small, case_id, age, Some("40h case")).await;
let app_small = doctate_server::create_router(config_small);
let body = body_json(app_small.oneshot(get("/api/oneliners")).await.unwrap()).await;
assert_eq!(body["window_hours"], 16);
assert!(body["oneliners"].as_array().unwrap().is_empty());
let (config_wide, dp_wide) = test_config_with_window_hours(48);
seed_case(&dp_wide, case_id, age, Some("40h case")).await;
let app_wide = doctate_server::create_router(config_wide);
let body = body_json(app_wide.oneshot(get("/api/oneliners")).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_small);
let _ = std::fs::remove_dir_all(&dp_wide);
}
/// Regression guard: a lingering `?hours=N` from an old client is
/// silently ignored — the user-config value drives the response.
#[tokio::test]
async fn hours_query_param_is_ignored() {
let (config, dp) = test_config();
let app = doctate_server::create_router(config);
let body = body_json(app.oneshot(get("/api/oneliners?hours=999")).await.unwrap()).await;
assert_eq!(body["window_hours"], 72);
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-72\"",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let _ = std::fs::remove_dir_all(&dp);
}
/// Two users with different `window_hours` must produce different
/// ETag suffixes, even on empty data — so an admin's edit to
/// users.toml invalidates client caches after the next restart.
#[tokio::test]
async fn etag_differs_between_users_with_different_window_hours() {
let (config_a, dp_a) = test_config_with_window_hours(16);
let (config_b, dp_b) = test_config_with_window_hours(48);
let app_a = doctate_server::create_router(config_a);
let app_b = doctate_server::create_router(config_b);
let r_a = app_a.oneshot(get("/api/oneliners")).await.unwrap();
let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap();
let etag_a = header_str(&r_a, "etag");
let etag_b = header_str(&r_b, "etag");
assert_ne!(etag_a, etag_b);
assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}");
assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}");
let _ = std::fs::remove_dir_all(&dp_a);
let _ = std::fs::remove_dir_all(&dp_b);
}
#[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"]["text"], "newer");
assert_eq!(arr[1]["oneliner"]["text"], "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();
write_ready_state(&dir_a, "A (fresh addendum)").await;
// 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")).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);
}
#[tokio::test]
async fn empty_state_serializes_as_kind_empty() {
let (config, dp) = test_config();
let case_dir = seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000030",
Duration::from_secs(60),
None,
)
.await;
write_state(
&case_dir,
&OnelinerState::Empty {
generated_at: "2026-04-18T10:00:00Z".into(),
},
)
.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_eq!(arr[0]["oneliner"]["kind"], "empty");
assert!(arr[0]["oneliner"].get("text").is_none());
let _ = std::fs::remove_dir_all(&dp);
}
#[tokio::test]
async fn error_state_serializes_as_kind_error() {
let (config, dp) = test_config();
let case_dir = seed_case(
&dp,
"550e8400-e29b-41d4-a716-000000000031",
Duration::from_secs(60),
None,
)
.await;
write_state(
&case_dir,
&OnelinerState::Error {
generated_at: "2026-04-18T10:00:00Z".into(),
},
)
.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_eq!(arr[0]["oneliner"]["kind"], "error");
let _ = std::fs::remove_dir_all(&dp);
}