//! `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, OnceLock}; use doctate_server::config::{Config, User}; use doctate_server::settings::Settings; /// Make a (test) `IONOS_API_KEY` visible before the static backend catalog /// in `analyze::backend` reads it. Idempotent — only the first call writes /// the env var; later calls are a no-op. Tests that exercise paths gated /// by `analyze::backend::any_backend_available()` should call this (the /// builder does it automatically when `with_llm` is invoked). /// /// SAFETY: in Edition 2024, `std::env::set_var` is `unsafe` because it /// races with concurrent `getenv` on POSIX. The `OnceLock` guarantees /// that the write happens at most once, before any test thread has had /// a chance to read the env via `analyze::backend::backends()`. As long /// as `ensure_test_llm_env()` is called in `TestConfig::new()` (i.e. at /// the top of every test setup), no other test thread observes the /// env-var being mutated mid-flight. pub fn ensure_test_llm_env() { static ONCE: OnceLock<()> = OnceLock::new(); ONCE.get_or_init(|| { // SAFETY: see function docstring — single-threaded init under OnceLock. unsafe { std::env::set_var("IONOS_API_KEY", "test-key"); } }); } /// 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`. 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, users: Vec, llm_url: Option, llm_api_key: Option, llm_model: Option, whisper_url: Option, whisper_timeout_seconds: Option, ollama_url: Option, 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 { // Pre-seed the test API key so any code path that touches the // backend catalog observes a non-empty `IONOS_API_KEY`. ensure_test_llm_env(); 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) -> 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) -> 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, api_key: impl Into, model: impl Into, ) -> 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, 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) -> Self { self.ollama_url = Some(url.into()); self } /// Materialize the config alone. Bucket-B overrides (`with_llm`, /// `with_whisper`, `with_ollama`) are silently dropped — call /// [`build_pair`] when those need to take effect. pub fn build(self) -> Arc { self.build_pair().0 } /// Materialize both `Config` and `Settings`. Use when a test exercises /// LLM, Whisper or Ollama paths and needs the builder-set values to /// reach the worker. pub fn build_pair(self) -> (Arc, Arc) { let data_path = self.data_path.unwrap_or_else(|| unique_tmpdir(self.label)); let api_keys: HashMap = self .users .iter() .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); let config = Arc::new(Config { data_path, users: self.users, api_keys, ..Config::test_default() }); let mut settings = Settings::default(); // LLM-related fields used to live on `Settings`; with the multi-backend // refactor they are now owned by `analyze::backend`. The `with_llm*` // builder methods are kept for source-compat but their stored URL/ // api_key/model values no longer flow into `settings`. Tests that // rely on a working LLM mock must inject backends through other // means (currently: those tests are #[ignore]'d, see the test // attribute comment). let _ = (self.llm_url, self.llm_api_key, self.llm_model); if let Some(v) = self.whisper_url { settings.whisper.url = v; } if let Some(v) = self.whisper_timeout_seconds { settings.whisper.timeout_seconds = v; } if let Some(v) = self.ollama_url { settings.ollama.url = v; } (config, Arc::new(settings)) } } impl Default for TestConfig { fn default() -> Self { Self::new() } }