import assert from "node:assert/strict"; import http from "node:http"; import * as runner from "../runner.mjs"; import * as u from "../../src/utils.mjs"; import { normalizeSegments, pathToSegments, hasPathParams, addRoute, findStaticHandler, firstParamMatch, findDynamicHandler, findHandler, extractQueryParams, handleRequest, makeRequestListener, log, interceptorsFn, interceptors, defaultInterceptors, chainInterceptors, wrapHandler, buildRoutes, promisifyServer, buildServer, } from "../../src/hero.mjs"; const test_normalizeSegments = t => { t.start("normalizeSegments()"); t.test("unchanged when already normalized", () => { assert.deepEqual(normalizeSegments([""]), [""]); }); t.test("empty terminator added when missing", () => { assert.deepEqual(normalizeSegments([]), [""]); assert.deepEqual(normalizeSegments([" "]), [" ", ""]); assert.deepEqual(normalizeSegments(["_"]), ["_", ""]); assert.deepEqual(normalizeSegments(["a"]), ["a", ""]); assert.deepEqual(normalizeSegments([":a"]), [":a", ""]); assert.deepEqual(normalizeSegments(["path"]), ["path", ""]); }); }; const test_pathToSegments = t => { t.start("pathToSegments()"); t.test("simple paths", () => { assert.deepEqual(pathToSegments("/"), [ "" ]); assert.deepEqual(pathToSegments("/simple"), ["simple", ""]); assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]); assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]); assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]); }); t.test("extra separators", () => { assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]); assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]); assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]); }); }; const test_hasPathParams = t => { t.start("hasPathParam()"); t.test("has it", () => { assert(hasPathParams(["some", ":path", ""])); assert(hasPathParams(["path", ":params", ""])); assert(hasPathParams(["api", "user", ":id", "info", ""])); }); t.test("doesn't have it", () => { assert(!hasPathParams([])); assert(!hasPathParams([ "" ])); assert(!hasPathParams(["some", "path", ""])); assert(!hasPathParams(["s:o:m:e:", "p::ath:::"])); assert(!hasPathParams([" :"])); }); }; const test_addRoute = t => { t.start("addRoute()"); const fn1 = () => {}; t.test("daily usage examples", () => { assert.deepEqual( addRoute({}, "GET", "/home", fn1), { static: { GET: { home: { "": fn1 }}}}, ); assert.deepEqual( addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1), { static: { PUT: { api: { user: { "": fn1 }}}, POST: { api: { user: { "": fn1 }}}, PATCH: { api: { user: { "": fn1 }}}, }, }, ); assert.deepEqual( addRoute({}, "*", "/settings", fn1), { static: { GET: { settings: { "": fn1 }}, HEAD: { settings: { "": fn1 }}, POST: { settings: { "": fn1 }}, PUT: { settings: { "": fn1 }}, PATCH: { settings: { "": fn1 }}, DELETE: { settings: { "": fn1 }}, OPTIONS: { settings: { "": fn1 }}, }, }, ); assert.deepEqual( addRoute({}, "GET", "/", fn1), { static: { GET: { "": fn1 }}}, ); assert.deepEqual( addRoute({}, "GET", "/api/user/:id", fn1), { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}}, ); assert.deepEqual( addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1), { dynamic: { PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, }, }, ); }); t.test("bad method", () => { assert.throws( () => addRoute({}, "VERB", "/path", fn1), assert.AssertionError, ); }); t.test("empty methods array", () => { assert.deepEqual(addRoute({}, [], "", fn1), {}); }); t.test("subpaths", () => { const fn1 = () => {}; const fn2 = () => {}; const t1 = addRoute({}, "GET", "/home", fn1); const t2 = addRoute(t1, "GET", "/home/details", fn2); assert.deepEqual( t2, { static: { GET: { home: { "": fn1, details: { "": fn2, }, }, }, }, }, ); }); }; const test_findStaticHandler = t => { t.start("findStaticHandler()"); t.test("multiple accesses to the same table", () => { const fn1 = () => {}; const fn2 = () => {}; const fn3 = () => {}; const table = { static: { GET: { api: { home: { "": fn1 }, settings: { "": fn2 }, }, }, POST: { api: { settings: { "": fn3 }, }, }, }, }; assert.deepEqual( findStaticHandler(table, "GET", [ "api", "home", "" ]), { handlerFn: fn1, params: {} }, ); assert.deepEqual( findStaticHandler(table, "PUT", [ "api", "home", "" ]), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "settings", "" ]), { handlerFn: fn2, params: {} }, ); assert.deepEqual( findStaticHandler(table, "POST", [ "api", "settings", "" ]), { handlerFn: fn3, params: {} }, ); assert.deepEqual( findStaticHandler(table, "PUT", [ "api", "settings", "" ]), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "profile", "" ]), null, ); assert.deepEqual( findStaticHandler({}, "GET", [ "api", "profile", "" ]), null, ); }); }; const test_firstParamMatch = t => { t.start("firstParamMatch()"); const params = {}; const fn1 = () => {}; const fn2 = () => {}; t.test("we BACKTRACK when searching down routes", () => { const segments = [ "path", "split", "match", "" ]; const tree = { path: { split: { MISMATCH: { "": fn1, }, }, ":param": { match: { "": fn2, }, }, }, }; assert.deepEqual( firstParamMatch(tree, segments, params), { handlerFn: fn2, params: { param: "split" }}, ); }); t.test("ambiguous route prefers params at the end", () => { const segments = [ "path", "param1", "param2", "" ]; const tree1 = { path: { ":shallower": { param2: { "": fn2, }, }, }, }; const tree2 = u.assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1); assert.deepEqual( firstParamMatch(tree1, segments, params), { handlerFn: fn2, params: { shallower: "param1" }}, ); assert.deepEqual( firstParamMatch(tree2, segments, params), { handlerFn: fn1, params: { deeper: "param2" }}, ); }); t.test("when 2 params are possible, we pick the first alphabetically", () => { const segments = [ "user", "someId", "" ]; const tree1 = { user: { ":bbb": { "": fn2, }, }, }; const tree2 = u.assocIn(tree1, ["user", ":aaa", ""], fn1); assert.deepEqual( firstParamMatch(tree1, segments, params), { handlerFn: fn2, params: { bbb: "someId" }}, ); assert.deepEqual( firstParamMatch(tree2, segments, params), { handlerFn: fn1, params: { aaa: "someId" }}, ); }); t.test(`we deal with segments that start with ":"`, () => { const segments = [ "path", ":param", "" ]; const tree = { path: { ":arg": { "": fn1, }, }, }; assert.deepEqual( firstParamMatch(tree, segments, params), { handlerFn: fn1, params: { arg: ":param" }}, ); }); }; const test_findDynamicHandler = t => { t.start("findDynamicHandler()"); t.test("daily usage cases", () => { const fn1 = () => {}; const fn2 = () => {}; const table = { dynamic: { GET: { users: { "by-id": { ":id": { "": fn1 }, }, }, }, PUT: { user: { ":user-id": { info: { "": fn2 }, }, }, }, }, }; assert.deepEqual( findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]), { handlerFn: fn1, params: { "id": "123" }}, ); assert.deepEqual( findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]), null, ); assert.deepEqual( findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]), null, ); assert.deepEqual( findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]), { handlerFn: fn2, params: { "user-id": "deadbeef" }}, ); }); }; const test_findHandler = t => { t.start("findHandler()"); t.test("mix of static and dynamic routes", () => { const static1 = () => {}; const static2 = () => {}; const static3 = () => {}; const dynamic1 = () => {}; const dynamic2 = () => {}; const dynamic3 = () => {}; const dynamic4 = () => {}; const table = { static: { GET: { user: { "": static1, }, pages: { "": static2, home: { "": static3, }, }, }, }, dynamic: { GET: { user: { ":id": { "": dynamic1, }, }, }, PUT: { user: { ":id": { "": dynamic2, "info": { "": dynamic3, }, "preferences": { "": dynamic4, }, }, }, }, }, }; assert.deepEqual( findHandler(table, "GET", "/"), null, ); assert.deepEqual( findHandler(table, "GET", "/user/"), { handlerFn: static1, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/pages"), { handlerFn: static2, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/pages/home/"), { handlerFn: static3, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/user/some-id"), { handlerFn: dynamic1, params: { id: "some-id" }}, ); assert.deepEqual( findHandler(table, "GET", "/user/other-id/info"), null, ); assert.deepEqual( findHandler(table, "PUT", "/user/other-id/info"), { handlerFn: dynamic3, params: { id: "other-id" }}, ); assert.deepEqual( findHandler(table, "PUT", "/user/another-id/preferences"), { handlerFn: dynamic4, params: { id: "another-id" }}, ); assert.deepEqual( findHandler(table, "POST", "/user/another-id/preferences"), null, ); }); }; const test_extractQueryParams = t => { t.start("extractQueryParams()"); t.test("empty values", () => { assert.deepEqual(extractQueryParams(), {}); assert.deepEqual(extractQueryParams(null), {}); assert.deepEqual(extractQueryParams(undefined), {}); }); t.test("we get a flat key-value strings", () => { assert.deepEqual( extractQueryParams("a[]=1&b=text&c=___"), { "a[]": "1", b: "text", c: "___", }, ); }); t.test("duplicate values are suppressed, deterministically", () => { assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" }); assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" }); }); }; const test_handleRequest = t => { t.start("handleRequest()"); const fn = req => req; t.test("request without params", async () => { const table = { static: { GET: { "": fn, }, }, }; assert.deepEqual( await handleRequest(table, "GET", "/?q=1"), { params: { path: {}, query: { q: "1", }, }, method: "GET", path: "/", handler: fn, }, ); }); t.test("request with params", async () => { const table = { dynamic: { PUT: { api: { user: { ":userId": { "": fn, }, }, }, }, }, }; assert.deepEqual( await handleRequest(table, "PUT", "/api/user/2222"), { params: { path: { userId: "2222", }, query: {}, }, method: "PUT", path: "/api/user/2222", handler: fn, }, ); }); t.test("missing route", async () => { assert.deepEqual( await handleRequest({}, "GET", "/"), { status: 404, body: "Not Found\n", }, ); }); }; const test_makeRequestListener = t => { t.start("makeRequestListener()"); t.test("straightforward body execution", async () => { const fn = _ => ({ status: "test status", body: "test body", headers: "test headers", }); const routes = [[ "GET", "/route1", fn ]]; const requestListener = makeRequestListener(buildRoutes(routes)); const req = { method: "GET", url: "/route1", }; const heads = []; const bodies = []; const res = { writeHead: (status, headers) => heads.push({ status, headers }), end: body => bodies.push(body), }; await requestListener(req, res); assert.deepEqual( heads, [{ status: "test status", headers: "test headers", }], ); assert.deepEqual( bodies, [ "test body" ], ); }); t.test("we break if handleRequest() throws an error", async () => { const fn = _ => { throw new Error("handler error"); }; const routes = [[ "GET", "/route2", fn ]]; const requestListener = makeRequestListener(buildRoutes(routes)); const req = { method: "GET", url: "/route2", }; const heads = []; const bodies = []; const res = { writeHead: (status, headers) => heads.push({ status, headers }), end: body => bodies.push(body), }; await assert.rejects( async () => await requestListener(req, res), { message: "handler error" }, ); assert.deepEqual(heads, []); assert.deepEqual(bodies, []); }); }; const test_log = t => { t.start("log()"); t.test("we can log data", () => { log({ a: 1, type: "log-test" }); }); t.test("we can't log unserializable things", () => { const obj = { self: null }; obj.self = obj; assert.throws(() => log(obj), TypeError); }); }; const test_interceptorsFn = t => { const next = x => x; { t.start("interceptorsFn().requestId()"); let i = 0; const uuidFn = () => `${i++}`; t.test("we add an id to whatever we receive", () => { assert.deepEqual( interceptorsFn({uuidFn}).requestId({}, next), { id: "0", }, ); assert.deepEqual( interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next), { a: "existing data", id: "1", }, ); }); t.test(`we overwrite the "id" if it already exists`, async () => { assert.deepEqual( interceptorsFn({uuidFn}).requestId({ id: "before" }, next), { id: "2", }, ); }); } { t.start("interceptorsFn().logged()"); t.test("we log before and after next()", async () => { const contents = []; const logger = x => contents.push(x); const status = 201; const req = { id: "an ID", url: "a URL", method: "a method", }; assert.deepEqual( await interceptorsFn({logger}).logged(req, _ => ({ status })), { status }, ); assert.deepEqual( contents, [ { ...req, type: "in-request" }, { id: req.id, status, type: "in-response" }, ], ); }); } { t.start("interceptorsFn().contentType()"); t.test("empty values", async () => { assert.deepEqual( await interceptorsFn().contentType({}, next), { body: "", headers: { "Content-Type": "application/json", "Content-Length": 0, }, }, ); assert.deepEqual( await interceptorsFn().contentType({ body: "" }, next), { body: "", headers: { "Content-Type": "text/html", "Content-Length": 0, }, }, ); }); t.test("body values", async () => { assert.deepEqual( await interceptorsFn().contentType({ body: { a: 1 }}, next), { body: `{"a":1}`, headers: { "Content-Type": "application/json", "Content-Length": 7, }, }, ); assert.deepEqual( await interceptorsFn().contentType({ body: "
" }, next), { body: "
", headers: { "Content-Type": "text/html", "Content-Length": 6, }, }, ); }); t.test("header values preference", async () => { assert.deepEqual( await interceptorsFn().contentType({ body: "", headers: { "Content-Type": "we have preference", "Content-Length": "and so do we", }, }, next), { body: "", headers: { "Content-Type": "we have preference", "Content-Length": "and so do we", }, } ); }); t.test("headers get propagated", async () => { assert.deepEqual( await interceptorsFn().contentType({ body: "", headers: { "My": "Header", }, }, next), { body: "", headers: { "My": "Header", "Content-Type": "text/html", "Content-Length": 0, }, }, ); }); } { t.start("interceptorsFn().serverError()"); t.test("no-op when no error occurs", async () => { assert.deepEqual( await interceptorsFn().serverError({ status: 1 }, next), { status: 1 }, ); }); t.test(`an error is thrown if "status" is missing`, async () => { const contents = []; const logger = x => contents.push(x); assert.deepEqual( await interceptorsFn({ logger }).serverError({ id: 123 }, next), { status: 500, body: "Internal Server Error\n", }, ); assert.deepEqual( contents, [{ id: 123, type: "server-error-interceptor", message: `Missing "status"`, }], ); }); t.test("we turn a handler error into a 500 response", async () => { const contents = []; const logger = x => contents.push(x); assert.deepEqual( await interceptorsFn({ logger }).serverError( { id: "some ID" }, _ => { throw new Error("My test error message"); }, ), { status: 500, body: "Internal Server Error\n", }, ); assert.deepEqual( contents, [{ id: "some ID", type: "server-error-interceptor", message: "My test error message", }], ); }); } }; const test_chainInterceptors = t => { t.start("chainInterceptors()"); t.test("empty values", () => { assert.equal(chainInterceptors([])("req"), "req"); }); t.test("the order of interceptors matter", () => { const a = []; const i1 = (req, next) => { a.push("i1"); return next(req); }; const i2 = (req, next) => { a.push("i2"); return next(req); }; assert.equal(chainInterceptors([i1, i2])("req"), "req"); assert.deepEqual(a, ["i1", "i2"]); assert.equal(chainInterceptors([i2, i1])("req"), "req"); assert.deepEqual(a, ["i1", "i2", "i2", "i1"]); }); t.test("with ordering, interceptors implicitly depend on each other", () => { const i1 = (req, next) => next({ ...req, id: 1 }); const i2 = (req, next) => next({ ...req, id: req.id + 1 }); assert.deepEqual( chainInterceptors([i1, i2])({}), { id: 2 }, ); assert.deepEqual( chainInterceptors([i2])({}), { id: NaN }, ); assert.deepEqual( chainInterceptors([i2, i1])({}), { id: 1 }, ); }); t.test("we can chain async interceptors", async () => { const i1 = async (req, next) => await next({ ...req, i1: true }); const i2 = async (req, next) => await next({ ...req, i2: true }); assert.deepEqual( await chainInterceptors([i1, i2])({}), { i1: true, i2: true, }, ); }); }; const test_wrapHandler = t => { t.start("wrapHandler()"); t.test("noop when arr is empty", () => { const fn = () => {}; const wrappedFn1 = wrapHandler(fn, []); assert.deepEqual(fn, wrappedFn1); const wrappedFn2 = wrapHandler(fn, [ interceptors.requestId ]); assert.notDeepEqual(fn, wrappedFn2); }); t.test("a handler with chained interceptors change its behaviour", async () => { let i = 0; const uuidFn = () => `${i++}`; const contents = []; const logger = x => contents.push(x); const interceptors = interceptorsFn({uuidFn, logger}); const fn = async _ => await { status: 1, body: { a: 1 }}; const wrappedFn = wrapHandler(fn, [ interceptors.requestId, interceptors.logged, interceptors.contentType, ]); const req = { url: "URL", method: "METHOD", }; assert.deepEqual( await fn(req), { status: 1, body: { a: 1 }}, ); assert.deepEqual(contents, []); assert.deepEqual( await wrappedFn(req), { status: 1, body: `{"a":1}`, headers: { "Content-Type": "application/json", "Content-Length": 7, }, }, ); assert.deepEqual( contents, [ { id: "0", url: "URL", method: "METHOD", type: "in-request", }, { id: "0", status: 1, type: "in-response", }, ], ); }); }; const test_buildRoutes = t => { t.start("buildRoutes()"); t.test("empty values", () => { assert.deepEqual(buildRoutes([]), {}); }); t.test("overwrites", () => { const fn1 = () => {}; const fn2 = () => {}; const r1 = [ [ "GET", "/", fn1 ] ]; const r2 = [ [ "GET", "/", fn2 ] ]; assert.deepEqual( buildRoutes(r1), { static: { GET: { "": fn1, }, }, }, ); assert.deepEqual( buildRoutes(r2), { static: { GET: { "": fn2, }, }, }, ); assert.deepEqual( buildRoutes(r1.concat(r2)), buildRoutes(r2), ); assert.deepEqual( buildRoutes(r2.concat(r1)), buildRoutes(r1), ); }); t.test("wrapped handler functions", async () => { const handler = req => ({ ...req, handled: true }); const interceptor = (req, next) => next({ ...req, intercepted: true }); const routes = [ [ "GET", "/without", handler ], [ "GET", "/with", handler, [ interceptor ] ], ]; const table = buildRoutes(routes); { const { handled, intercepted } = await handleRequest(table, "GET", "/without"); assert.deepEqual( { handled, intercepted }, { handled: true, intercepted: undefined }, ); } { const { handled, intercepted } = await handleRequest(table, "GET", "/with"); assert.deepEqual( { handled, intercepted }, { handled: true, intercepted: true }, ); } }); t.test("interceptors are combined", async () => { const handler = req => ({ ...req, handled: true }); const interceptor1 = (req, next) => next({ ...req, interceptor1: true }); const interceptor2 = (req, next) => next({ ...req, interceptor2: true }); const routes = [ [ "GET", "/global-only", handler ], [ "GET", "/global-and-local", handler, [ interceptor2 ] ], ]; const table = buildRoutes(routes, [ interceptor1 ]); { const { handled, interceptor1, interceptor2 } = await handleRequest(table, "GET", "/global-only"); assert.deepEqual( { handled, interceptor1, interceptor2 }, { handled: true, interceptor1: true, interceptor2: undefined }, ); } { const { handled, interceptor1, interceptor2 } = await handleRequest(table, "GET", "/global-and-local"); assert.deepEqual( { handled, interceptor1, interceptor2 }, { handled: true, interceptor1: true, interceptor2: true }, ); } }); t.test("multiple routes built", () => { const fn1 = () => {}; const fn2 = () => {}; const fn3 = () => {}; const fn4 = () => {}; const fn5 = () => {}; const routes = [ [ "GET", "/", fn1 ], [ "GET", "/home", fn2 ], [ "GET", "/user", fn3 ], [ "POST", "/user", fn4 ], [ "GET", "/user/:id", fn5 ], ]; assert.deepEqual( buildRoutes(routes), { static: { GET: { "": fn1, home: { "": fn2, }, user: { "": fn3, }, }, POST: { user: { "": fn4, }, }, }, dynamic: { GET: { user: { ":id": { "": fn5, } } } }, }, ); }); }; const test_promisifyServer = t => { t.start("promisifyServer()"); t.test("we can access the underlying server ref", () => { const server = promisifyServer(http.createServer(() => {})); assert.ok(server.ref instanceof http.Server); }); }; const test_buildServer = t => { t.start("buildServer()"); const socketRequest = (socketPath, path) => new Promise((resolve, reject) => { const callback = res => { let body = ""; res.on("data", chunk => body += chunk); res.on("error", reject); res.on("end", () => resolve({ body, status: res.statusCode, })); }; const request = http.request({ socketPath, path, }, callback); request.end(); }); t.test("empty values", async () => { const socketPath = "./tests/hero-0.sock"; const server = buildServer([]); await server.listen(socketPath); const response = await socketRequest(socketPath, "/anything"); await server.close(); assert.deepEqual(response, { status: 404, body: "Not Found\n" }); }); t.test("integrated application server", async () => { const socketPath = "./tests/hero-1.sock"; const pathHandler = req => ({ status: 200, body: "OK" }); const routes = [ [ "GET", "/path", pathHandler ] ]; const server = buildServer(routes, defaultInterceptors); await server.listen(socketPath); const response = await socketRequest(socketPath, "/path"); await server.close(); assert.deepEqual(response, { status: 200, body: "OK" }); }); }; await runner.runTests([ test_normalizeSegments, test_pathToSegments, test_hasPathParams, test_addRoute, test_findStaticHandler, test_firstParamMatch, test_findDynamicHandler, test_findHandler, test_extractQueryParams, test_handleRequest, test_makeRequestListener, test_log, test_interceptorsFn, test_chainInterceptors, test_wrapHandler, test_buildRoutes, test_promisifyServer, test_buildServer, ]);