use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
use super::client::TestResponse;
static TEST_SETTINGS: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedQueries {
pub queries: Vec<String>,
}
impl CapturedQueries {
pub fn new() -> Self {
Self {
queries: Vec::new(),
}
}
pub fn capture(&mut self, query: &str) {
self.queries.push(query.to_string());
}
pub fn record(&mut self, query: &str) {
self.capture(query);
}
#[must_use]
pub fn count(&self) -> usize {
self.queries.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.queries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.queries.is_empty()
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.queries.iter().any(|query| query.contains(needle))
}
#[must_use]
pub fn queries(&self) -> &[String] {
&self.queries
}
}
impl Default for CapturedQueries {
fn default() -> Self {
Self::new()
}
}
pub fn assert_num_queries(expected: usize, actual: usize) -> bool {
expected == actual
}
pub struct OverrideSettingsGuard {
key: String,
previous_value: Option<String>,
}
impl Drop for OverrideSettingsGuard {
fn drop(&mut self) {
let mut settings = TEST_SETTINGS
.lock()
.expect("test settings mutex should not be poisoned");
match &self.previous_value {
Some(previous) => {
settings.insert(self.key.clone(), previous.clone());
}
None => {
settings.remove(&self.key);
}
}
}
}
#[must_use]
pub fn override_settings(key: &str, value: &str) -> OverrideSettingsGuard {
let normalized_key = normalize_key(key);
let mut settings = TEST_SETTINGS
.lock()
.expect("test settings mutex should not be poisoned");
let previous_value = settings.insert(normalized_key.clone(), value.to_string());
OverrideSettingsGuard {
key: normalized_key,
previous_value,
}
}
pub fn setup_test_environment() {
let mut settings = TEST_SETTINGS
.lock()
.expect("test settings mutex should not be poisoned");
settings.insert("DEBUG".to_string(), "true".to_string());
}
pub fn teardown_test_environment() {
let mut settings = TEST_SETTINGS
.lock()
.expect("test settings mutex should not be poisoned");
settings.clear();
}
pub fn assert_template_used(response: &TestResponse, template_name: &str) {
let template_markers = [
template_name.to_string(),
format!("data-template=\"{template_name}\""),
format!("template:{template_name}"),
];
assert!(
template_markers
.iter()
.any(|marker| response.content.contains(marker)),
"expected response body for {} to reference template {template_name:?}, got {:?}",
response.url,
response.content
);
}
pub fn assert_form_error(response: &TestResponse, field: &str, error_msg: &str) {
assert!(
response.content.contains(field),
"expected response body for {} to mention form field {field:?}, got {:?}",
response.url,
response.content
);
assert!(
response.content.contains(error_msg),
"expected response body for {} to mention form error {error_msg:?}, got {:?}",
response.url,
response.content
);
}
#[must_use]
fn normalize_key(key: &str) -> String {
key.trim().to_ascii_uppercase()
}
#[cfg(test)]
#[must_use]
pub(crate) fn current_setting(key: &str) -> Option<String> {
TEST_SETTINGS
.lock()
.expect("test settings mutex should not be poisoned")
.get(&normalize_key(key))
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, StatusCode};
use std::panic::catch_unwind;
fn response_with(content: &str, url: &str) -> TestResponse {
TestResponse {
status_code: StatusCode::OK,
content: content.to_string(),
headers: HeaderMap::new(),
url: url.to_string(),
}
}
#[test]
fn test_setup_teardown_environment() {
setup_test_environment();
assert_eq!(current_setting("debug"), Some("true".to_string()));
teardown_test_environment();
assert_eq!(current_setting("debug"), None);
}
#[test]
fn override_settings_restores_previous_value_on_drop() {
let key = "RESTORE_TEST_KEY";
{
let _guard = override_settings(key, "initial");
assert_eq!(current_setting(key), Some("initial".to_string()));
{
let _guard2 = override_settings(key, "overridden");
assert_eq!(current_setting(key), Some("overridden".to_string()));
}
assert_eq!(current_setting(key), Some("initial".to_string()));
}
assert_eq!(current_setting(key), None);
}
#[test]
fn override_settings_removes_inserted_value_when_no_previous_setting_exists() {
let key = "EPHEMERAL_FLAG";
{
let _guard = override_settings(key, "enabled");
assert_eq!(current_setting(key), Some("enabled".to_string()));
}
assert_eq!(current_setting(key), None);
}
#[test]
fn override_settings_normalizes_key_names() {
let _guard = override_settings(" normalize_me ", "yes");
assert_eq!(current_setting("NORMALIZE_ME"), Some("yes".to_string()));
}
#[test]
fn assert_template_used_accepts_template_markers() {
let response = response_with(
"<main data-template=\"app/detail.html\">ok</main>",
"/items/1/",
);
assert_template_used(&response, "app/detail.html");
}
#[test]
fn assert_template_used_panics_when_marker_is_missing() {
let response = response_with("plain body", "/items/1/");
let result = catch_unwind(|| assert_template_used(&response, "app/detail.html"));
assert!(result.is_err());
}
#[test]
fn assert_form_error_requires_field_and_message() {
let response = response_with("email: This field is required.", "/signup/");
assert_form_error(&response, "email", "This field is required.");
}
#[test]
fn assert_form_error_panics_when_message_is_missing() {
let response = response_with("email", "/signup/");
let result = catch_unwind(|| assert_form_error(&response, "email", "required"));
assert!(result.is_err());
}
#[test]
fn captured_queries_records_and_reports_queries() {
let mut queries = CapturedQueries::new();
queries.record("SELECT 1");
queries.record("UPDATE articles SET title = 'Rjango'");
assert_eq!(queries.len(), 2);
assert!(queries.contains("articles"));
assert_eq!(queries.queries()[0], "SELECT 1");
}
#[test]
fn captured_queries_reports_empty_state() {
let queries = CapturedQueries::new();
assert!(queries.is_empty());
assert!(!queries.contains("SELECT"));
}
#[test]
fn assert_num_queries_compares_counts() {
assert!(assert_num_queries(2, 2));
assert!(!assert_num_queries(1, 2));
}
}