refactor(tests): introduce shared tests/common/ support module

Lift duplicated test-harness code (User factories, TestConfig builder,
login/CSRF flow, form/json helpers, case-directory seeding, URL path
builders) into `server/tests/common/` so future API changes touch one
file instead of every integration test.

Migrated batches: csrf_attack, auth, login, magic_link, security_headers
(28 tests); analyze, case_page, delete_recording, close_watermark,
retention_sweep (67 tests). All 95 tests still green.

Net line delta across these 10 files: +1113 / -1896 (~780 lines removed),
plus ~500 lines of new shared infrastructure under tests/common/.
This commit is contained in:
2026-04-22 10:42:51 +02:00
parent 2e3b5efe86
commit f438e972d4
17 changed files with 1808 additions and 1898 deletions
+163
View File
@@ -0,0 +1,163 @@
//! `Config` construction for tests.
//!
//! Collapses the ~13 lookalike `test_config_*` / `build_config` helpers
//! scattered across the integration tests into one fluent builder.
//! Every builder call is a minimal override on top of
//! `Config::test_default()`; the builder automatically derives the
//! `api_key → slug` index map from the collected users.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use doctate_server::config::{Config, User};
/// Unique tempdir path for a given label. `process::id()` + UUID v4
/// keeps parallel `cargo test` runs from colliding; the label lets
/// humans identify which test owns which leftover directory when
/// cleanup fails.
pub fn unique_tmpdir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"doctate-test-{label}-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
/// Fluent builder for `Arc<Config>`. All methods take `self` by value
/// and return `Self` so calls chain naturally:
///
/// ```ignore
/// let cfg = TestConfig::new()
/// .with_user(test_user("dr_a"))
/// .with_llm(mock.uri())
/// .build();
/// ```
pub struct TestConfig {
data_path: Option<PathBuf>,
users: Vec<User>,
llm_url: Option<String>,
llm_api_key: Option<String>,
llm_model: Option<String>,
whisper_url: Option<String>,
whisper_timeout_seconds: Option<u64>,
ollama_url: Option<String>,
label: &'static str,
}
impl TestConfig {
/// Start a fresh builder. `label` is used only to name the tempdir
/// when no explicit `data_path` is set via `with_data_path`.
pub fn new() -> Self {
Self {
data_path: None,
users: Vec::new(),
llm_url: None,
llm_api_key: None,
llm_model: None,
whisper_url: None,
whisper_timeout_seconds: None,
ollama_url: None,
label: "common",
}
}
/// Label used when auto-generating the tempdir name.
pub fn with_label(mut self, label: &'static str) -> Self {
self.label = label;
self
}
/// Override the data path. By default `build()` generates a unique
/// tempdir under `std::env::temp_dir()`.
pub fn with_data_path(mut self, path: PathBuf) -> Self {
self.data_path = Some(path);
self
}
/// Add a single user. Can be called repeatedly.
pub fn with_user(mut self, user: User) -> Self {
self.users.push(user);
self
}
/// Replace the user list outright.
pub fn with_users(mut self, users: Vec<User>) -> Self {
self.users = users;
self
}
/// Configure a hosted-provider LLM with the common test defaults
/// (`api_key = "test-key"`, `model = "test-model"`). For Ollama-style
/// endpoints that expect no `Authorization` header, use
/// [`with_llm_explicit`] and pass an empty api_key.
pub fn with_llm(mut self, url: impl Into<String>) -> Self {
self.llm_url = Some(url.into());
self.llm_api_key.get_or_insert_with(|| "test-key".into());
self.llm_model.get_or_insert_with(|| "test-model".into());
self
}
/// Configure the LLM with explicit values. `api_key` may be empty
/// to exercise the Ollama-style no-auth path.
pub fn with_llm_explicit(
mut self,
url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
self.llm_url = Some(url.into());
self.llm_api_key = Some(api_key.into());
self.llm_model = Some(model.into());
self
}
/// Whisper ASR mock URL + timeout.
pub fn with_whisper(mut self, url: impl Into<String>, timeout_seconds: u64) -> Self {
self.whisper_url = Some(url.into());
self.whisper_timeout_seconds = Some(timeout_seconds);
self
}
/// Ollama (oneliner generator) mock URL.
pub fn with_ollama(mut self, url: impl Into<String>) -> Self {
self.ollama_url = Some(url.into());
self
}
/// Materialize the config. Panics only if the workspace-defaults in
/// `Config::test_default` themselves are broken (not reachable in
/// practice).
pub fn build(self) -> Arc<Config> {
let data_path = self
.data_path
.unwrap_or_else(|| unique_tmpdir(self.label));
let api_keys: HashMap<String, String> = self
.users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
let defaults = Config::test_default();
Arc::new(Config {
data_path,
users: self.users,
api_keys,
llm_url: self.llm_url.unwrap_or(defaults.llm_url),
llm_api_key: self.llm_api_key.unwrap_or(defaults.llm_api_key),
llm_model: self.llm_model.unwrap_or(defaults.llm_model),
whisper_url: self.whisper_url.unwrap_or(defaults.whisper_url),
whisper_timeout_seconds: self
.whisper_timeout_seconds
.unwrap_or(defaults.whisper_timeout_seconds),
ollama_url: self.ollama_url.unwrap_or(defaults.ollama_url),
..Config::test_default()
})
}
}
impl Default for TestConfig {
fn default() -> Self {
Self::new()
}
}
+164
View File
@@ -0,0 +1,164 @@
//! HTTP request builders and response extraction helpers.
//!
//! The stock axum `Request::builder()…oneshot()` chain is verbose;
//! centralising the common shapes here turns ~8 repetitive lines per
//! call site into one function call, and localises URL/header shape
//! changes to this file.
use axum::body::Body;
use axum::http::{Request, header};
use axum::response::Response;
use doctate_common::{
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
};
/// Plain form-urlencoded POST with a session cookie. `body` is passed
/// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add
/// the token automatically.
pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri.as_ref())
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body.into()))
.unwrap()
}
/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
/// Pass an empty `extra_body` when the form has no other fields
/// (e.g. POST /web/cases/{id}/close).
pub fn csrf_form_post<U: AsRef<str>>(
uri: U,
cookie: &str,
csrf: &str,
extra_body: &str,
) -> Request<Body> {
let body = if extra_body.is_empty() {
format!("csrf_token={csrf}")
} else {
format!("{extra_body}&csrf_token={csrf}")
};
form_post(uri, cookie, body)
}
/// GET with a session cookie — authenticated `/web/*` pages.
pub fn get_with_cookie<U: AsRef<str>>(uri: U, cookie: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri.as_ref())
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
/// GET with `X-API-Key` — the public `/api/*` surface used by the
/// watch/desktop clients.
pub fn get_with_api_key<U: AsRef<str>>(uri: U, api_key: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri.as_ref())
.header(API_KEY_HEADER, api_key)
.body(Body::empty())
.unwrap()
}
/// GET with `X-API-Key` and a conditional `If-None-Match` header. Used
/// by the oneliners ETag tests and the upload watermark-invalidation
/// tests.
pub fn get_with_api_key_if_none_match<U: AsRef<str>>(
uri: U,
api_key: &str,
if_none_match: &str,
) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri.as_ref())
.header(API_KEY_HEADER, api_key)
.header(header::IF_NONE_MATCH, if_none_match)
.body(Body::empty())
.unwrap()
}
/// Consume a response body into raw bytes.
pub async fn body_bytes(response: Response) -> Vec<u8> {
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
/// Consume a response body into a UTF-8 string. Panics on non-UTF-8 —
/// which is correct for the HTML/JSON responses these tests assert on.
pub async fn body_string(response: Response) -> String {
String::from_utf8(body_bytes(response).await).unwrap()
}
/// Consume a response body into a `serde_json::Value`. Panics on
/// malformed JSON, which the test should want to surface loudly.
pub async fn body_json(response: Response) -> serde_json::Value {
let bytes = body_bytes(response).await;
serde_json::from_slice(&bytes).unwrap()
}
/// Borrow a header value as a `&str`. Returns `None` if the header is
/// absent or not valid UTF-8.
pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> {
response.headers().get(name).and_then(|v| v.to_str().ok())
}
/// Header value as an owned `String`, `""` if missing. Matches the
/// existing style in `oneliners_api_test.rs`'s `header_str` helper.
pub fn header_str(response: &Response, name: &str) -> String {
header_opt(response, name).unwrap_or("").to_owned()
}
/// Build a multipart body for `POST /api/upload`. Returns
/// `(content_type_header_value, body_bytes)` — pass the first as the
/// `Content-Type` header verbatim.
///
/// Uses the server's canonical field names (`case_id`, `recorded_at`,
/// `audio`) from `doctate_common::constants`, so a server-side rename
/// propagates here for free.
pub fn multipart_upload_body(
case_id: &str,
recorded_at: &str,
audio: &[u8],
) -> (String, Vec<u8>) {
let boundary = "----testboundary";
let mut body = Vec::new();
// case_id
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(),
);
body.extend_from_slice(case_id.as_bytes());
body.extend_from_slice(b"\r\n");
// recorded_at
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(),
);
body.extend_from_slice(recorded_at.as_bytes());
body.extend_from_slice(b"\r\n");
// audio
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n"
)
.as_bytes(),
);
body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes());
body.extend_from_slice(audio);
body.extend_from_slice(b"\r\n");
// end
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(format!("multipart/form-data; boundary={boundary}"), body)
}
+40
View File
@@ -0,0 +1,40 @@
//! Shared test-support helpers for `server/tests/*.rs`.
//!
//! Every integration test file under `tests/*.rs` is a standalone crate.
//! This module's special filename `mod.rs` prevents Cargo from
//! compiling it as its own test target — it is only loaded by test
//! files that opt in via `mod common;` at the top.
//!
//! `#[allow(dead_code)]` is applied because each test crate uses only
//! a subset of the helpers; without the allow, rustc emits a forest of
//! warnings for the unused ones in each crate.
#![allow(dead_code)]
// Re-exports below are visible to every test crate, but each crate only
// uses a subset — without the allow, rustc emits "unused import"
// warnings per crate. The helpers themselves are exercised; the
// warning is a false positive of the per-crate visibility model.
#![allow(unused_imports)]
pub mod config;
pub mod http;
pub mod paths;
pub mod seed;
pub mod session;
pub mod users;
// Re-exports for ergonomic `use common::*;` in most test files.
pub use config::{TestConfig, unique_tmpdir};
pub use http::{
body_json, body_string, csrf_form_post, form_post, get_with_api_key,
get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body,
};
pub use seed::{
past_rfc3339, seed_case, seed_oneliner_ready, seed_oneliner_state, seed_recording,
seed_recording_with_age, seed_recording_with_sidecars, write_closed_marker,
};
pub use session::{extract_session_cookie, login, login_request, login_with_csrf};
pub use users::{
TEST_BCRYPT_COST, TEST_PASSWORD, test_admin, test_user, test_user_with_password,
test_user_with_retention, test_user_with_window_hours,
};
+67
View File
@@ -0,0 +1,67 @@
//! URL path constants and builders.
//!
//! Centralising these catches routing refactors at compile time (a
//! rename to the constant/fn shows up in every test file in one
//! `cargo check`) rather than as a scatter of 404s.
//!
//! The public `/api/*` surface lives in `doctate_common` and is
//! re-exported from there (see `ONELINERS_PATH`, `UPLOAD_PATH`).
pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH};
// -- /web/* --
/// `POST /web/login` — form login (slug, password).
pub const LOGIN: &str = "/web/login";
/// `POST /web/logout` — CSRF-protected logout.
pub const LOGOUT: &str = "/web/logout";
/// `GET /web/cases` — case list; also triggers the lazy retention sweep.
pub const CASES: &str = "/web/cases";
/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset).
pub const BULK: &str = "/web/cases/bulk";
/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases.
pub const PURGE_CLOSED: &str = "/web/cases/purge-closed";
/// `GET /web/events` — SSE stream (per-user scoped).
pub const EVENTS: &str = "/web/events";
// -- per-case builders --
/// `/web/cases/{case_id}`.
pub fn case_detail(case_id: &str) -> String {
format!("/web/cases/{case_id}")
}
/// `/web/cases/{case_id}/recordings`.
pub fn case_recordings(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings")
}
/// `POST /web/cases/{case_id}/close`.
pub fn case_close(case_id: &str) -> String {
format!("/web/cases/{case_id}/close")
}
/// `POST /web/cases/{case_id}/reopen`.
pub fn case_reopen(case_id: &str) -> String {
format!("/web/cases/{case_id}/reopen")
}
/// `POST /web/cases/{case_id}/analyze`.
pub fn case_analyze(case_id: &str) -> String {
format!("/web/cases/{case_id}/analyze")
}
/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset.
pub fn case_reset(case_id: &str) -> String {
format!("/web/cases/{case_id}/reset")
}
/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete.
pub fn recording_delete(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings/delete")
}
/// `GET /web/magic?token={token}` — magic-link consumption.
pub fn magic(token: &str) -> String {
format!("/web/magic?token={token}")
}
+95
View File
@@ -0,0 +1,95 @@
//! Filesystem-fixture builders for case directories, recordings,
//! oneliner state files, and close markers.
//!
//! All functions use synchronous `std::fs` (tests run inside
//! `#[tokio::test]` but these writes are short and their output is
//! consumed sequentially, so there is no win from async I/O here). A
//! few of the oneliner_api tests use `tokio::fs` directly because they
//! need to run inside the same async flow; that's fine — the two
//! coexist.
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use doctate_common::oneliners::OnelinerState;
use filetime::FileTime;
/// Create `<data_path>/<slug>/<case_id>/` and return its path. This is
/// the only "has to be on disk for the route to see it" fixture —
/// recordings and derived artefacts layer on top.
pub fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
let dir = data_path.join(slug).join(case_id);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Write `<stem>.m4a` with placeholder bytes, plus optionally a
/// `<stem>.transcript.txt`. `stem` must be the full RFC3339-like
/// filename stem (e.g. `2026-04-15T10-00-00Z`); pass the same prefix
/// you want to read back later. Returns the audio filename (no path)
/// so the caller can feed it back to the delete endpoint.
pub fn seed_recording(case_dir: &Path, stem: &str, transcript: Option<&str>) -> String {
let filename = format!("{stem}.m4a");
std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap();
if let Some(text) = transcript {
std::fs::write(case_dir.join(format!("{stem}.transcript.txt")), text).unwrap();
}
filename
}
/// Like [`seed_recording`] but additionally writes the `.duration.txt`
/// sidecar — used by the delete-recording tests that assert every
/// sidecar is cleaned up.
pub fn seed_recording_with_sidecars(
case_dir: &Path,
stem: &str,
transcript: &str,
) -> String {
let filename = seed_recording(case_dir, stem, Some(transcript));
std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap();
filename
}
/// Write an m4a with an artificial `mtime` in the past (relative to
/// now). Used by the retention-sweep tests to simulate "recorded N days
/// ago" without a clock abstraction.
pub fn seed_recording_with_age(case_dir: &Path, stem: &str, age: Duration) {
let filename = format!("{stem}.m4a");
let path = case_dir.join(&filename);
std::fs::write(&path, b"audio").unwrap();
let target = SystemTime::now() - age;
filetime::set_file_mtime(&path, FileTime::from_system_time(target)).unwrap();
}
/// Persist `OnelinerState::Ready { text }` with a fixed `generated_at`.
/// Used by UI-rendering tests that care about the displayed text, not
/// the timestamp.
pub fn seed_oneliner_ready(case_dir: &Path, text: &str) {
let state = OnelinerState::Ready {
text: text.to_owned(),
generated_at: "2026-04-18T10:00:00Z".into(),
};
seed_oneliner_state(case_dir, &state);
}
/// Persist an arbitrary [`OnelinerState`] as `oneliner.json`.
pub fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) {
let bytes = serde_json::to_vec(state).unwrap();
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
}
/// Write a `.closed` marker with a caller-chosen `closed_at` string.
/// Tests that need a specific age pass a historical RFC3339 stamp; a
/// helper for that is [`past_rfc3339`].
pub fn write_closed_marker(case_dir: &Path, closed_at: &str) {
let json = format!(r#"{{"closed_at":"{closed_at}"}}"#);
std::fs::write(case_dir.join(".closed"), json).unwrap();
}
/// Format "now - age" as RFC3339 UTC. Used to mint historical
/// `closed_at` strings for retention-purge tests.
pub fn past_rfc3339(age: Duration) -> String {
use time::format_description::well_known::Rfc3339;
let t = time::OffsetDateTime::now_utc() - time::Duration::seconds(age.as_secs() as i64);
t.format(&Rfc3339).unwrap()
}
+84
View File
@@ -0,0 +1,84 @@
//! Session cookie extraction + login flows.
//!
//! These were previously copy-pasted 46 times across the integration
//! tests. Centralising them here means: a change to the cookie format
//! or the login form field names affects one file, not six.
use axum::Router;
use axum::body::Body;
use axum::http::{Request, header};
use axum::response::Response;
use tower::util::ServiceExt;
use doctate_server::web_session::SessionStore;
use crate::common::paths;
/// Extract the freshly-minted `session=<value>` cookie from a login
/// response's `Set-Cookie` headers. Returns the whole `name=value` pair
/// (no attributes like `; Path=/`) so it can be sent back verbatim in a
/// `Cookie` header.
pub fn extract_session_cookie(resp: &Response) -> Option<String> {
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
let s = v.to_str().ok()?;
if let Some(pair) = s.split(';').next()
&& pair.starts_with("session=")
{
return Some(pair.to_string());
}
}
None
}
/// Build the `POST /web/login` request with a URL-encoded form body.
/// Pulled out so tests that need to *inspect* the login response
/// (e.g. session-fixation tests) can reuse the exact wire shape.
pub fn login_request(slug: &str, password: &str) -> Request<Body> {
let body = format!("slug={slug}&password={password}");
Request::builder()
.method("POST")
.uri(paths::LOGIN)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
/// Log in, return the resulting `session=...` cookie string.
///
/// Panics if the server didn't set a session cookie — meaning the
/// credentials were wrong or the route shape has changed. Tests that
/// deliberately exercise the failure path should use [`login_request`]
/// directly.
pub async fn login(app: &Router, slug: &str, password: &str) -> String {
let resp = app
.clone()
.oneshot(login_request(slug, password))
.await
.unwrap();
extract_session_cookie(&resp).expect("login did not set a session cookie")
}
/// Log in and additionally fetch the session's CSRF token directly from
/// the session store. Pairs with
/// `doctate_server::create_router_and_session_store` — the returned
/// token is what the `CsrfForm<T>` extractor validates on every
/// state-changing POST under `/web/`.
///
/// Returns `(cookie_header_value, csrf_token)`.
pub async fn login_with_csrf(
app: &Router,
store: &SessionStore,
slug: &str,
password: &str,
) -> (String, String) {
let cookie = login(app, slug, password).await;
let token = cookie.trim_start_matches("session=");
let csrf = store
.read()
.await
.get(token)
.expect("session must be in the store right after login")
.csrf_token
.clone();
(cookie, csrf)
}
+80
View File
@@ -0,0 +1,80 @@
//! Factory functions for the `User` struct from `doctate_server::config`.
//!
//! Tests previously copy-pasted a 10-line `make_user` in every file.
//! These factories centralise the defaults (doctor role, bcrypt cost 4,
//! window 72 h, password "s") so an API change to `User` touches one
//! file, not twenty.
use doctate_server::config::{RetentionUserSettings, User};
/// Bcrypt cost factor used in all tests. The production default is 12;
/// tests drop it to 4 because the hash is computed on every `test_user()`
/// call and a cost-12 hash adds ~200 ms per test. Security is irrelevant
/// for test-only passwords; speed is not.
pub const TEST_BCRYPT_COST: u32 = 4;
/// Default plaintext password across tests. Individual tests that need
/// a specific password (e.g. for a login-wrong-password assertion) use
/// [`test_user_with_password`] instead.
pub const TEST_PASSWORD: &str = "s";
/// Bcrypt-hash the test password. Extracted so the cost factor lives in
/// one place.
fn hash_password(plain: &str) -> String {
bcrypt::hash(plain, TEST_BCRYPT_COST).unwrap()
}
/// Doctor user. `api_key = "key-{slug}"` — kept deterministic so tests
/// that send the X-API-Key header can compute it from the slug without
/// keeping a separate constant.
pub fn test_user(slug: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: hash_password(TEST_PASSWORD),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
}
}
/// Admin variant — used by bulk/reset/purge-closed tests where the
/// handler enforces `role == "admin"`.
pub fn test_admin(slug: &str) -> User {
let mut u = test_user(slug);
u.role = "admin".into();
u
}
/// Doctor user with an explicit plaintext password. Used by tests that
/// log in with a specific password (e.g. wrong-password rejection).
pub fn test_user_with_password(slug: &str, password: &str) -> User {
let mut u = test_user(slug);
u.web_password = hash_password(password);
u
}
/// Doctor user with custom retention settings — for the retention sweep
/// tests that drive the lazy auto-close / auto-purge logic.
pub fn test_user_with_retention(
slug: &str,
auto_close_days: u32,
auto_delete_days: u32,
) -> User {
let mut u = test_user(slug);
u.retention = RetentionUserSettings {
auto_close_days,
auto_delete_days,
};
u
}
/// Doctor user with a custom `window_hours` — used by `/api/oneliners`
/// tests that verify the visibility window is server-authoritative.
pub fn test_user_with_window_hours(slug: &str, hours: u32) -> User {
let mut u = test_user(slug);
u.window_hours = hours;
u
}