rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
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));
    }
}