what-core 1.7.5

Core framework for What - an HTML-first web framework powered by Rust
Documentation
//! Shared test helpers for integration tests

use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use tower::ServiceExt;

use what_core::server::{AppState, content_dir_name, create_router};
use what_core::{CollectionPolicyConfig, Config};

/// A default Config with the named collections made fully open (create/update/
/// delete = "all", no ownership). Useful for CRUD-mechanics tests that predate
/// the v1.4 owner-protected default and don't intend to exercise authorization.
pub fn open_collections_config(names: &[&str]) -> Config {
    let mut config = Config::default();
    for name in names {
        config.collections.insert(
            name.to_string(),
            CollectionPolicyConfig {
                owner: Some("none".to_string()),
                create: Some("all".to_string()),
                update: Some("all".to_string()),
                delete: Some("all".to_string()),
                read: Some("all".to_string()),
                ..Default::default()
            },
        );
    }
    config
}

// ---------------------------------------------------------------------------
// TestProject builder
// ---------------------------------------------------------------------------

pub struct TestProject {
    dir: TempDir,
}

impl TestProject {
    pub fn new() -> Self {
        let dir = TempDir::new().expect("failed to create temp dir");
        std::fs::create_dir_all(dir.path().join("site")).unwrap();
        Self { dir }
    }

    pub fn root(&self) -> &Path {
        self.dir.path()
    }

    pub fn add_page(&self, rel_path: &str, content: &str) -> &Self {
        let full = self
            .dir
            .path()
            .join(content_dir_name(self.dir.path()))
            .join(rel_path);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full, content).unwrap();
        self
    }

    pub fn add_app_config(&self, dir: &str, content: &str) -> &Self {
        let target_dir = self
            .dir
            .path()
            .join(content_dir_name(self.dir.path()))
            .join(dir);
        std::fs::create_dir_all(&target_dir).unwrap();
        std::fs::write(target_dir.join("application.what"), content).unwrap();
        self
    }

    pub fn add_component(&self, name: &str, content: &str) -> &Self {
        let comp_dir = self.dir.path().join("components");
        std::fs::create_dir_all(&comp_dir).unwrap();
        std::fs::write(comp_dir.join(name), content).unwrap();
        self
    }

    pub fn add_layout(&self, rel_path: &str, content: &str) -> &Self {
        let full = self.dir.path().join(rel_path);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full, content).unwrap();
        self
    }

    pub fn add_static(&self, rel_path: &str, bytes: &[u8]) -> &Self {
        let full = self.dir.path().join("static").join(rel_path);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full, bytes).unwrap();
        self
    }

    pub fn add_data_file(&self, rel_path: &str, content: &str) -> &Self {
        let full = self.dir.path().join(rel_path);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full, content).unwrap();
        self
    }

    /// Build AppState with default Config (sessions enabled)
    pub fn build_state(self) -> (TempDir, AppState) {
        let config = Config::default();
        self.build_state_inner(config, false)
    }

    /// Build AppState with a custom Config
    pub fn build_state_with_config(self, config: Config) -> (TempDir, AppState) {
        self.build_state_inner(config, false)
    }

    /// Build AppState with uploads enabled
    pub fn build_state_with_uploads(self) -> (TempDir, AppState) {
        let mut config = Config::default();
        config.uploads.enabled = true;
        config.uploads.directory = "uploads".to_string();
        config.uploads.max_size = "1mb".to_string();
        self.build_state_inner(config, false)
    }

    /// Build AppState with dev mode enabled
    pub fn build_state_dev(self) -> (TempDir, AppState) {
        let config = Config::default();
        self.build_state_inner(config, true)
    }

    /// Build AppState with dev mode enabled and a custom Config
    pub fn build_state_dev_with_config(self, config: Config) -> (TempDir, AppState) {
        self.build_state_inner(config, true)
    }

    fn build_state_inner(self, config: Config, dev: bool) -> (TempDir, AppState) {
        let root = self.dir.path().to_path_buf();
        let state = AppState::with_dev_mode(config, root, dev).expect("failed to build AppState");
        (self.dir, state)
    }
}

// ---------------------------------------------------------------------------
// TestResponse
// ---------------------------------------------------------------------------

pub struct TestResponse {
    pub status: StatusCode,
    pub headers: axum::http::HeaderMap,
    pub body: String,
}

impl TestResponse {
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(name).and_then(|v| v.to_str().ok())
    }

    pub fn content_type(&self) -> Option<&str> {
        self.header("content-type")
    }

    pub fn set_cookie(&self) -> Option<&str> {
        self.header("set-cookie")
    }

    /// Extract the `name=value` session cookie pair from a Set-Cookie header,
    /// suitable for sending back as a `Cookie` header on the next request
    /// (mirrors a browser preserving the session across requests).
    pub fn session_cookie(&self) -> Option<String> {
        let raw = self.set_cookie()?;
        // Take the first `name=value` segment, dropping attributes after `;`.
        raw.split(';').next().map(|s| s.trim().to_string())
    }

    pub fn location(&self) -> Option<&str> {
        self.header("location")
    }

    pub fn assert_contains(&self, needle: &str) {
        assert!(
            self.body.contains(needle),
            "Expected body to contain {:?}, got:\n{}",
            needle,
            &self.body[..self.body.len().min(500)]
        );
    }

    pub fn assert_not_contains(&self, needle: &str) {
        assert!(
            !self.body.contains(needle),
            "Expected body NOT to contain {:?}, but it did:\n{}",
            needle,
            &self.body[..self.body.len().min(500)]
        );
    }

    /// Extract the CSRF token from the `<meta name="csrf-token">` tag in the response body
    pub fn csrf_token(&self) -> Option<String> {
        let marker = r#"<meta name="csrf-token" content=""#;
        let start = self.body.find(marker)?;
        let after = &self.body[start + marker.len()..];
        let end = after.find('"')?;
        Some(after[..end].to_string())
    }
}

// ---------------------------------------------------------------------------
// Request helpers
// ---------------------------------------------------------------------------

pub async fn get(router: &Router, path: &str) -> TestResponse {
    get_with_headers(router, path, vec![]).await
}

pub async fn get_with_headers(
    router: &Router,
    path: &str,
    extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
    let mut builder = Request::builder().method("GET").uri(path);
    for (k, v) in extra_headers {
        builder = builder.header(k, v);
    }
    let req = builder.body(Body::empty()).unwrap();
    send(router, req).await
}

pub async fn post_form(router: &Router, path: &str, form_data: &str) -> TestResponse {
    post_form_with_headers(router, path, form_data, vec![]).await
}

pub async fn post_form_with_headers(
    router: &Router,
    path: &str,
    form_data: &str,
    extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
    let mut builder = Request::builder()
        .method("POST")
        .uri(path)
        .header("content-type", "application/x-www-form-urlencoded");
    for (k, v) in extra_headers {
        builder = builder.header(k, v);
    }
    let req = builder.body(Body::from(form_data.to_string())).unwrap();
    send(router, req).await
}

async fn send(router: &Router, req: Request<Body>) -> TestResponse {
    let resp = router.clone().oneshot(req).await.expect("request failed");
    let status = resp.status();
    let headers = resp.headers().clone();
    let bytes = resp
        .into_body()
        .collect()
        .await
        .expect("body read failed")
        .to_bytes();
    let body = String::from_utf8_lossy(&bytes).into_owned();
    TestResponse {
        status,
        headers,
        body,
    }
}

// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------

pub fn create_test_jwt(claims: &serde_json::Value, secret: &str) -> String {
    use jsonwebtoken::{EncodingKey, Header, encode};
    encode(
        &Header::default(),
        claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .expect("JWT encoding failed")
}

/// Build a multipart/form-data body with text fields and file fields
pub fn build_multipart_body(fields: Vec<(&str, MultipartField)>) -> (String, Vec<u8>) {
    let boundary = "----WhatTestBoundary7MA4YWxkTrZu0gW";
    let mut body = Vec::new();

    for (name, field) in fields {
        body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
        match field {
            MultipartField::Text(value) => {
                body.extend_from_slice(
                    format!(
                        "Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n",
                        name, value
                    )
                    .as_bytes(),
                );
            }
            MultipartField::File {
                filename,
                content_type,
                data,
            } => {
                body.extend_from_slice(
                    format!(
                        "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
                        name, filename, content_type
                    ).as_bytes()
                );
                body.extend_from_slice(data);
                body.extend_from_slice(b"\r\n");
            }
        }
    }
    body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());

    let content_type = format!("multipart/form-data; boundary={}", boundary);
    (content_type, body)
}

pub enum MultipartField<'a> {
    Text(&'a str),
    File {
        filename: &'a str,
        content_type: &'a str,
        data: &'a [u8],
    },
}

pub async fn post_multipart(
    router: &Router,
    path: &str,
    fields: Vec<(&str, MultipartField<'_>)>,
) -> TestResponse {
    post_multipart_with_headers(router, path, fields, vec![]).await
}

pub async fn post_multipart_with_headers(
    router: &Router,
    path: &str,
    fields: Vec<(&str, MultipartField<'_>)>,
    extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
    let (content_type, body) = build_multipart_body(fields);
    let mut builder = Request::builder()
        .method("POST")
        .uri(path)
        .header("content-type", content_type);
    for (k, v) in extra_headers {
        builder = builder.header(k, v);
    }
    let req = builder.body(Body::from(body)).unwrap();
    send(router, req).await
}

pub fn auth_config_with_secret(secret: &str) -> Config {
    let mut config = Config::default();
    config.auth.enabled = true;
    config.auth.jwt_secret = Some(secret.to_string());
    config.auth.jwt_cookie_name = "w_token".to_string();
    config.auth.login_path = "/login".to_string();
    config
}