// As both our router and the gin-gonic router is based off the design of httprouter, we can use their tests to verify // our implementation import { describe, expect, test } from "bun:test"; import Router, { createParamsObj, reconstructRegisteredURL } from "./router"; import { Method } from "./types"; /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-unused-vars */ describe("router tree", () => { test("countParams", () => { expect(countParams("/path/:param1/static/*catch-all")).toEqual(2); expect(countParams("/:param".repeat(256))).toEqual(256); }); describe("treeAddAndGet", () => { const router = createRouter([ "/hi", "/contact", "/co", "/c", "/a", "/ab", "/doc/", "/doc/go_faq.html", "/doc/go1.html", "/α", "/β", ]); checkRequests(router, [ ["/a", false, "/a", null], ["/", true, "", null], ["/hi", false, "/hi", null], ["/contact", false, "/contact", null], ["/co", false, "/co", null], ["/con", true, "", null], // key mismatch ["/cona", true, "", null], // key mismatch ["/no", true, "", null], // no matching child ["/ab", false, "/ab", null], ["/α", false, "/α", null], ["/β", false, "/β", null], ]); }); describe("treeWildcard", () => { const router = createRouter([ "/", "/cmd/:tool/", "/cmd/:tool/:sub", "/cmd/whoami", "/cmd/whoami/root", "/cmd/whoami/root/", "/src/*filepath", "/search/", "/search/:query", "/search/gin-gonic", "/search/google", "/user/:name", "/user/:name/about", "/files/:dir/*filepath", "/doc/", "/doc/go_faq.html", "/doc/go1.html", "/info/:user/public", "/info/:user/project/:project", "/info/:user/project/golang", "/aa/*xx", "/ab/*xx", "/:cc", "/c1/:dd/e", "/c1/:dd/e1", "/:cc/cc", "/:cc/:dd/ee", "/:cc/:dd/:ee/ff", "/:cc/:dd/:ee/:ff/gg", "/:cc/:dd/:ee/:ff/:gg/hh", "/get/test/abc/", "/get/:param/abc/", "/something/:paramname/thirdthing", "/something/secondthing/test", "/get/abc", "/get/:param", "/get/abc/123abc", "/get/abc/:param", "/get/abc/123abc/xxx8", "/get/abc/123abc/:param", "/get/abc/123abc/xxx8/1234", "/get/abc/123abc/xxx8/:param", "/get/abc/123abc/xxx8/1234/ffas", "/get/abc/123abc/xxx8/1234/:param", "/get/abc/123abc/xxx8/1234/kkdd/12c", "/get/abc/123abc/xxx8/1234/kkdd/:param", "/get/abc/:param/test", "/get/abc/123abd/:param", "/get/abc/123abddd/:param", "/get/abc/123/:param", "/get/abc/123abg/:param", "/get/abc/123abf/:param", "/get/abc/123abfff/:param", ]); checkRequests(router, [ ["/", false, "/", null], ["/cmd/test", true, "/cmd/:tool/", { tool: "test" }], ["/cmd/test/", false, "/cmd/:tool/", { tool: "test" }], ["/cmd/test/3", false, "/cmd/:tool/:sub", { tool: "test", sub: "3" }], ["/cmd/who", true, "/cmd/:tool/", { tool: "who" }], ["/cmd/who/", false, "/cmd/:tool/", { tool: "who" }], ["/cmd/whoami", false, "/cmd/whoami", null], ["/cmd/whoami/", true, "/cmd/whoami", null], ["/cmd/whoami/r", false, "/cmd/:tool/:sub", { tool: "whoami", sub: "r" }], ["/cmd/whoami/r/", true, "/cmd/:tool/:sub", { tool: "whoami", sub: "r" }], ["/cmd/whoami/root", false, "/cmd/whoami/root", null], ["/cmd/whoami/root/", false, "/cmd/whoami/root/", null], ["/src/", false, "/src/*filepath", { filepath: "" }], [ "/src/some/file.png", false, "/src/*filepath", { filepath: "some/file.png" }, ], ["/search/", false, "/search/", null], [ "/search/someth!ng+in+ünìcodé", false, "/search/:query", { query: "someth!ng+in+ünìcodé" }, ], [ "/search/someth!ng+in+ünìcodé/", true, "", { query: "someth!ng+in+ünìcodé" }, ], ["/search/gin", false, "/search/:query", { query: "gin" }], ["/search/gin-gonic", false, "/search/gin-gonic", null], ["/search/google", false, "/search/google", null], ["/user/gopher", false, "/user/:name", { name: "gopher" }], ["/user/gopher/about", false, "/user/:name/about", { name: "gopher" }], [ "/files/js/inc/framework.js", false, "/files/:dir/*filepath", { dir: "js", filepath: "inc/framework.js" }, ], ["/info/gordon/public", false, "/info/:user/public", { user: "gordon" }], [ "/info/gordon/project/go", false, "/info/:user/project/:project", { user: "gordon", project: "go" }, ], [ "/info/gordon/project/golang", false, "/info/:user/project/golang", { user: "gordon" }, ], ["/aa/aa", false, "/aa/*xx", { xx: "aa" }], ["/ab/ab", false, "/ab/*xx", { xx: "ab" }], ["/a", false, "/:cc", { cc: "a" }], // * Error with argument being intercepted // new PR handle (/all /all/cc /a/cc) // fix PR: https://github.com/gin-gonic/gin/pull/2796 ["/all", false, "/:cc", { cc: "all" }], ["/d", false, "/:cc", { cc: "d" }], ["/ad", false, "/:cc", { cc: "ad" }], ["/dd", false, "/:cc", { cc: "dd" }], ["/dddaa", false, "/:cc", { cc: "dddaa" }], ["/aa", false, "/:cc", { cc: "aa" }], ["/aaa", false, "/:cc", { cc: "aaa" }], ["/aaa/cc", false, "/:cc/cc", { cc: "aaa" }], ["/ab", false, "/:cc", { cc: "ab" }], ["/abb", false, "/:cc", { cc: "abb" }], ["/abb/cc", false, "/:cc/cc", { cc: "abb" }], ["/allxxxx", false, "/:cc", { cc: "allxxxx" }], ["/alldd", false, "/:cc", { cc: "alldd" }], ["/all/cc", false, "/:cc/cc", { cc: "all" }], ["/a/cc", false, "/:cc/cc", { cc: "a" }], ["/c1/d/e", false, "/c1/:dd/e", { dd: "d" }], ["/c1/d/e1", false, "/c1/:dd/e1", { dd: "d" }], ["/c1/d/ee", false, "/:cc/:dd/ee", { cc: "c1", dd: "d" }], ["/cc/cc", false, "/:cc/cc", { cc: "cc" }], ["/ccc/cc", false, "/:cc/cc", { cc: "ccc" }], ["/deedwjfs/cc", false, "/:cc/cc", { cc: "deedwjfs" }], ["/acllcc/cc", false, "/:cc/cc", { cc: "acllcc" }], ["/get/test/abc/", false, "/get/test/abc/", null], ["/get/te/abc/", false, "/get/:param/abc/", { param: "te" }], ["/get/testaa/abc/", false, "/get/:param/abc/", { param: "testaa" }], ["/get/xx/abc/", false, "/get/:param/abc/", { param: "xx" }], ["/get/tt/abc/", false, "/get/:param/abc/", { param: "tt" }], ["/get/a/abc/", false, "/get/:param/abc/", { param: "a" }], ["/get/t/abc/", false, "/get/:param/abc/", { param: "t" }], ["/get/aa/abc/", false, "/get/:param/abc/", { param: "aa" }], ["/get/abas/abc/", false, "/get/:param/abc/", { param: "abas" }], [ "/something/secondthing/test", false, "/something/secondthing/test", null, ], [ "/something/abcdad/thirdthing", false, "/something/:paramname/thirdthing", { paramname: "abcdad" }, ], [ "/something/secondthingaaaa/thirdthing", false, "/something/:paramname/thirdthing", { paramname: "secondthingaaaa" }, ], [ "/something/se/thirdthing", false, "/something/:paramname/thirdthing", { paramname: "se" }, ], [ "/something/s/thirdthing", false, "/something/:paramname/thirdthing", { paramname: "s" }, ], ["/c/d/ee", false, "/:cc/:dd/ee", { cc: "c", dd: "d" }], ["/c/d/e/ff", false, "/:cc/:dd/:ee/ff", { cc: "c", dd: "d", ee: "e" }], [ "/c/d/e/f/gg", false, "/:cc/:dd/:ee/:ff/gg", { cc: "c", dd: "d", ee: "e", ff: "f" }, ], [ "/c/d/e/f/g/hh", false, "/:cc/:dd/:ee/:ff/:gg/hh", { cc: "c", dd: "d", ee: "e", ff: "f", gg: "g" }, ], [ "/cc/dd/ee/ff/gg/hh", false, "/:cc/:dd/:ee/:ff/:gg/hh", { cc: "cc", dd: "dd", ee: "ee", ff: "ff", gg: "gg" }, ], ["/get/abc", false, "/get/abc", null], ["/get/a", false, "/get/:param", { param: "a" }], ["/get/abz", false, "/get/:param", { param: "abz" }], ["/get/12a", false, "/get/:param", { param: "12a" }], ["/get/abcd", false, "/get/:param", { param: "abcd" }], ["/get/abc/123abc", false, "/get/abc/123abc", null], ["/get/abc/12", false, "/get/abc/:param", { param: "12" }], ["/get/abc/123ab", false, "/get/abc/:param", { param: "123ab" }], ["/get/abc/xyz", false, "/get/abc/:param", { param: "xyz" }], [ "/get/abc/123abcddxx", false, "/get/abc/:param", { param: "123abcddxx" }, ], ["/get/abc/123abc/xxx8", false, "/get/abc/123abc/xxx8", null], ["/get/abc/123abc/x", false, "/get/abc/123abc/:param", { param: "x" }], [ "/get/abc/123abc/xxx", false, "/get/abc/123abc/:param", { param: "xxx" }, ], [ "/get/abc/123abc/abc", false, "/get/abc/123abc/:param", { param: "abc" }, ], [ "/get/abc/123abc/xxx8xxas", false, "/get/abc/123abc/:param", { param: "xxx8xxas" }, ], ["/get/abc/123abc/xxx8/1234", false, "/get/abc/123abc/xxx8/1234", null], [ "/get/abc/123abc/xxx8/1", false, "/get/abc/123abc/xxx8/:param", { param: "1" }, ], [ "/get/abc/123abc/xxx8/123", false, "/get/abc/123abc/xxx8/:param", { param: "123" }, ], [ "/get/abc/123abc/xxx8/78k", false, "/get/abc/123abc/xxx8/:param", { param: "78k" }, ], [ "/get/abc/123abc/xxx8/1234xxxd", false, "/get/abc/123abc/xxx8/:param", { param: "1234xxxd" }, ], [ "/get/abc/123abc/xxx8/1234/ffas", false, "/get/abc/123abc/xxx8/1234/ffas", null, ], [ "/get/abc/123abc/xxx8/1234/f", false, "/get/abc/123abc/xxx8/1234/:param", { param: "f" }, ], [ "/get/abc/123abc/xxx8/1234/ffa", false, "/get/abc/123abc/xxx8/1234/:param", { param: "ffa" }, ], [ "/get/abc/123abc/xxx8/1234/kka", false, "/get/abc/123abc/xxx8/1234/:param", { param: "kka" }, ], [ "/get/abc/123abc/xxx8/1234/ffas321", false, "/get/abc/123abc/xxx8/1234/:param", { param: "ffas321" }, ], [ "/get/abc/123abc/xxx8/1234/kkdd/12c", false, "/get/abc/123abc/xxx8/1234/kkdd/12c", null, ], [ "/get/abc/123abc/xxx8/1234/kkdd/1", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", { param: "1" }, ], [ "/get/abc/123abc/xxx8/1234/kkdd/12", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", { param: "12" }, ], [ "/get/abc/123abc/xxx8/1234/kkdd/12b", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", { param: "12b" }, ], [ "/get/abc/123abc/xxx8/1234/kkdd/34", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", { param: "34" }, ], [ "/get/abc/123abc/xxx8/1234/kkdd/12c2e3", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", { param: "12c2e3" }, ], ["/get/abc/12/test", false, "/get/abc/:param/test", { param: "12" }], [ "/get/abc/123abdd/test", false, "/get/abc/:param/test", { param: "123abdd" }, ], [ "/get/abc/123abdddf/test", false, "/get/abc/:param/test", { param: "123abdddf" }, ], [ "/get/abc/123ab/test", false, "/get/abc/:param/test", { param: "123ab" }, ], [ "/get/abc/123abgg/test", false, "/get/abc/:param/test", { param: "123abgg" }, ], [ "/get/abc/123abff/test", false, "/get/abc/:param/test", { param: "123abff" }, ], [ "/get/abc/123abffff/test", false, "/get/abc/:param/test", { param: "123abffff" }, ], [ "/get/abc/123abd/test", false, "/get/abc/123abd/:param", { param: "test" }, ], [ "/get/abc/123abddd/test", false, "/get/abc/123abddd/:param", { param: "test" }, ], [ "/get/abc/123/test22", false, "/get/abc/123/:param", { param: "test22" }, ], [ "/get/abc/123abg/test", false, "/get/abc/123abg/:param", { param: "test" }, ], [ "/get/abc/123abf/testss", false, "/get/abc/123abf/:param", { param: "testss" }, ], [ "/get/abc/123abfff/te", false, "/get/abc/123abfff/:param", { param: "te" }, ], ]); }); test("unescapedParameters", () => { const router = createRouter([ "/", "/cmd/:tool/:sub", "/cmd/:tool/", "/src/*filepath", "/search/:query", "/files/:dir/*filepath", "/info/:user/project/:project", "/info/:user", ]); checkRequests(router, [ ["/", false, "/", null], ["/cmd/test/", false, "/cmd/:tool/", { tool: "test" }], ["/cmd/test", true, "", { tool: "test" }], [ "/src/some/file.png", false, "/src/*filepath", { filepath: "/some/file.png" }, ], [ "/src/some/file+test.png", false, "/src/*filepath", { filepath: "/some/file test.png" }, ], [ "/src/some/file++++%%%%test.png", false, "/src/*filepath", { filepath: "/some/file++++%%%%test.png" }, ], [ "/src/some/file%2Ftest.png", false, "/src/*filepath", { filepath: "/some/file/test.png" }, ], [ "/search/someth!ng+in+ünìcodé", false, "/search/:query", { query: "someth!ng in ünìcodé" }, ], [ "/info/gordon/project/go", false, "/info/:user/project/:project", { user: "gordon", project: "go" }, ], ["/info/slash%2Fgordon", false, "/info/:user", { user: "slash/gordon" }], [ "/info/slash%2Fgordon/project/Project%20%231", false, "/info/:user/project/:project", { user: "slash/gordon", project: "Project #1" }, ], ["/info/slash%%%%", false, "/info/:user", { user: "slash%%%%" }], [ "/info/slash%%%%2Fgordon/project/Project%%%%20%231", false, "/info/:user/project/:project", { user: "slash%%%%2Fgordon", project: "Project%%%%20%231" }, ], ]); }); describe("treeWildcardConflict", () => { testRoutes([ ["/cmd/:tool/:sub", false], ["/cmd/vet", false], ["/foo/bar", false], ["/foo/:name", false], ["/foo/:names", true], ["/cmd/*path", true], ["/cmd/:badvar", false], ["/cmd/:tool/names", false], ["/cmd/:tool/:badsub/details", false], ["/src/*filepath", false], ["/src/:file", true], ["/src/static.json", false], ["/src/*filepathx", true], ["/src/", false], ["/src/foo/bar", false], ["/src1/", false], ["/src1/*filepath", false], ["/src2*filepath", false], ["/src2/*filepath", false], ["/search/:query", false], ["/search/valid", false], ["/user_:name", false], ["/user_x", false], ["/user_:name", true], // already registered ["/id:id", false], ["/id/:id", false], ]); }); describe("treeChildConflict", () => { testRoutes([ ["/cmd/vet", false], ["/cmd/:tool", false], ["/cmd/:tool/:sub", false], ["/cmd/:tool/misc", false], ["/cmd/:tool/:othersub", true], ["/src/AUTHORS", false], ["/src/*filepath", false], ["/user_x", false], ["/user_:name", false], ["/id/:id", false], ["/id:id", false], ["/:id", false], ["/*filepath", true], ]); }); describe("treeDuplicatePath", () => { const router = createRouter([ "/", "/doc/", "/src/*filepath", "/search/:query", "/user/:name", ]); checkRequests(router, [ ["/", false, "/", null], ["/doc/", false, "/doc/", null], [ "/src/some/file.png", false, "/src/*filepath", { filepath: "some/file.png" }, ], [ "/search/someth!ng+in+ünìcodé", false, "/search/:query", { query: "someth!ng+in+ünìcodé" }, ], ["/user/gopher", false, "/user/:name", { name: "gopher" }], ]); }); describe("emptyWildcardName", () => { createRouter(["/user:", "/user:/", "/cmd/:/", "/src/*"], true); }); describe("treeCatchAllConflict", () => { testRoutes( [ ["/src/*filepath/x", true], ["/src2/", false], ["/src2/*filepath/x", true], ["/src3/*filepath", false], ["/src3/*filepath/x", true], ], "Wildcard segment must be the last", ); }); describe("treeCatchAllConflictRoot", () => { testRoutes([ ["/", false], ["/*filepath", false], ]); }); describe("treeTrailingSlashRedirect", () => { const router = createRouter([ "/hi", "/b/", "/search/:query", "/cmd/:tool/", "/src/*filepath", "/x", "/x/y", "/y/", "/y/z", "/0/:id", "/0/:id/1", "/1/:id/", "/1/:id/2", "/aa", "/a/", "/admin", "/admin/:category", "/admin/:category/:page", "/doc", "/doc/go_faq.html", "/doc/go1.html", "/no/a", "/no/b", "/api/:page/:name", "/api/hello/:name/bar/", "/api/bar/:name", "/api/baz/foo", "/api/baz/foo/bar", "/blog/:p", "/posts/:b/:c", "/posts/b/:c/d/", "/vendor/:x/*y", ]); const tsrRoutes = [ "/hi/", "/b", "/search/gopher/", "/cmd/vet", "/src", "/x/", "/y", "/0/go/", "/1/go", "/a", "/admin/", "/admin/config/", "/admin/config/permissions/", "/doc/", "/admin/static/", "/admin/cfg/", "/admin/cfg/users/", "/api/hello/x/bar", "/api/baz/foo/", "/api/baz/bax/", "/api/bar/huh/", "/api/baz/foo/bar/", "/api/world/abc/", "/blog/pp/", "/posts/b/c/d", "/vendor/x", ]; for (const path of tsrRoutes) { test(`tsr path: ${path}`, () => { const ctx = router.find(Method.GET, path); expect(ctx).not.toBeNull(); expect(ctx.tsfSuggestion).not.toBeUndefined(); }); } const nonTsrRoutes = [ "/", "/no", "/no/", "/_", "/_/", "/api", "/api/", "/api/hello/x/foo", "/api/baz/foo/bad", "/foo/p/p", ]; for (const path of nonTsrRoutes) { test(`non-tsr path: ${path}`, () => { const ctx = router.find(Method.GET, path); expect(ctx).toBeNull(); }); } }); }); function testRoutes( routes: [string, boolean][], expectedError = "already registered for", ) { const router = new Router(); for (const [path, shouldConflict] of routes) { test(`path: ${path} | shouldConflict: ${shouldConflict}`, () => { if (!shouldConflict) { expect(() => router.register(Method.GET, path, async () => {}), ).not.toThrow(); } else { expect(() => router.register(Method.GET, path, async () => {})).toThrow( expectedError, ); } }); } } function createRouter(routes: string[], withSnapshots = true): Router { const router = new Router(); for (const path of routes) { router.register(Method.GET, path, async () => {}); } if (withSnapshots) { test("createRouter", () => { expect(router.debug()).toMatchSnapshot("createRouter"); }); } return router; } function countParams(path: string): number { const router = createRouter([path], false); const route = router.find(Method.GET, path); expect(route).not.toBeNull(); return route.params.length; } /** * A test request is a tuple of: * - the request path * - whether the request should be found * - the expected registered path in the router * - the expected params object */ type testRequest = [string, boolean, string, Record | null]; function checkRequests(router: Router, requests: testRequest[]) { for (const [ path, shouldBeMissing, expectedPath, expectedParams, ] of requests) { test(`path: ${path}`, () => { const ctx = router.find(Method.GET, path); if (!shouldBeMissing) { expect(ctx).not.toBeNull(); // expect(ctx.tsrSuggestion).toBeUndefined() // we're not expecting TSR suggestions in these tests const reconstructedPath = reconstructRegisteredURL(ctx); // expect(reconstructedPath).toEqual(expectedPath) expect(createParamsObj(ctx)).toEqual(expectedParams ?? {}); } else { if (ctx !== null && ctx.tsfSuggestion === undefined) { expect(ctx).toBeNull(); } } }); } }