what-core 1.7.5

Core framework for What - an HTML-first web framework powered by Rust
Documentation
mod common;

use axum::http::StatusCode;
use common::*;
use what_core::server::create_router;

// ---------------------------------------------------------------------------
// Helper: extract session cookie value from Set-Cookie header
// ---------------------------------------------------------------------------

fn extract_session_id(set_cookie: &str) -> &str {
    // "w_session=abc123; HttpOnly; ..." → "abc123"
    let start = set_cookie.find('=').unwrap() + 1;
    let end = set_cookie[start..].find(';').unwrap() + start;
    &set_cookie[start..end]
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[tokio::test]
async fn new_session_sets_cookie() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);

    let cookie = resp.set_cookie().expect("should have Set-Cookie header");
    assert!(
        cookie.contains("w_session="),
        "cookie should contain session id: {}",
        cookie
    );
}

#[tokio::test]
async fn session_reused_on_subsequent_request() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // First request — new session
    let resp1 = get(&router, "/").await;
    let cookie_header = resp1.set_cookie().expect("first request should set cookie");
    let session_id = extract_session_id(cookie_header);

    // Second request — send cookie back
    let cookie_val = format!("w_session={}", session_id);
    let resp2 = get_with_headers(&router, "/", vec![("cookie", &cookie_val)]).await;

    // Should not get a new Set-Cookie header (session reused)
    assert!(
        resp2.set_cookie().is_none(),
        "second request should not set a new cookie"
    );
}

#[tokio::test]
async fn session_variable_reactive_wrapping_in_body() {
    let proj = TestProject::new();
    proj.add_page(
        "index.html",
        "<what>\nsession.count += 1\n</what>\n<p>#session.count#</p>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("w-bind=\"session.count\"");
}

#[tokio::test]
async fn session_variable_no_wrapping_in_attributes() {
    let proj = TestProject::new();
    proj.add_page(
        "index.html",
        "<what>\nsession.count += 1\n</what>\n<div data-count=\"#session.count#\">Text</div>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);
    // The attribute value should be the plain number, not wrapped
    resp.assert_contains("data-count=\"1\"");
    // Make sure the w-bind span is NOT inside the attribute
    resp.assert_not_contains("data-count=\"<span");
}

#[tokio::test]
async fn session_mutation_increment() {
    let proj = TestProject::new();
    proj.add_page(
        "counter.html",
        "<what>\nsession.counter += 1\n</what>\n<p>#session.counter#</p>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // First request — counter should be 1
    let resp1 = get(&router, "/counter").await;
    assert_eq!(resp1.status, StatusCode::OK);
    resp1.assert_contains(">1<");

    let cookie_header = resp1.set_cookie().unwrap();
    let session_id = extract_session_id(cookie_header);
    let cookie_val = format!("w_session={}", session_id);

    // Second request — counter should be 2
    let resp2 = get_with_headers(&router, "/counter", vec![("cookie", &cookie_val)]).await;
    assert_eq!(resp2.status, StatusCode::OK);
    resp2.assert_contains(">2<");
}

#[tokio::test]
async fn session_mutation_set() {
    let proj = TestProject::new();
    proj.add_page(
        "hello.html",
        "<what>\nsession.name = \"Alice\"\n</what>\n<p>#session.name#</p>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/hello").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Alice");
}

#[tokio::test]
async fn session_mutation_push() {
    let proj = TestProject::new();
    proj.add_page(
        "items.html",
        "<what>\nsession.items.push(\"hello\")\n</what>\n<loop data=\"#session.items#\"><span>#item#</span></loop>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/items").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("hello");
}

#[tokio::test]
async fn session_reset_endpoint() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // Get a session first (also gives us a CSRF token)
    let resp1 = get(&router, "/").await;
    let cookie_header = resp1.set_cookie().unwrap();
    let old_id = extract_session_id(cookie_header);
    let cookie_val = format!("w_session={}", old_id);
    let csrf = resp1.csrf_token().expect("should have CSRF token");

    // POST /w-session/reset with CSRF token
    let resp2 = post_form_with_headers(
        &router,
        "/w-session/reset",
        "redirect=/",
        vec![("cookie", &cookie_val), ("X-CSRF-Token", &csrf)],
    )
    .await;

    assert_eq!(resp2.status, StatusCode::SEE_OTHER);

    // Should get a new Set-Cookie with a different session ID
    let new_cookie = resp2.set_cookie().expect("reset should set new cookie");
    let new_id = extract_session_id(new_cookie);
    assert_ne!(old_id, new_id, "reset should create a new session");
}

#[tokio::test]
async fn session_clear_data_endpoint() {
    let proj = TestProject::new();
    proj.add_page(
        "set.html",
        "<what>\nsession.val = \"keep\"\n</what>\n<p>#session.val#</p>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // Visit page to set session data (also gives us a CSRF token)
    let resp1 = get(&router, "/set").await;
    let cookie_header = resp1.set_cookie().unwrap();
    let session_id = extract_session_id(cookie_header);
    let cookie_val = format!("w_session={}", session_id);
    let csrf = resp1.csrf_token().expect("should have CSRF token");

    // POST /w-session/clear-data with CSRF token
    let resp2 = post_form_with_headers(
        &router,
        "/w-session/clear-data",
        "",
        vec![("cookie", &cookie_val), ("X-CSRF-Token", &csrf)],
    )
    .await;

    // Should redirect back (303 See Other)
    assert_eq!(resp2.status, StatusCode::SEE_OTHER);
}

#[tokio::test]
async fn session_mutation_pushmax_caps_array() {
    let proj = TestProject::new();
    // pushmax(3, ...) should keep at most 3 items, dropping oldest
    proj.add_page(
        "add.html",
        "<what>\nsession.log.pushmax(3, \"entry\")\n</what>\n<p>#session.log#</p>",
    );
    proj.add_page(
        "read.html",
        "<loop data=\"#session.log#\"><span>#item#</span></loop>",
    );
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // Make 5 requests to push 5 entries (max 3)
    let resp1 = get(&router, "/add").await;
    let cookie_header = resp1.set_cookie().unwrap();
    let session_id = extract_session_id(cookie_header);
    let cookie_val = format!("w_session={}", session_id);

    get_with_headers(&router, "/add", vec![("cookie", &cookie_val)]).await;
    get_with_headers(&router, "/add", vec![("cookie", &cookie_val)]).await;
    get_with_headers(&router, "/add", vec![("cookie", &cookie_val)]).await;
    get_with_headers(&router, "/add", vec![("cookie", &cookie_val)]).await;

    // Read the array — should have exactly 3 items, not 5
    let resp = get_with_headers(&router, "/read", vec![("cookie", &cookie_val)]).await;
    assert_eq!(resp.status, StatusCode::OK);

    // Count <span> occurrences — should be exactly 3
    let count = resp.body.matches("<span>").count();
    assert_eq!(
        count, 3,
        "pushmax(3) should cap array at 3 items, got {}",
        count
    );
}

#[tokio::test]
async fn session_data_persists_across_requests() {
    let proj = TestProject::new();
    proj.add_page(
        "set.html",
        "<what>\nsession.val = \"persisted\"\n</what>\n<p>set</p>",
    );
    proj.add_page("read.html", "<p>#session.val#</p>");
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    // Visit page A to set session data
    let resp1 = get(&router, "/set").await;
    assert_eq!(resp1.status, StatusCode::OK);

    let cookie_header = resp1.set_cookie().unwrap();
    let session_id = extract_session_id(cookie_header);
    let cookie_val = format!("w_session={}", session_id);

    // Visit page B with same cookie — should see session value
    let resp2 = get_with_headers(&router, "/read", vec![("cookie", &cookie_val)]).await;
    assert_eq!(resp2.status, StatusCode::OK);
    resp2.assert_contains("persisted");
}