tideway 0.7.17

A batteries-included Rust web framework built on Axum for building SaaS applications quickly
Documentation
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use axum::body::Body;
use axum::extract::Form;
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use axum::{Json, Router, routing::get, routing::post};
use serde::Deserialize;
use serde_json::json;
use tideway::App;
use tideway::testing::{TestHost, post as test_post};

#[tokio::test]
async fn test_host_supports_declarative_scenarios() {
    async fn health() -> Json<serde_json::Value> {
        Json(json!({
            "ok": true,
            "meta": { "mode": "test-host" }
        }))
    }

    let app = App::new().merge_router(Router::new().route("/host-health", get(health)));
    let host = TestHost::new(app);

    let outcome = host
        .scenario(|scenario| {
            scenario.get("/host-health");
            scenario.header_should_exist("content-type");
            scenario.json_path_should_be("ok", json!(true));
            scenario.json_should_contain(json!({
                "meta": { "mode": "test-host" }
            }));
            scenario.assert_with(|outcome| {
                if outcome.status() == StatusCode::OK {
                    Ok(())
                } else {
                    Err(format!("expected ok, got {}", outcome.status()))
                }
            });
        })
        .await;

    assert_eq!(outcome.json_value()["ok"], json!(true));
}

#[tokio::test]
async fn test_host_runs_before_and_after_hooks() {
    async fn create_widget(headers: HeaderMap) -> Response<Body> {
        let trace = headers
            .get("x-trace")
            .and_then(|value| value.to_str().ok())
            .unwrap_or("missing");

        Response::builder()
            .status(StatusCode::CREATED)
            .header("x-trace", trace)
            .body(Body::from("widget created"))
            .unwrap()
    }

    let hook_ran = Arc::new(AtomicBool::new(false));
    let app = App::new().merge_router(Router::new().route("/widgets", post(create_widget)));
    let host = TestHost::new(app)
        .before_each(|request| {
            request
                .headers_mut()
                .insert("x-trace", "spec-123".parse().unwrap());
        })
        .after_each({
            let hook_ran = Arc::clone(&hook_ran);
            move |outcome| {
                hook_ran.store(true, Ordering::SeqCst);
                assert_eq!(outcome.request().header("x-trace"), Some("spec-123"));
            }
        });

    let outcome = host
        .scenario(|scenario| {
            scenario.post("/widgets");
            scenario.status_code_should_be(201);
            scenario.header_should_be("x-trace", "spec-123");
            scenario.content_should_contain("widget created");
        })
        .await;

    assert_eq!(outcome.status(), StatusCode::CREATED);
    assert!(hook_ran.load(Ordering::SeqCst));
}

#[tokio::test]
async fn test_host_supports_async_hooks() {
    async fn echo_header(headers: HeaderMap) -> Response<Body> {
        let value = headers
            .get("x-async")
            .and_then(|value| value.to_str().ok())
            .unwrap_or("missing");

        Response::builder()
            .status(StatusCode::OK)
            .header("x-async", value)
            .body(Body::empty())
            .unwrap()
    }

    let before_seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
    let after_seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
    let app = App::new().merge_router(Router::new().route("/async", get(echo_header)));
    let host = TestHost::new(app)
        .before_each_async({
            let before_seen = Arc::clone(&before_seen);
            move |request| {
                let before_seen = Arc::clone(&before_seen);
                Box::pin(async move {
                    tokio::task::yield_now().await;
                    before_seen
                        .lock()
                        .await
                        .push(request.uri().path().to_string());
                    request
                        .headers_mut()
                        .insert("x-async", "enabled".parse().unwrap());
                })
            }
        })
        .after_each_async({
            let after_seen = Arc::clone(&after_seen);
            move |outcome| {
                let after_seen = Arc::clone(&after_seen);
                Box::pin(async move {
                    tokio::task::yield_now().await;
                    after_seen.lock().await.push(outcome.status());
                })
            }
        });

    host.scenario(|scenario| {
        scenario.get("/async");
        scenario.header_should_be("x-async", "enabled");
    })
    .await;

    assert_eq!(before_seen.lock().await.clone(), vec!["/async".to_string()]);
    assert_eq!(after_seen.lock().await.clone(), vec![StatusCode::OK]);
}

#[tokio::test]
async fn test_host_try_scenario_reports_default_status_failures() {
    let host = TestHost::from_router(Router::new());

    let error = host
        .try_scenario(|scenario| {
            scenario.get("/missing");
        })
        .await
        .expect_err("missing route should fail the default 200 expectation");

    let message = error.to_string();
    assert!(message.contains("GET /missing"));
    assert!(message.contains("Expected status 200 OK, got 404 Not Found"));
}

#[tokio::test]
async fn test_scenario_aliases_match_documented_api() {
    async fn echo(headers: HeaderMap, Json(payload): Json<serde_json::Value>) -> Response<Body> {
        let x_test = headers
            .get("x-test")
            .and_then(|value| value.to_str().ok())
            .unwrap_or("missing");

        Response::builder()
            .status(StatusCode::OK)
            .header("x-test", x_test)
            .header("content-type", "application/json")
            .body(Body::from(payload.to_string()))
            .unwrap()
    }

    let app = Router::new().route("/echo", post(echo));

    let response = test_post(app, "/echo")
        .with_header("x-test", "1")
        .with_json(&json!({ "ok": true }))
        .send()
        .await
        .assert_json_ok()
        .assert_header("x-test", "1");

    assert_eq!(response.json_value().await["ok"], json!(true));
}

#[tokio::test]
async fn test_host_supports_form_posts() {
    #[derive(Deserialize)]
    struct SignupForm {
        email: String,
    }

    async fn signup(Form(form): Form<SignupForm>) -> Json<serde_json::Value> {
        Json(json!({ "email": form.email }))
    }

    let app = App::new().merge_router(Router::new().route("/signup", post(signup)));
    let host = TestHost::new(app);

    let outcome = host
        .scenario(|scenario| {
            scenario.post("/signup");
            scenario.with_form(&[("email", "form@example.com")]);
            scenario.json_path_should_be("email", json!("form@example.com"));
        })
        .await;

    assert_eq!(outcome.json_value()["email"], json!("form@example.com"));
}

#[tokio::test]
async fn test_host_supports_redirect_assertions() {
    async fn redirect_handler() -> Response<Body> {
        Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header("location", "/done")
            .body(Body::empty())
            .unwrap()
    }

    let app = App::new().merge_router(Router::new().route("/old", get(redirect_handler)));
    let host = TestHost::new(app);

    let outcome = host
        .scenario(|scenario| {
            scenario.get("/old");
            scenario.status_code_should_be(303);
            scenario.redirect_to_should_be("/done");
        })
        .await;

    assert_eq!(outcome.status(), StatusCode::SEE_OTHER);
}