martin 1.8.2

Blazing fast and lightweight tile server with PostGIS, MBTiles, and PMTiles support
Documentation
#![cfg(feature = "styles")]

use actix_web::http::header::CONTENT_TYPE;
use actix_web::test::{TestRequest, call_service, read_body, read_body_json};
use indoc::indoc;
use insta::assert_json_snapshot;
use martin::config::file::srv::SrvConfig;
use serde_json::Value;

pub mod utils;
pub use utils::*;

macro_rules! create_app {
    ($sources:expr) => {{
        let state = mock_sources(mock_cfg($sources)).await.0;
        let app = ::actix_web::App::new()
            .app_data(actix_web::web::Data::new(
                ::martin::srv::Catalog::new(
                    #[cfg(any(feature = "sprites", feature = "fonts", feature = "styles"))]
                    &state,
                )
                .unwrap(),
            ))
            .app_data(actix_web::web::Data::new(SrvConfig::default()));

        #[cfg(feature = "_tiles")]
        let app = app.app_data(actix_web::web::Data::new(state.tile_manager.clone()));

        let app = app
            .app_data(actix_web::web::Data::new(state.styles))
            .configure(|c| ::martin::srv::router(c, &SrvConfig::default()));

        ::actix_web::test::init_service(app).await
    }};
}

fn test_get(path: &str) -> TestRequest {
    TestRequest::get().uri(path)
}

#[cfg(all(feature = "rendering", target_os = "linux"))]
const CONFIG_STYLES: &str = indoc! {"
        styles:
            rendering: true
            sources:
                maplibre_demo: ../tests/fixtures/styles/maplibre_demo.json
    "};
#[cfg(any(not(feature = "rendering"), not(target_os = "linux")))]
const CONFIG_STYLES: &str = indoc! {"
        styles:
            sources:
                maplibre_demo: ../tests/fixtures/styles/maplibre_demo.json
    "};

#[actix_rt::test]
#[tracing_test::traced_test]
async fn catalog_multiple_styles() {
    let app = create_app! { CONFIG_STYLES };

    let req = test_get("/catalog").to_request();
    let response = call_service(&app, req).await;
    let response = assert_response(response).await;
    let body: Value = read_body_json(response).await;

    insta::with_settings!({sort_maps => true}, {
        assert_json_snapshot!(body["styles"], @r#"
        {
          "maplibre_demo": {
            "path": "../tests/fixtures/styles/maplibre_demo.json"
          }
        }
        "#);
    });
}

#[actix_rt::test]
#[tracing_test::traced_test]
async fn style_json_not_found() {
    let app = create_app! { CONFIG_STYLES };

    let req = test_get("/style/nonexistent_style").to_request();
    let response = call_service(&app, req).await;

    assert_eq!(response.status(), 404);

    assert_eq!(
        response.headers().get(CONTENT_TYPE).unwrap(),
        "text/plain; charset=utf-8"
    );
    let body = String::from_utf8(read_body(response).await.to_vec()).unwrap();
    assert_eq!(body, "No such style exists");
}

#[cfg(all(feature = "rendering", target_os = "linux"))]
mod render_tests {
    use rstest::rstest;

    use super::*;

    /// Full PNG magic: 8 bytes
    const PNG_MAGIC: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
    /// JPEG magic: first 3 bytes
    const JPEG_MAGIC: &[u8] = &[0xFF, 0xD8, 0xFF];

    #[rstest]
    #[case::single_style(CONFIG_STYLES, "/style/maplibre_demo/0/0/0.png")]
    #[case::single_style_zoom_1(CONFIG_STYLES, "/style/maplibre_demo/1/0/0.png")]
    #[case::single_style_corner(CONFIG_STYLES, "/style/maplibre_demo/1/1/0.png")]
    #[case::single_style_mid_zoom(CONFIG_STYLES, "/style/maplibre_demo/5/15/15.png")]
    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_tile_png(#[case] config: &str, #[case] path: &str) {
        let app = create_app! { config };

        let req = test_get(path).to_request();
        let response = call_service(&app, req).await;
        let response = assert_response(response).await;

        assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), "image/png");

        let body = read_body(response).await;
        assert!(
            body.len() > 1000,
            "PNG should have reasonable size for {path}, got {}",
            body.len()
        );

        // Verify full PNG magic (8 bytes)
        assert_eq!(&body[..8], PNG_MAGIC, "Response is not a valid PNG");

        // Decode and verify dimensions
        let img = image::load_from_memory_with_format(&body, image::ImageFormat::Png)
            .expect("Failed to decode PNG response");
        assert_eq!(
            (img.width(), img.height()),
            (512, 512),
            "Tile must be 512x512"
        );
    }

    #[rstest]
    #[case::jpeg_ext(CONFIG_STYLES, "/style/maplibre_demo/0/0/0.jpeg")]
    #[case::jpg_alias(CONFIG_STYLES, "/style/maplibre_demo/0/0/0.jpg")]
    #[case::jpeg_zoom_1(CONFIG_STYLES, "/style/maplibre_demo/1/0/0.jpeg")]
    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_tile_jpeg(#[case] config: &str, #[case] path: &str) {
        let app = create_app! { config };

        let req = test_get(path).to_request();
        let response = call_service(&app, req).await;
        let response = assert_response(response).await;

        assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), "image/jpeg");

        let body = read_body(response).await;
        assert!(
            body.len() > 1000,
            "JPEG should have reasonable size for {path}, got {}",
            body.len()
        );

        // Verify JPEG magic bytes
        assert_eq!(&body[..3], JPEG_MAGIC, "Response is not a valid JPEG");

        // Decode and verify dimensions
        let img = image::load_from_memory_with_format(&body, image::ImageFormat::Jpeg)
            .expect("Failed to decode JPEG response");
        assert_eq!(
            (img.width(), img.height()),
            (512, 512),
            "Tile must be 512x512"
        );
    }

    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_tile_not_found_style() {
        let app = create_app! { CONFIG_STYLES };

        let req = test_get("/style/nonexistent_style/0/0/0.png").to_request();
        let response = call_service(&app, req).await;

        assert_eq!(response.status(), 404);
        let body = String::from_utf8(read_body(response).await.to_vec()).unwrap();
        assert_eq!(body, "No such style exists");
    }

    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_tile_impossible() {
        let app = create_app! { CONFIG_STYLES };

        // 4000,4000 is not possible for zoom level 0
        let req = test_get("/style/maplibre_demo/0/4000/4000.png").to_request();
        let response = call_service(&app, req).await;

        assert_eq!(response.status(), 400);
        let body = String::from_utf8(read_body(response).await.to_vec()).unwrap();
        assert_eq!(body, "Invalid tile coordinates for zoom level");
    }

    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_different_tiles_differ() {
        let app = create_app! { CONFIG_STYLES };

        let req_a = test_get("/style/maplibre_demo/0/0/0.png").to_request();
        let resp_a = call_service(&app, req_a).await;
        let body_a = read_body(assert_response(resp_a).await).await;

        let req_b = test_get("/style/maplibre_demo/1/1/0.png").to_request();
        let resp_b = call_service(&app, req_b).await;
        let body_b = read_body(assert_response(resp_b).await).await;

        assert_ne!(
            body_a, body_b,
            "Different tile coordinates must produce different images"
        );
    }

    #[tokio::test]
    #[tracing_test::traced_test]
    async fn render_concurrent_requests() {
        let app = create_app! { CONFIG_STYLES };

        let coords = [
            "/style/maplibre_demo/0/0/0.png",
            "/style/maplibre_demo/1/0/0.png",
            "/style/maplibre_demo/1/1/0.png",
            "/style/maplibre_demo/1/0/1.png",
            "/style/maplibre_demo/1/1/1.png",
        ];

        let futures = coords
            .iter()
            .map(|path| call_service(&app, test_get(path).to_request()));

        let responses = futures::future::join_all(futures).await;

        let mut bodies = Vec::new();
        for (i, response) in responses.into_iter().enumerate() {
            let response = assert_response(response).await;
            assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), "image/png");
            let body = read_body(response).await;
            assert!(
                body.len() > 1000,
                "Concurrent request {i} should produce a valid image"
            );
            assert_eq!(
                &body[..8],
                PNG_MAGIC,
                "Concurrent request {i} is not valid PNG"
            );
            bodies.push(body);
        }

        // Verify not all responses are identical (renderer isn't returning cached static image)
        let unique_count = bodies
            .iter()
            .collect::<std::collections::HashSet<_>>()
            .len();
        assert!(
            unique_count > 1,
            "All {0} concurrent responses are identical — renderer may be ignoring coordinates",
            bodies.len()
        );
    }
}