Files
doctate/server/tests/oneliners_api_test.rs
T
Brummel 94392e72d8 feat: Add API endpoint for listing oneliners
This commit introduces a new API endpoint `/api/oneliners` that allows
authenticated users to retrieve a list of their recent oneliners.

The endpoint supports conditional GET requests using the `ETag` header
for efficient caching. It also allows specifying a time window for the
oneliners via the `hours` query parameter, with sensible defaults and
clamping.

The implementation includes:
- A new route handler `handle_oneliners`.
- A new type `OnelinerWatermark` for tracking user-specific timestamps.
- Logic for scanning user data directories, filtering by time, and
  handling deleted cases.
- Test cases to cover various scenarios, including caching, time
  windowing, and edge cases.
2026-04-18 09:27:23 +02:00

366 lines
10 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/\"0-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);
}