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:
Generated
+12
@@ -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"
|
||||
|
||||
@@ -33,3 +33,4 @@ spellbook = "0.4.0"
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
wiremock = "0.6"
|
||||
filetime = "0.2"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user