diff options
Diffstat (limited to 'tests/js/hero.mjs')
-rw-r--r-- | tests/js/hero.mjs | 1050 |
1 files changed, 1050 insertions, 0 deletions
diff --git a/tests/js/hero.mjs b/tests/js/hero.mjs new file mode 100644 index 0000000..09e6e54 --- /dev/null +++ b/tests/js/hero.mjs @@ -0,0 +1,1050 @@ +import assert from "node:assert/strict"; +import { runTests } from "../runner.mjs"; +import { assocIn } from "../../src/utils.mjs"; +import { + normalizeSegments, + pathToSegments, + hasPathParams, + addRoute, + buildRoutes, + findStaticHandler, + firstParamMatch, + findDynamicHandler, + findHandler, + extractQueryParams, + handleRequest, + makeRequestListener, + interceptorsFn, + chainInterceptors, + wrapHandler, +} 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_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("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_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 = 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 = 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" }}, + ); + }); +}; + +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", + }, + ); + }); +}; + +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_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: "<br />" }, next), + { + body: "<br />", + 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", + }, + ); + 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", + }, + ); + 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("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", + }, + ], + ); + }); +}; + + +await runTests([ + test_normalizeSegments, + test_pathToSegments, + test_hasPathParams, + test_addRoute, + test_buildRoutes, + test_findStaticHandler, + test_firstParamMatch, + test_findDynamicHandler, + test_findHandler, + test_extractQueryParams, + test_handleRequest, + test_makeRequestListener, + test_interceptorsFn, + test_chainInterceptors, + test_wrapHandler, +]); |