diff --git a/Cargo.lock b/Cargo.lock index 016eccc..5832534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1187,6 +1187,7 @@ dependencies = [ "bcrypt", "doctate-common", "dotenvy", + "filetime", "pulldown-cmark", "rand 0.8.6", "reqwest", @@ -1488,6 +1489,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/server/Cargo.toml b/server/Cargo.toml index 54fb013..fb391c4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -33,3 +33,4 @@ spellbook = "0.4.0" [dev-dependencies] tower = { version = "0.5", features = ["util"] } wiremock = "0.6" +filetime = "0.2" diff --git a/server/src/lib.rs b/server/src/lib.rs index 4ca8123..1a615fc 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -9,17 +9,27 @@ pub mod routes; pub mod transcribe; pub mod web_session; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use axum::extract::FromRef; use axum::Router; +use time::OffsetDateTime; +use tokio::sync::RwLock; use analyze::AnalyzeSender; use config::Config; use transcribe::TranscribeSender; use web_session::SessionStore; +/// Per-user UTC timestamp of the last successful oneliner write. +/// Read on every `/api/oneliners` request to build the ETag; written +/// from the transcribe worker and the boot recovery scan. Missing entry +/// → the user has no known oneliner activity yet; the endpoint returns +/// a full response and sets an ETag based on a `0` seed. +pub type OnelinerWatermark = Arc>>; + /// Live "is this worker currently processing a job?" flag. /// Set true before each job, false after. UI uses it to distinguish a real /// in-flight job from a stale on-disk marker (orphan input, missing @@ -60,6 +70,7 @@ pub struct AppState { pub session_store: SessionStore, pub analyze_busy: AnalyzeBusy, pub transcribe_busy: TranscribeBusy, + pub oneliner_watermark: OnelinerWatermark, } impl FromRef for Arc { @@ -98,6 +109,12 @@ impl FromRef for TranscribeBusy { } } +impl FromRef for OnelinerWatermark { + fn from_ref(state: &AppState) -> Self { + state.oneliner_watermark.clone() + } +} + /// Test/simple entrypoint: jobs pushed into either channel are dropped /// because the receivers are not retained. Use [`create_router_with_state`] /// from `main.rs` where real workers own the receivers. @@ -111,6 +128,7 @@ pub fn create_router(config: Arc) -> Router { session_store: web_session::new_store(), analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), + oneliner_watermark: Arc::new(RwLock::new(HashMap::new())), }) } diff --git a/server/src/main.rs b/server/src/main.rs index 20fca87..74437ac 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -12,7 +12,7 @@ use doctate_server::analyze; use doctate_server::config::Config; use doctate_server::gazetteer::{Gazetteer, SpellbookDict}; use doctate_server::transcribe; -use doctate_server::AppState; +use doctate_server::{AppState, OnelinerWatermark}; #[tokio::main] async fn main() { @@ -110,6 +110,13 @@ async fn main() { } }); + // Oneliner watermark: per-user "last successful oneliner write (UTC)". + // Feeds the ETag on `/api/oneliners`. Shared across the transcribe + // worker (writer) and the oneliners route handler (reader). + let oneliner_watermark: OnelinerWatermark = Arc::new(tokio::sync::RwLock::new( + std::collections::HashMap::new(), + )); + // Transcription pipeline: channel + worker + recovery scan. let (transcribe_tx, transcribe_rx) = transcribe::channel(); let transcribe_busy: doctate_server::WorkerBusy = @@ -120,6 +127,7 @@ async fn main() { http_client.clone(), transcribe_busy.clone(), vocab.clone(), + oneliner_watermark.clone(), )); { let tx = transcribe_tx.clone(); @@ -127,11 +135,15 @@ async fn main() { let client = http_client.clone(); let cfg = config.clone(); let vocab = vocab.clone(); + let watermark = oneliner_watermark.clone(); tokio::spawn(async move { transcribe::recovery::scan_and_enqueue(&data_path, &tx).await; // Then catch up oneliners for cases that crashed between // transcript-write and oneliner-write in a previous run. - transcribe::recovery::regenerate_missing_oneliners(&data_path, &client, &cfg, &vocab).await; + transcribe::recovery::regenerate_missing_oneliners( + &data_path, &client, &cfg, &vocab, &watermark, + ) + .await; }); } @@ -161,6 +173,7 @@ async fn main() { session_store: doctate_server::web_session::new_store(), analyze_busy: doctate_server::AnalyzeBusy(analyze_busy), transcribe_busy: doctate_server::TranscribeBusy(transcribe_busy), + oneliner_watermark, }; let addr = format!("0.0.0.0:{}", config.server_port); diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 2d7a848..676ac99 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -3,6 +3,7 @@ mod case_actions; mod debug; mod health; mod login; +mod oneliners; mod upload; pub(crate) mod user_web; pub(crate) mod web; @@ -17,6 +18,7 @@ pub fn api_router() -> Router { .route("/api/health", get(health::handle_health)) .route("/api/debug/whoami", get(debug::handle_whoami)) .route("/api/upload", post(upload::handle_upload)) + .route("/api/oneliners", get(oneliners::handle_oneliners)) .route("/web/login", get(login::handle_login_page).post(login::handle_login_submit)) .route("/web/logout", post(login::handle_logout)) .route("/web/cases", get(user_web::handle_my_cases)) diff --git a/server/src/routes/oneliners.rs b/server/src/routes/oneliners.rs new file mode 100644 index 0000000..d911106 --- /dev/null +++ b/server/src/routes/oneliners.rs @@ -0,0 +1,208 @@ +//! GET /api/oneliners — list oneliners for the authenticated user. +//! +//! Time window is caller-controlled via `?hours=N` (default 16, clamped +//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner +//! text (or null if not yet generated), and UTC timestamps for when the +//! case started and when its oneliner was last written. +//! +//! Conditional-GET: the response carries a weak ETag `W/"-"` +//! derived from the per-user watermark. Clients that send back a matching +//! `If-None-Match` get `304 Not Modified` without touching the filesystem. +//! The `hours` component is included so a client swapping window sizes +//! does not get a stale 304. + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use axum::extract::{Query, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; +use tracing::warn; + +use crate::auth::AuthenticatedUser; +use crate::error::AppError; +use crate::OnelinerWatermark; + +const DEFAULT_HOURS: u32 = 16; +const MAX_HOURS: u32 = 168; + +#[derive(Deserialize)] +pub struct OnelinersQuery { + hours: Option, +} + +#[derive(Serialize)] +struct OnelinersResponse { + as_of: String, + window_hours: u32, + oneliners: Vec, +} + +#[derive(Serialize)] +struct OnelinerEntry { + case_id: String, + oneliner: Option, + created_at: String, + updated_at: Option, +} + +pub async fn handle_oneliners( + user: AuthenticatedUser, + State(watermark): State, + Query(params): Query, + headers: HeaderMap, +) -> Result { + let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS); + let now = OffsetDateTime::now_utc(); + let since = now - Duration::hours(hours as i64); + + let current_watermark = watermark.read().await.get(&user.slug).copied(); + let etag = build_etag(current_watermark, hours); + + if client_has_matching_etag(&headers, &etag) { + return Ok(not_modified(&etag)); + } + + let oneliners = enumerate_recent_oneliners(&user.data_dir, since).await; + let body = OnelinersResponse { + as_of: now.format(&Rfc3339).unwrap_or_default(), + window_hours: hours, + oneliners, + }; + + let mut response = Json(body).into_response(); + if let Ok(value) = etag.parse() { + response.headers_mut().insert(header::ETAG, value); + } + Ok(response) +} + +fn build_etag(watermark: Option, hours: u32) -> String { + let seconds = watermark.map(|t| t.unix_timestamp()).unwrap_or(0); + format!("W/\"{seconds}-{hours}\"") +} + +/// Case-sensitive match against the raw `If-None-Match` header value. +/// No wildcard or comma-split logic — the client is our own code sending +/// back the exact ETag we handed it. +fn client_has_matching_etag(headers: &HeaderMap, etag: &str) -> bool { + headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|sent| sent == etag) + .unwrap_or(false) +} + +fn not_modified(etag: &str) -> Response { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + if let Ok(value) = etag.parse() { + response.headers_mut().insert(header::ETAG, value); + } + response +} + +/// Scan `//` for cases whose earliest recording +/// sits inside the `[since, now]` window. Returns entries sorted by +/// `created_at` descending (newest first). Soft-deleted cases and +/// non-UUID directories are filtered out. +async fn enumerate_recent_oneliners( + user_data_dir: &Path, + since: OffsetDateTime, +) -> Vec { + let mut entries: Vec = Vec::new(); + let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else { + return entries; + }; + while let Ok(Some(case_entry)) = cases.next_entry().await { + let case_dir = case_entry.path(); + if !case_dir.is_dir() { + continue; + } + let Some(case_id) = case_dir.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if uuid::Uuid::parse_str(case_id).is_err() { + continue; + } + if crate::paths::is_deleted(&case_dir).await { + continue; + } + + let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else { + continue; + }; + let created_at = OffsetDateTime::from(created_mtime); + if created_at < since { + continue; + } + + let (oneliner_text, updated_at) = read_oneliner(&case_dir).await; + let Ok(created_at_str) = created_at.format(&Rfc3339) else { + warn!(case = %case_dir.display(), "format created_at failed"); + continue; + }; + let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok()); + + entries.push(OnelinerEntry { + case_id: case_id.to_owned(), + oneliner: oneliner_text, + created_at: created_at_str, + updated_at: updated_at_str, + }); + } + entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + entries +} + +/// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts — +/// a failed recording still marks the birth of the case. Returns None on +/// read errors or empty directories. +async fn earliest_m4a_mtime(case_dir: &Path) -> Option { + let mut files = tokio::fs::read_dir(case_dir).await.ok()?; + let mut earliest: Option = None; + while let Ok(Some(entry)) = files.next_entry().await { + let path: PathBuf = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) { + continue; + } + let Ok(meta) = entry.metadata().await else { + continue; + }; + let Ok(mtime) = meta.modified() else { + continue; + }; + earliest = match earliest { + None => Some(mtime), + Some(e) if mtime < e => Some(mtime), + other => other, + }; + } + earliest +} + +/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file +/// yields `(None, None)`. +async fn read_oneliner(case_dir: &Path) -> (Option, Option) { + let path = case_dir.join("oneliner.txt"); + let text = tokio::fs::read_to_string(&path) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if text.is_none() { + return (None, None); + } + let mtime = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|m| m.modified().ok()) + .map(OffsetDateTime::from); + (text, mtime) +} diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index bc0fc61..1e27998 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -5,6 +5,7 @@ use tracing::{info, warn}; use super::{TranscribeJob, TranscribeSender}; use crate::config::Config; use crate::gazetteer::Gazetteer; +use crate::OnelinerWatermark; /// Walk `data_path/*/` and enqueue every recording pending transcription for /// every user. Intended for startup; the same primitive backs the per-user @@ -83,11 +84,13 @@ pub async fn enqueue_pending_for_user( } /// Walk `data_path/*/*` and return every case directory that has at least -/// one non-empty `*.transcript.txt` but no `oneliner.txt`. Pure filesystem -/// scan, no LLM calls — separated from the regeneration wrapper so it can -/// be unit-tested without mocking Ollama. -pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec { - let mut out: Vec = Vec::new(); +/// one non-empty `*.transcript.txt` but no `oneliner.txt`, paired with the +/// user-slug (parent directory name). Pure filesystem scan, no LLM calls — +/// separated from the regeneration wrapper so it can be unit-tested without +/// mocking Ollama. The slug is carried through so the caller can bump the +/// per-user watermark when regeneration succeeds. +pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> { + let mut out: Vec<(PathBuf, String)> = Vec::new(); let Ok(mut users) = tokio::fs::read_dir(data_path).await else { return out; }; @@ -96,6 +99,9 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec { if !user_path.is_dir() { continue; } + let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else { + continue; + }; let Ok(mut cases) = tokio::fs::read_dir(&user_path).await else { continue; }; @@ -108,11 +114,11 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec { continue; } if has_non_empty_transcript(&case_dir).await { - out.push(case_dir); + out.push((case_dir, slug.clone())); } } } - out.sort(); + out.sort_by(|a, b| a.0.cmp(&b.0)); out } @@ -146,14 +152,15 @@ pub async fn regenerate_missing_oneliners( client: &reqwest::Client, config: &Config, vocab: &Gazetteer, + watermark: &OnelinerWatermark, ) { let cases = cases_needing_oneliner(data_path).await; if cases.is_empty() { return; } info!(count = cases.len(), "Regenerating missing oneliners"); - for case_dir in cases { - super::worker::update_oneliner(&case_dir, client, config, vocab).await; + for (case_dir, slug) in cases { + super::worker::update_oneliner(&case_dir, &slug, client, config, vocab, watermark).await; } } @@ -172,7 +179,7 @@ mod tests { .unwrap(); let got = cases_needing_oneliner(data.path()).await; - assert_eq!(got, vec![case]); + assert_eq!(got, vec![(case, "user".to_owned())]); } #[tokio::test] diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 2f5e28d..d42a5c9 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -2,12 +2,13 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use time::OffsetDateTime; use tracing::{error, info, warn}; use super::{ffmpeg, ollama, whisper, TranscribeReceiver}; use crate::config::{Config, WhisperUserSettings}; use crate::gazetteer::Gazetteer; -use crate::{BusyGuard, WorkerBusy}; +use crate::{BusyGuard, OnelinerWatermark, WorkerBusy}; const ONELINER_TIMEOUT: Duration = Duration::from_secs(60); @@ -19,6 +20,7 @@ pub async fn run( client: reqwest::Client, worker_busy: WorkerBusy, vocab: Arc, + watermark: OnelinerWatermark, ) { info!("Transcription worker started"); let timeout = Duration::from_secs(config.whisper_timeout_seconds); @@ -88,7 +90,7 @@ pub async fn run( if let Some(case_dir) = audio_path.parent() && !has_pending_recordings(case_dir).await { - update_oneliner(case_dir, &client, &config, &vocab).await; + update_oneliner(case_dir, &job.user_slug, &client, &config, &vocab, &watermark).await; } } @@ -127,9 +129,11 @@ async fn mark_failed(audio_path: &Path) { /// no-op — next transcript write retries. pub(crate) async fn update_oneliner( case_dir: &Path, + user_slug: &str, client: &reqwest::Client, config: &Config, vocab: &Gazetteer, + watermark: &OnelinerWatermark, ) { let path = case_dir.join("oneliner.txt"); @@ -154,6 +158,12 @@ pub(crate) async fn update_oneliner( error!(path = %path.display(), error = %e, "writing oneliner failed"); } else { info!(path = %path.display(), chars = line.chars().count(), "Oneliner updated"); + // Bump the per-user watermark so the next `/api/oneliners` + // poll sees a fresh ETag and fetches the new content. + watermark + .write() + .await + .insert(user_slug.to_string(), OffsetDateTime::now_utc()); } } Err(e) => { diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs new file mode 100644 index 0000000..339540a --- /dev/null +++ b/server/tests/oneliners_api_test.rs @@ -0,0 +1,365 @@ +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, 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 { + 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 { + 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, +) -> 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, 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); +} diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 03273a5..3e7d37d 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -288,7 +288,10 @@ async fn worker_renames_audio_to_failed_on_whisper_error() { let client = reqwest::Client::new(); let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty()); - transcribe::worker::run(rx, config, client, busy, vocab).await; + let watermark: doctate_server::OnelinerWatermark = std::sync::Arc::new( + tokio::sync::RwLock::new(std::collections::HashMap::new()), + ); + transcribe::worker::run(rx, config, client, busy, vocab, watermark).await; // Audio must have been renamed so recovery skips it next time. assert!(!audio.exists(), "original .m4a still present"); @@ -337,7 +340,10 @@ async fn transcribe_worker_normalizes_whisper_output() { let client = reqwest::Client::new(); let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - transcribe::worker::run(rx, config, client, busy, vocab).await; + let watermark: doctate_server::OnelinerWatermark = std::sync::Arc::new( + tokio::sync::RwLock::new(std::collections::HashMap::new()), + ); + transcribe::worker::run(rx, config, client, busy, vocab, watermark).await; let transcript_path = case_dir.join("2026-04-13T10-30-00Z.transcript.txt"); assert!(transcript_path.exists(), "transcript missing");