mod common;
use common::run_lua_local;
#[tokio::test]
async fn test_http_serve_get_body() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/health"] = function(req) return { status = 200, body = "ok" } end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/health")
assert.eq(resp.status, 200)
assert.eq(resp.body, "ok")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_post_body() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
POST = {
["/submit"] = function(req)
return { status = 201, body = req.body }
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.post("http://127.0.0.1:" .. port .. "/submit", "hello world")
assert.eq(resp.status, 201)
assert.eq(resp.body, "hello world")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_json_response() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/data"] = function(req)
return { status = 200, json = { items = {1, 2, 3} } }
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/data")
assert.eq(resp.status, 200)
assert.contains(resp.headers["content-type"], "application/json")
local data = json.parse(resp.body)
assert.eq(data.items[1], 1)
assert.eq(data.items[2], 2)
assert.eq(data.items[3], 3)
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_custom_headers() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/custom"] = function(req)
return {
status = 200,
body = "with headers",
headers = { ["x-custom"] = "test-value" }
}
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/custom")
assert.eq(resp.status, 200)
assert.eq(resp.headers["x-custom"], "test-value")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_404_unregistered() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/exists"] = function(req) return { body = "here" } end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/missing")
assert.eq(resp.status, 404)
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_multiple_methods_same_path() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/resource"] = function(req) return { body = "get-result" } end,
},
POST = {
["/resource"] = function(req) return { status = 201, body = "post-result" } end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local get_resp = http.get("http://127.0.0.1:" .. port .. "/resource")
assert.eq(get_resp.status, 200)
assert.eq(get_resp.body, "get-result")
local post_resp = http.post("http://127.0.0.1:" .. port .. "/resource", "")
assert.eq(post_resp.status, 201)
assert.eq(post_resp.body, "post-result")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_request_query() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/search"] = function(req)
return { body = req.query }
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/search?q=hello&page=1")
assert.eq(resp.status, 200)
assert.eq(resp.body, "q=hello&page=1")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_request_headers() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/echo-header"] = function(req)
return { body = req.headers["x-test-header"] or "missing" }
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/echo-header", {
headers = { ["x-test-header"] = "my-value" }
})
assert.eq(resp.status, 200)
assert.eq(resp.body, "my-value")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_sse() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/events"] = function(req)
return {
status = 200,
sse = function(send)
send({ data = "hello" })
send({ event = "update", data = "world" })
send({ event = "done", data = "bye", id = "3" })
end
}
end,
}
})
end)
sleep(0.2)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/events")
assert.eq(resp.status, 200)
assert.contains(resp.headers["content-type"], "text/event-stream")
-- Verify SSE events are present in order
local hello_idx = string.find(resp.body, "data: hello", 1, true)
assert.ne(hello_idx, nil)
local update_idx = string.find(resp.body, "event: update", hello_idx + 1, true)
assert.ne(update_idx, nil)
local world_idx = string.find(resp.body, "data: world", update_idx + 1, true)
assert.ne(world_idx, nil)
local done_idx = string.find(resp.body, "event: done", world_idx + 1, true)
assert.ne(done_idx, nil)
local bye_idx = string.find(resp.body, "data: bye", done_idx + 1, true)
assert.ne(bye_idx, nil)
local id_idx = string.find(resp.body, "id: 3", bye_idx + 1, true)
assert.ne(id_idx, nil)
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_custom_content_type() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/html"] = function(req)
return {
status = 200,
body = "<h1>ok</h1>",
headers = { ["content-type"] = "text/html" }
}
end,
}
})
end)
sleep(0.2)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/html")
assert.eq(resp.status, 200)
assert.eq(resp.headers["content-type"], "text/html")
assert.eq(resp.body, "<h1>ok</h1>")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_async_handler() {
run_lua_local(
r#"
-- Start a simple backend server
local backend = async.spawn(function()
http.serve(0, {
GET = {
["/data"] = function(req)
return { status = 200, body = "backend-response" }
end,
}
})
end)
sleep(0.2)
local backend_port = _SERVER_PORT
-- Start a proxy server whose handler calls http.get (async inside handler)
local proxy = async.spawn(function()
http.serve(0, {
GET = {
["/proxy"] = function(req)
local resp = http.get("http://127.0.0.1:" .. backend_port .. "/data")
return {
status = resp.status,
body = "proxied: " .. resp.body
}
end,
}
})
end)
sleep(0.2)
local proxy_port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. proxy_port .. "/proxy")
assert.eq(resp.status, 200)
assert.eq(resp.body, "proxied: backend-response")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_query_params() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/search"] = function(req)
return {
status = 200,
json = {
raw_query = req.query,
name = req.params.name,
page = req.params.page,
}
}
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/search?name=hello&page=2")
assert.eq(resp.status, 200)
local data = json.parse(resp.body)
assert.eq(data.raw_query, "name=hello&page=2")
assert.eq(data.name, "hello")
assert.eq(data.page, "2")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_url_encoded_query_params() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/echo"] = function(req)
return {
status = 200,
json = {
challenge = req.params.challenge,
space = req.params.space,
plus = req.params.plus,
eq = req.params.eq,
unicode = req.params.unicode,
}
}
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
-- challenge ends with `=` (base64 padding) URL-encoded as %3D
-- space encoded as %20 and as `+`
-- raw `=` mid-value, and a unicode char
local resp = http.get("http://127.0.0.1:" .. port
.. "/echo?challenge=abc%3D&space=hello%20world&plus=hello+world&eq=a%3Db&unicode=caf%C3%A9")
assert.eq(resp.status, 200)
local data = json.parse(resp.body)
assert.eq(data.challenge, "abc=")
assert.eq(data.space, "hello world")
assert.eq(data.plus, "hello world")
assert.eq(data.eq, "a=b")
assert.eq(data.unicode, "café")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_multi_value_header() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let vm = common::create_vm();
let local = tokio::task::LocalSet::new();
let buf: String = local
.run_until(async {
let script = r#"
async.spawn(function()
http.serve(0, {
GET = {
["/multi-cookie"] = function(req)
return {
status = 200,
body = "ok",
headers = {
["Set-Cookie"] = {
"a=1; Path=/",
"b=2; Path=/",
},
},
}
end,
}
})
end)
sleep(0.1)
return _SERVER_PORT
"#;
let port: i64 = vm
.load(assay::lua::async_bridge::strip_shebang(script))
.eval_async()
.await
.unwrap();
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.unwrap();
stream
.write_all(
b"GET /multi-cookie HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
let mut buf = String::new();
tokio::time::timeout(std::time::Duration::from_secs(5), stream.read_to_string(&mut buf))
.await
.expect("timeout reading raw response")
.unwrap();
buf
})
.await;
assert!(buf.contains("set-cookie: a=1"), "missing a=1 in: {buf}");
assert!(buf.contains("set-cookie: b=2"), "missing b=2 in: {buf}");
}
#[tokio::test]
async fn test_http_serve_empty_query_params() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/noquery"] = function(req)
return {
status = 200,
json = {
raw_query = req.query,
has_params = next(req.params) ~= nil,
}
}
end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local resp = http.get("http://127.0.0.1:" .. port .. "/noquery")
assert.eq(resp.status, 200)
local data = json.parse(resp.body)
assert.eq(data.raw_query, "")
assert.eq(data.has_params, false)
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_http_serve_wildcard_route() {
run_lua_local(
r#"
local server = async.spawn(function()
http.serve(0, {
GET = {
["/exact"] = function(req) return { status = 200, body = "exact" } end,
["/api/*"] = function(req) return { status = 200, body = "api:" .. req.path } end,
["/*"] = function(req) return { status = 200, body = "catch:" .. req.path } end,
}
})
end)
sleep(0.1)
local port = _SERVER_PORT
local base = "http://127.0.0.1:" .. port
-- Exact match takes priority
local r1 = http.get(base .. "/exact")
assert.eq(r1.body, "exact")
-- Prefix wildcard matches
local r2 = http.get(base .. "/api/users")
assert.eq(r2.body, "api:/api/users")
-- Nested paths match the most specific wildcard
local r3 = http.get(base .. "/api/users/123")
assert.eq(r3.body, "api:/api/users/123")
-- Root wildcard catches everything else
local r4 = http.get(base .. "/other/page")
assert.eq(r4.body, "catch:/other/page")
-- Root wildcard catches single-segment paths
local r5 = http.get(base .. "/hello")
assert.eq(r5.body, "catch:/hello")
"#,
)
.await
.unwrap();
}