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.
This commit is contained in:
2026-04-18 09:27:23 +02:00
parent 6fbb549ddf
commit 94392e72d8
10 changed files with 658 additions and 16 deletions
Generated
+12
View File
@@ -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"
+1
View File
@@ -33,3 +33,4 @@ spellbook = "0.4.0"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
wiremock = "0.6"
filetime = "0.2"
+18
View File
@@ -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<RwLock<HashMap<String, OffsetDateTime>>>;
/// 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<AppState> for Arc<Config> {
@@ -98,6 +109,12 @@ impl FromRef<AppState> for TranscribeBusy {
}
}
impl FromRef<AppState> 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<Config>) -> 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())),
})
}
+15 -2
View File
@@ -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);
+2
View File
@@ -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<AppState> {
.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))
+208
View File
@@ -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/"<unix>-<hours>"`
//! 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<u32>,
}
#[derive(Serialize)]
struct OnelinersResponse {
as_of: String,
window_hours: u32,
oneliners: Vec<OnelinerEntry>,
}
#[derive(Serialize)]
struct OnelinerEntry {
case_id: String,
oneliner: Option<String>,
created_at: String,
updated_at: Option<String>,
}
pub async fn handle_oneliners(
user: AuthenticatedUser,
State(watermark): State<OnelinerWatermark>,
Query(params): Query<OnelinersQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
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<OffsetDateTime>, 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 `<user_data_dir>/<case_id>/` 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<OnelinerEntry> {
let mut entries: Vec<OnelinerEntry> = 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<SystemTime> {
let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
let mut earliest: Option<SystemTime> = 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<String>, Option<OffsetDateTime>) {
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)
}
+17 -10
View File
@@ -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<PathBuf> {
let mut out: Vec<PathBuf> = 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<PathBuf> {
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<PathBuf> {
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]
+12 -2
View File
@@ -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<Gazetteer>,
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) => {
+365
View File
@@ -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<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);
}
+8 -2
View File
@@ -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");