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.
This commit is contained in:
2026-04-21 16:48:01 +02:00
parent c8186c9f88
commit 3218fc62bd
24 changed files with 167 additions and 132 deletions
+4 -13
View File
@@ -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<UiAction> {
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);
+3 -2
View File
@@ -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,
+7
View File
@@ -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
-19
View File
@@ -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<u64>,
/// 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<u32>,
}
#[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));
}
}
+1 -3
View File
@@ -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};
+1 -5
View File
@@ -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<String>,
) -> Result<PollOutcome, PollError> {
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,
}
}
+7 -1
View File
@@ -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`.
+18
View File
@@ -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,
})
}
}
+44
View File
@@ -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);
}
}
+12 -19
View File
@@ -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/"<fingerprint_hex>-<hours>"`. 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<u32>,
}
pub async fn handle_oneliners(
user: AuthenticatedUser,
Query(params): Query<OnelinersQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
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);
+1
View File
@@ -32,6 +32,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -18,6 +18,7 @@ fn test_config() -> Arc<Config> {
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()
+1
View File
@@ -24,6 +24,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -25,6 +25,7 @@ fn make_user() -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+57 -70
View File
@@ -29,6 +29,7 @@ fn test_config() -> (Arc<Config>, 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<Config>, PathBuf) {
(config, data_path)
}
fn test_config_with_window_hours(hours: u32) -> (Arc<Config>, 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");
+1
View File
@@ -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,
}
}
+1
View File
@@ -38,6 +38,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -266,6 +266,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}],
whisper_url,
whisper_timeout_seconds: 5,
@@ -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);
+1
View File
@@ -23,6 +23,7 @@ fn test_config() -> Arc<Config> {
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()
+1
View File
@@ -22,6 +22,7 @@ fn test_config() -> Arc<Config> {
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()