From 3218fc62bd676e10ad2bdf423cd60285f7b6d4bd Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 21 Apr 2026 16:48:01 +0200 Subject: [PATCH] feat: server-authoritative case window via users.toml The /api/oneliners time window is now read per-user from users.toml (window_hours, default 72h). Clients no longer carry a window: client.toml oneliner_window_hours, SyncConfig.window_hours, the ?hours=N query param, and the render_case_list cutoff filter are gone. ETag suffix keeps the effective hours so an admin edit to users.toml invalidates client caches on the next request. OnelinersResponse.window_hours stays in the wire format, but now exists solely to anchor client reconciliation. --- client-desktop/src/app.rs | 17 +-- docs/projektplan.md | 5 +- doctate-client-core/src/case_store.rs | 7 + doctate-client-core/src/config.rs | 19 --- doctate-client-core/src/lib.rs | 4 +- doctate-client-core/src/server_sync.rs | 6 +- doctate-common/src/oneliners.rs | 8 +- server/src/auth.rs | 18 +++ server/src/config.rs | 44 ++++++ server/src/routes/oneliners.rs | 31 ++--- server/tests/analyze_test.rs | 1 + server/tests/auth_test.rs | 1 + server/tests/case_page_test.rs | 1 + server/tests/close_watermark_test.rs | 1 + server/tests/delete_recording_test.rs | 1 + server/tests/login_test.rs | 1 + server/tests/magic_link_test.rs | 1 + server/tests/oneliners_api_test.rs | 127 ++++++++---------- server/tests/retention_sweep_test.rs | 1 + server/tests/sse_integration.rs | 1 + server/tests/transcribe_test.rs | 1 + .../tests/transient_failure_retries_test.rs | 1 + server/tests/upload_test.rs | 1 + server/tests/web_test.rs | 1 + 24 files changed, 167 insertions(+), 132 deletions(-) diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 991dde7..d8337c4 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -19,9 +19,7 @@ use doctate_client_core::{ pick_footer_status, run_startup_cleanup, write_sidecar, }; use doctate_common::oneliners::OnelinerState; -use doctate_common::timestamp::{ - extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem, -}; +use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; use tokio::runtime::Runtime; use tokio::sync::{mpsc, watch}; @@ -158,7 +156,6 @@ impl DoctateApp { .config .as_ref() .and_then(|c| c.oneliner_poll_interval_seconds), - oneliner_window_hours: self.config.as_ref().and_then(|c| c.oneliner_window_hours), }; if let Err(e) = crate::config::save(&cfg) { error!(error = %e, "config save failed"); @@ -474,13 +471,9 @@ impl DoctateApp { fn render_case_list(&self, ui: &mut egui::Ui) -> Option { let snapshot = self.snapshot_rx.borrow().clone(); - let window = self.config.as_ref().map(Config::window_hours).unwrap_or(72); - let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64); - - let visible: Vec<&CaseMarker> = snapshot - .iter() - .filter(|m| parse_rfc3339(&m.last_activity_at).is_none_or(|t| t >= cutoff)) - .collect(); + // Snapshot is already server-filtered — the server's `window_hours` + // per user (users.toml) is the single source of truth. + let visible: Vec<&CaseMarker> = snapshot.iter().collect(); if visible.is_empty() { ui.add_space(8.0); @@ -634,12 +627,10 @@ fn spawn_worker( ) { let _guard = runtime.enter(); let poll_interval = config.poll_interval(); - let window_hours = config.window_hours(); let sync_config = SyncConfig { server_url: config.server_url, api_key: config.api_key, poll_interval, - window_hours, }; let (sync, events_rx) = ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir); diff --git a/docs/projektplan.md b/docs/projektplan.md index c2382f4..846d557 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -352,8 +352,9 @@ Axum ist der zentrale Koordinator. Er empfängt Uploads, startet die sequentiell POST /api/upload → Aufnahme empfangen, ACK zurück GET /api/health → Liveness-Probe für Deployments GET /api/debug/whoami → API-Key → slug (Entwicklungshilfe) -GET /api/oneliners?hours=N → kollektive Fall-/Oneliner-Liste (ETag-basiert, - default 16 h, max 168 h; Client-Polling) +GET /api/oneliners → kollektive Fall-/Oneliner-Liste (ETag-basiert, + Fensterweite via users.toml->window_hours + pro User, Default 72 h; Client-Polling) # API: Magic-Link (API-Key → Browser-Session ohne Passwort) POST /api/auth/magic-link → Einmal-Token ausstellen (X-API-Key, TTL 60 s, diff --git a/doctate-client-core/src/case_store.rs b/doctate-client-core/src/case_store.rs index 616a6cf..a83abde 100644 --- a/doctate-client-core/src/case_store.rs +++ b/doctate-client-core/src/case_store.rs @@ -236,6 +236,13 @@ impl CaseStore { /// Reconcile local markers against an authoritative server snapshot. /// + /// The window itself is **read from the server response** + /// (`OnelinersResponse.window_hours`). Clients no longer decide how + /// wide it is — the policy lives in the server's `users.toml` and + /// arrives unchanged in every response. This function's only job + /// is to interpret the window: within it, the server's silence + /// about a case means "deleted"; outside it, silence means nothing. + /// /// Removes exactly those markers that are all of: /// 1. `synced_to_server == true` (server has seen this case before), /// 2. `last_activity_at >= as_of - window_hours` (inside the window diff --git a/doctate-client-core/src/config.rs b/doctate-client-core/src/config.rs index 5ba32e0..58012e1 100644 --- a/doctate-client-core/src/config.rs +++ b/doctate-client-core/src/config.rs @@ -15,9 +15,6 @@ use thiserror::Error; /// Fallback polling cadence for `/api/oneliners` in seconds. Chosen to /// feel real-time without hammering the server. pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5; -/// Fallback window passed as `?hours=N` to `/api/oneliners`. 72 h so -/// Monday-morning shows Friday's unfinished work. -pub const DEFAULT_ONELINER_WINDOW_HOURS: u32 = 72; /// Client configuration loaded from a TOML file at a platform-specific /// path. All fields except the two durations are mandatory. @@ -31,10 +28,6 @@ pub struct Config { /// built-in default. #[serde(default, skip_serializing_if = "Option::is_none")] pub oneliner_poll_interval_seconds: Option, - /// Time window handed to the server as `?hours=N`. Omit in TOML to - /// use the built-in default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oneliner_window_hours: Option, } #[derive(Debug, Error)] @@ -103,12 +96,6 @@ impl Config { .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), ) } - - /// Window-hours parameter for `/api/oneliners`, with default. - pub fn window_hours(&self) -> u32 { - self.oneliner_window_hours - .unwrap_or(DEFAULT_ONELINER_WINDOW_HOURS) - } } #[cfg(test)] @@ -121,7 +108,6 @@ mod tests { server_url: "https://doctate.example.org".into(), api_key: "sk-test-12345".into(), oneliner_poll_interval_seconds: None, - oneliner_window_hours: None, } } @@ -166,7 +152,6 @@ mod tests { std::fs::write(&path, "server_url = \"http://x\"\napi_key = \"y\"\n").unwrap(); let cfg = Config::load_from(&path).unwrap().unwrap(); assert_eq!(cfg.poll_interval(), Duration::from_secs(5)); - assert_eq!(cfg.window_hours(), 72); } #[test] @@ -175,10 +160,8 @@ mod tests { server_url: "http://x".into(), api_key: "y".into(), oneliner_poll_interval_seconds: Some(10), - oneliner_window_hours: Some(24), }; assert_eq!(cfg.poll_interval(), Duration::from_secs(10)); - assert_eq!(cfg.window_hours(), 24); } #[test] @@ -189,11 +172,9 @@ mod tests { server_url: "http://x".into(), api_key: "y".into(), oneliner_poll_interval_seconds: Some(3), - oneliner_window_hours: Some(168), }; original.save_to(&path).unwrap(); let loaded = Config::load_from(&path).unwrap().unwrap(); assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3)); - assert_eq!(loaded.oneliner_window_hours, Some(168)); } } diff --git a/doctate-client-core/src/lib.rs b/doctate-client-core/src/lib.rs index 5dcaaf8..b55225b 100644 --- a/doctate-client-core/src/lib.rs +++ b/doctate-client-core/src/lib.rs @@ -23,9 +23,7 @@ pub mod startup; pub mod upload; pub use case_store::{CaseMarker, CaseStore, CaseStoreError}; -pub use config::{ - Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS, -}; +pub use config::{Config, ConfigError, DEFAULT_POLL_INTERVAL_SECONDS}; pub use footer_status::{FooterStatus, pick_footer_status}; pub use pending_cleanup::cleanup_orphan_audio; pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState}; diff --git a/doctate-client-core/src/server_sync.rs b/doctate-client-core/src/server_sync.rs index e3e3489..d79ce22 100644 --- a/doctate-client-core/src/server_sync.rs +++ b/doctate-client-core/src/server_sync.rs @@ -46,7 +46,6 @@ pub struct SyncConfig { pub server_url: String, pub api_key: String, pub poll_interval: Duration, - pub window_hours: u32, } /// Three-state observable: what the worker is currently doing. @@ -194,7 +193,6 @@ async fn run_loop( ) { info!( interval_s = config.poll_interval.as_secs(), - window_h = config.window_hours, "server sync worker started" ); @@ -409,10 +407,9 @@ pub(crate) async fn poll_once( etag: &mut Option, ) -> Result { let url = format!( - "{}{}?hours={}", + "{}{}", config.server_url.trim_end_matches('/'), ONELINERS_PATH, - config.window_hours ); let mut req = http.get(&url).header(API_KEY_HEADER, &config.api_key); @@ -502,7 +499,6 @@ mod tests { server_url: server.uri(), api_key: TEST_KEY.into(), poll_interval: Duration::from_millis(50), - window_hours: 72, } } diff --git a/doctate-common/src/oneliners.rs b/doctate-common/src/oneliners.rs index ad4005d..c9fd079 100644 --- a/doctate-common/src/oneliners.rs +++ b/doctate-common/src/oneliners.rs @@ -48,7 +48,13 @@ pub enum OnelinerState { pub struct OnelinersResponse { /// RFC3339 UTC timestamp at which the server built this response. pub as_of: String, - /// Effective window (after server-side clamp). + /// Server-authoritative window the response covers, in hours. + /// Derived from the user's `window_hours` entry in `users.toml`. + /// **Clients must not use this value to apply a display filter** — + /// the oneliners list is already exactly what the server wants shown. + /// The field exists solely so reconciliation logic can anchor its + /// "outside the window" rule (see + /// `doctate-client-core::CaseStore::reconcile_with_server_snapshot`). pub window_hours: u32, /// Oneliners of cases whose earliest recording sits inside the window, /// sorted newest-first by `created_at`. diff --git a/server/src/auth.rs b/server/src/auth.rs index 1ffb777..fe471be 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -22,6 +22,9 @@ pub struct AuthenticatedUser { pub slug: String, pub role: String, pub data_dir: PathBuf, + /// Copied from the matching entry in `users.toml` — the single + /// source of truth for how many hours of history clients see. + pub window_hours: u32, } impl AuthenticatedUser { @@ -61,6 +64,7 @@ where slug: slug.clone(), role: user.role.clone(), data_dir, + window_hours: user.window_hours, }) } } @@ -71,6 +75,9 @@ pub struct AuthenticatedWebUser { pub slug: String, pub role: String, pub data_dir: PathBuf, + /// Mirrors [`AuthenticatedUser::window_hours`] so the same policy + /// is available to web handlers without a second config lookup. + pub window_hours: u32, } impl AuthenticatedWebUser { @@ -124,10 +131,21 @@ where .ok_or_else(|| AppError::Redirect("/web/login".into()))?; let data_dir = config.data_path.join(&session.slug); + // Look up the TOML user record for the policy fields the session + // does not carry. A missing record can only happen if users.toml + // was edited while a session was still live; fall back to the + // serde default so the request stays functional. + let window_hours = config + .users + .iter() + .find(|u| u.slug == session.slug) + .map(|u| u.window_hours) + .unwrap_or(72); Ok(Self { slug: session.slug.clone(), role: session.role.clone(), data_dir, + window_hours, }) } } diff --git a/server/src/config.rs b/server/src/config.rs index 9327072..dd2f6b5 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -26,6 +26,13 @@ pub struct RetentionUserSettings { pub auto_delete_days: u32, } +/// Default time window (hours) of visible cases when the user entry +/// omits `window_hours`. Chosen so Monday morning still shows Friday's +/// unfinished work. Server-authoritative: clients never override this. +fn default_window_hours() -> u32 { + 72 +} + /// A single user entry from users.toml. #[derive(Debug, Deserialize)] pub struct User { @@ -37,6 +44,13 @@ pub struct User { pub whisper: WhisperUserSettings, #[serde(default)] pub retention: RetentionUserSettings, + /// Time window (hours) that defines which cases are visible to this + /// user's clients. Server-authoritative: the `/api/oneliners` scan + /// filters recordings older than `now - window_hours` before they + /// reach any client. Clients render what the server returns and + /// apply no additional time filter. + #[serde(default = "default_window_hours")] + pub window_hours: u32, } /// Wrapper for deserializing the [[user]] array from TOML. @@ -275,6 +289,7 @@ mod tests { role: "doctor".into(), whisper: WhisperUserSettings::default(), retention: RetentionUserSettings::default(), + window_hours: default_window_hours(), } } @@ -402,4 +417,33 @@ mod tests { let err = validate_and_index_users(&users).unwrap_err(); assert!(err.contains("duplicate slug"), "got: {err}"); } + + #[test] + fn parses_user_without_window_hours_defaults_to_72() { + let toml_str = r#" + [[user]] + slug = "a" + api_key = "k1" + web_password = "" + role = "doctor" + "#; + let parsed: UsersFile = toml::from_str(toml_str).unwrap(); + // Missing field falls back to the serde default so existing + // users.toml files continue to work unchanged. + assert_eq!(parsed.user[0].window_hours, 72); + } + + #[test] + fn parses_user_with_explicit_window_hours() { + let toml_str = r#" + [[user]] + slug = "a" + api_key = "k1" + web_password = "" + role = "doctor" + window_hours = 168 + "#; + let parsed: UsersFile = toml::from_str(toml_str).unwrap(); + assert_eq!(parsed.user[0].window_hours, 168); + } } diff --git a/server/src/routes/oneliners.rs b/server/src/routes/oneliners.rs index 270446c..6b43924 100644 --- a/server/src/routes/oneliners.rs +++ b/server/src/routes/oneliners.rs @@ -1,10 +1,13 @@ //! 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 -//! state (or null if no state file has been written yet), and UTC -//! timestamps for when the case started and when its oneliner was last -//! written. +//! The time window is **server-authoritative**: it is read from the +//! user's `users.toml` entry (`window_hours`) and surfaced via the +//! [`AuthenticatedUser`] extractor. Clients no longer control it — +//! any `?hours=N` on the request URL is ignored. +//! +//! Each entry carries the case-id, the oneliner state (or null if no +//! state file has been written yet), and UTC timestamps for when the +//! case started and when its oneliner was last written. //! //! Conditional-GET: the response carries a weak ETag //! `W/"-"`. The fingerprint is a deterministic @@ -15,18 +18,17 @@ //! shifts at least one tuple and the hash moves. Server restart is a //! no-op: same FS, same hash. //! -//! The `hours` component is included so a client swapping window sizes -//! does not get a stale 304. +//! The `hours` component of the ETag stays so that an admin editing +//! `window_hours` in `users.toml` (and restarting the server) +//! invalidates existing client caches automatically. use std::path::{Path, PathBuf}; use std::time::SystemTime; use axum::Json; -use axum::extract::Query; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse}; -use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; use tracing::warn; @@ -35,20 +37,11 @@ use crate::auth::AuthenticatedUser; use crate::error::AppError; use crate::paths; -const DEFAULT_HOURS: u32 = 16; -const MAX_HOURS: u32 = 168; - -#[derive(Deserialize)] -pub struct OnelinersQuery { - hours: Option, -} - pub async fn handle_oneliners( user: AuthenticatedUser, - Query(params): Query, headers: HeaderMap, ) -> Result { - let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS); + let hours = user.window_hours; let now = OffsetDateTime::now_utc(); let since = now - Duration::hours(hours as i64); diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 7d0e545..67dddf9 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -32,6 +32,7 @@ fn make_user(slug: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs index d615be3..919b1ad 100644 --- a/server/tests/auth_test.rs +++ b/server/tests/auth_test.rs @@ -18,6 +18,7 @@ fn test_config() -> Arc { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }], api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), ..Config::test_default() diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index a4736c8..6b8ee03 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -24,6 +24,7 @@ fn make_user(slug: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/close_watermark_test.rs b/server/tests/close_watermark_test.rs index 1df74b2..8d50c96 100644 --- a/server/tests/close_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -24,6 +24,7 @@ fn make_user(slug: &str, password_plain: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/delete_recording_test.rs b/server/tests/delete_recording_test.rs index e4427fb..cbf6be9 100644 --- a/server/tests/delete_recording_test.rs +++ b/server/tests/delete_recording_test.rs @@ -24,6 +24,7 @@ fn make_user(slug: &str, password_plain: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs index ea4527c..47f3eb8 100644 --- a/server/tests/login_test.rs +++ b/server/tests/login_test.rs @@ -15,6 +15,7 @@ fn make_user(slug: &str, password_plain: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index dec78c5..35d9606 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -25,6 +25,7 @@ fn make_user() -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 29dc62d..2850f11 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -29,6 +29,7 @@ fn test_config() -> (Arc, PathBuf) { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }], api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]), ..Config::test_default() @@ -36,6 +37,16 @@ fn test_config() -> (Arc, PathBuf) { (config, data_path) } +fn test_config_with_window_hours(hours: u32) -> (Arc, PathBuf) { + let (cfg, dp) = test_config(); + // Safe because `test_config` just created the Arc and handed it + // back with refcount == 1. + let mut cfg = + Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique")); + cfg.users[0].window_hours = hours; + (Arc::new(cfg), dp) +} + async fn seed_case( user_root: &Path, case_id: &str, @@ -145,10 +156,10 @@ async fn empty_user_dir_returns_200_empty_list() { let etag = header_str(&response, "etag"); assert!(etag.starts_with("W/\""), "etag was {etag:?}"); - assert!(etag.ends_with("-16\""), "etag was {etag:?}"); + assert!(etag.ends_with("-72\""), "etag was {etag:?}"); let body = body_json(response).await; - assert_eq!(body["window_hours"], 16); + assert_eq!(body["window_hours"], 72); assert!(body["oneliners"].as_array().unwrap().is_empty()); let _ = std::fs::remove_dir_all(&dp); @@ -207,7 +218,7 @@ async fn case_older_than_default_window_excluded() { seed_case( &dp, "550e8400-e29b-41d4-a716-000000000003", - Duration::from_secs(20 * 3600), // 20 h old + Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default Some("old case"), ) .await; @@ -219,48 +230,41 @@ async fn case_older_than_default_window_excluded() { let _ = std::fs::remove_dir_all(&dp); } +/// Demonstrates that the visibility decision lives in users.toml: +/// the same 40 h-old case is hidden under window_hours = 16 and +/// visible under window_hours = 48. #[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); +async fn case_visibility_follows_user_window_hours() { + let case_id = "550e8400-e29b-41d4-a716-000000000004"; + let age = Duration::from_secs(40 * 3600); - let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; + let (config_small, dp_small) = test_config_with_window_hours(16); + seed_case(&dp_small, case_id, age, Some("40h case")).await; + let app_small = doctate_server::create_router(config_small); + let body = body_json(app_small.oneshot(get("/api/oneliners")).await.unwrap()).await; + assert_eq!(body["window_hours"], 16); + assert!(body["oneliners"].as_array().unwrap().is_empty()); + + let (config_wide, dp_wide) = test_config_with_window_hours(48); + seed_case(&dp_wide, case_id, age, Some("40h case")).await; + let app_wide = doctate_server::create_router(config_wide); + let body = body_json(app_wide.oneshot(get("/api/oneliners")).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); + let _ = std::fs::remove_dir_all(&dp_small); + let _ = std::fs::remove_dir_all(&dp_wide); } +/// Regression guard: a lingering `?hours=N` from an old client is +/// silently ignored — the user-config value drives the response. #[tokio::test] -async fn hours_clamped_to_max() { +async fn hours_query_param_is_ignored() { 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 body = body_json(app.oneshot(get("/api/oneliners?hours=999")).await.unwrap()).await; + assert_eq!(body["window_hours"], 72); let _ = std::fs::remove_dir_all(&dp); } @@ -293,7 +297,7 @@ async fn stale_if_none_match_returns_200() { let response = app .oneshot(get_with_if_none_match( "/api/oneliners", - "W/\"99999999-16\"", + "W/\"99999999-72\"", )) .await .unwrap(); @@ -302,45 +306,28 @@ async fn stale_if_none_match_returns_200() { let _ = std::fs::remove_dir_all(&dp); } +/// Two users with different `window_hours` must produce different +/// ETag suffixes, even on empty data — so an admin's edit to +/// users.toml invalidates client caches after the next restart. #[tokio::test] -async fn etag_differs_between_different_hours() { - let (config, dp) = test_config(); - let app = doctate_server::create_router(config); +async fn etag_differs_between_users_with_different_window_hours() { + let (config_a, dp_a) = test_config_with_window_hours(16); + let (config_b, dp_b) = test_config_with_window_hours(48); - let r1 = app - .clone() - .oneshot(get("/api/oneliners?hours=1")) - .await - .unwrap(); - let r16 = app.oneshot(get("/api/oneliners?hours=16")).await.unwrap(); + let app_a = doctate_server::create_router(config_a); + let app_b = doctate_server::create_router(config_b); - let etag1 = header_str(&r1, "etag"); - let etag16 = header_str(&r16, "etag"); - assert_ne!(etag1, etag16); + let r_a = app_a.oneshot(get("/api/oneliners")).await.unwrap(); + let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap(); - let _ = std::fs::remove_dir_all(&dp); -} + let etag_a = header_str(&r_a, "etag"); + let etag_b = header_str(&r_b, "etag"); + assert_ne!(etag_a, etag_b); + assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}"); + assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}"); -#[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); + let _ = std::fs::remove_dir_all(&dp_a); + let _ = std::fs::remove_dir_all(&dp_b); } #[tokio::test] @@ -430,7 +417,7 @@ async fn sorts_by_last_recording_not_created() { .await; let app = doctate_server::create_router(config); - let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; + 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]["case_id"], case_a, "A must sort first"); diff --git a/server/tests/retention_sweep_test.rs b/server/tests/retention_sweep_test.rs index 8976ad3..20a516a 100644 --- a/server/tests/retention_sweep_test.rs +++ b/server/tests/retention_sweep_test.rs @@ -35,6 +35,7 @@ fn user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) auto_close_days, auto_delete_days, }, + window_hours: 72, } } diff --git a/server/tests/sse_integration.rs b/server/tests/sse_integration.rs index 12c133d..1582c36 100644 --- a/server/tests/sse_integration.rs +++ b/server/tests/sse_integration.rs @@ -38,6 +38,7 @@ fn make_user(slug: &str) -> User { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, } } diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 6c1448a..5a0c6bf 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -266,6 +266,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }], whisper_url, whisper_timeout_seconds: 5, diff --git a/server/tests/transient_failure_retries_test.rs b/server/tests/transient_failure_retries_test.rs index 795f199..24b9faa 100644 --- a/server/tests/transient_failure_retries_test.rs +++ b/server/tests/transient_failure_retries_test.rs @@ -75,6 +75,7 @@ async fn transient_whisper_failure_is_reenqueued_and_recovers() { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }]; let config = Arc::new(cfg); diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index a7a5520..fee191c 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -23,6 +23,7 @@ fn test_config() -> Arc { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }], api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), ..Config::test_default() diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 0b443a7..ac9dca7 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -22,6 +22,7 @@ fn test_config() -> Arc { role: "doctor".into(), whisper: Default::default(), retention: Default::default(), + window_hours: 72, }], api_keys: HashMap::from([("test-key".into(), "dr_test".into())]), ..Config::test_default()