mini-static 0.20.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use super::*;

use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::sleep;

#[tokio::test]
async fn source_folder_overlapping_output_dir_is_rejected() {
    let root = TempDir::new().unwrap();

    // The output dir defaults to the served root, so registering that root as a source
    // folder must be refused: watching the output would feed every pipeline its own
    // writes back into its trigger.
    let result = Server::new(root.path())
        .unwrap()
        .with_source_folder(root.path());
    assert!(
        result.is_err(),
        "a source folder equal to the output dir must be rejected"
    );
}

#[tokio::test]
async fn source_folder_inside_output_dir_is_rejected() {
    let root = TempDir::new().unwrap();
    let nested = root.path().join("nested");
    fs::create_dir(&nested).unwrap();

    let result = Server::new(root.path())
        .unwrap()
        .with_source_folder(&nested);
    assert!(
        result.is_err(),
        "a source folder nested in the output dir must be rejected"
    );
}

#[tokio::test]
async fn output_dir_overlapping_source_folder_is_rejected() {
    let root = TempDir::new().unwrap();
    let source = TempDir::new().unwrap();

    let server = Server::new(root.path())
        .unwrap()
        .with_source_folder(source.path())
        .unwrap();

    let result = server.with_output_dir(source.path());
    assert!(
        result.is_err(),
        "an output dir equal to a source folder must be rejected"
    );
}

#[tokio::test]
async fn css_bundle_creates_output_on_startup_with_live_reload() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();

    fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();

    let server = Server::new(out.path())
        .unwrap()
        .with_live_reload()
        .with_source_folder(src.path())
        .unwrap()
        .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));

    let (_port, handle) = server.run_ephemeral().await.unwrap();

    sleep(Duration::from_millis(800)).await;

    let bundle = out.path().join("styles.css");
    assert!(
        bundle.exists(),
        "bundle should be written to the default <output>/styles.css"
    );
    let content = fs::read_to_string(&bundle).unwrap();
    assert!(!content.is_empty(), "bundle should contain CSS");

    handle.shutdown().await;
}

#[tokio::test]
async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();
    let src_path = src.path();
    let bundle = out.path().join("styles.css");

    fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();

    let server = Server::new(out.path())
        .unwrap()
        .with_live_reload()
        .with_source_folder(src_path)
        .unwrap()
        .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));

    let (_port, handle) = server.run_ephemeral().await.unwrap();

    // Let the startup build and the watcher's first poll pass (500ms) complete.
    sleep(Duration::from_millis(800)).await;
    assert!(bundle.exists());

    fs::write(
        src_path.join("style.css"),
        "body { margin: 0; color: blue; }",
    )
    .unwrap();

    // Wait long enough for the watcher poll + rebundle to land at least once.
    sleep(Duration::from_millis(1500)).await;
    let content_v2 = fs::read_to_string(&bundle).unwrap();
    assert!(
        content_v2.contains("color"),
        "rebundle should contain the new color rule"
    );

    let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
    sleep(Duration::from_millis(1200)).await;
    let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();

    // The regression this guards: the output write must NOT re-trigger another rebuild
    // (the feedback loop would keep mutating the bundle's mtime here). A settled mtime
    // over a full poll interval proves a single rebuild, not a loop.
    assert_eq!(
        mtime_after, mtime_later,
        "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn css_bundle_creates_output_on_startup_without_live_reload() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();

    fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();

    let server = Server::new(out.path())
        .unwrap()
        .with_source_folder(src.path())
        .unwrap()
        .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));

    let (_port, handle) = server.run_ephemeral().await.unwrap();

    sleep(Duration::from_millis(200)).await;

    let bundle = out.path().join("styles.css");
    assert!(
        bundle.exists(),
        "bundle should be created even without live_reload"
    );
    let content = fs::read_to_string(&bundle).unwrap();
    assert!(!content.is_empty(), "bundle should contain CSS");

    handle.shutdown().await;
}

#[tokio::test]
async fn css_bundle_concatenates_multiple_source_css_files() {
    let src = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();

    fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
    fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();

    let server = Server::new(out.path())
        .unwrap()
        .with_live_reload()
        .with_source_folder(src.path())
        .unwrap()
        .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));

    let (_port, handle) = server.run_ephemeral().await.unwrap();

    sleep(Duration::from_millis(800)).await;

    let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
    assert!(
        content.contains("margin"),
        "output should contain reset CSS"
    );
    assert!(
        content.contains("background"),
        "output should contain theme CSS"
    );

    handle.shutdown().await;
}