cdfa5ae90e
Introduce `last_recording_at` to `OnelinerEntry` and use it as the primary sort key for cases. This ensures that cases with recent dictation activity are prioritized, even if their initial creation date is older. The logic for determining a case's activity has been updated to consider the most recent `.m4a` file's modification time (`last_recording_at`), falling back to `updated_at` or `created_at` if necessary. This change also refactors the timestamp formatting and handling within the web interface to correctly display and sort cases based on their actual last activity time, improving user experience and data relevance. The `now_rfc3339` function is updated to strip sub-second precision for consistent filename generation.
420 lines
12 KiB
Rust
420 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/\"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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|