3218fc62bd
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.
483 lines
15 KiB
Rust
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);
|
|
}
|