/** * Router v5 Tests * * Test cases: * 1. Static vs Dynamic Priority * 2. Parameter Matching * 3. Wildcard Matching * 4. Security (URI encoding) * 5. Validation Errors */ import { describe, test, expect } from "bun:test"; import { Router, RouterError, createRouter, WILDCARD_PARAM_KEY, } from "./router"; import type { RouteSpec } from "../spec/schema"; // ═══════════════════════════════════════════════════════════════════════════ // Test Fixtures // ═══════════════════════════════════════════════════════════════════════════ function makeRoute(id: string, pattern: string, kind: "page" | "api" = "api"): RouteSpec { return { id, pattern, kind, module: `generated/${id}.route.ts`, ...(kind === "page" ? { componentModule: `generated/${id}.route.tsx` } : {}), } as RouteSpec; } // ═══════════════════════════════════════════════════════════════════════════ // 1. Static vs Dynamic Priority // ═══════════════════════════════════════════════════════════════════════════ describe("Static vs Dynamic Priority", () => { test("static route takes precedence over param route", () => { const router = createRouter([ makeRoute("todos-item", "/api/todos/:id"), makeRoute("todos-stats", "/api/todos/stats"), ]); const result = router.match("/api/todos/stats"); expect(result).not.toBeNull(); expect(result!.route.id).toBe("todos-stats"); expect(result!.params).toEqual({}); }); test("static route precedence regardless of registration order", () => { // Register static AFTER dynamic const router = createRouter([ makeRoute("users-item", "/users/:id"), makeRoute("users-me", "/users/me"), ]); expect(router.match("/users/me")!.route.id).toBe("users-me"); expect(router.match("/users/123")!.route.id).toBe("users-item"); }); test("root path matching", () => { const router = createRouter([ makeRoute("home", "/", "page"), makeRoute("api", "/api"), ]); expect(router.match("/")!.route.id).toBe("home"); expect(router.match("/api")!.route.id).toBe("api"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 2. Parameter Matching // ═══════════════════════════════════════════════════════════════════════════ describe("Parameter Matching", () => { test("extracts single param correctly", () => { const router = createRouter([ makeRoute("todos-item", "/api/todos/:id"), ]); const result = router.match("/api/todos/123"); expect(result).not.toBeNull(); expect(result!.route.id).toBe("todos-item"); expect(result!.params).toEqual({ id: "123" }); }); test("extracts multiple params correctly", () => { const router = createRouter([ makeRoute("user-post", "/users/:userId/posts/:postId"), ]); const result = router.match("/users/42/posts/99"); expect(result).not.toBeNull(); expect(result!.params).toEqual({ userId: "42", postId: "99" }); }); test("decodes UTF-8 encoded params", () => { const router = createRouter([ makeRoute("user", "/user/:name"), ]); // café encoded as caf%C3%A9 const result = router.match("/user/caf%C3%A9"); expect(result).not.toBeNull(); expect(result!.params).toEqual({ name: "café" }); }); test("handles non-ASCII static routes", () => { const router = createRouter([ makeRoute("cafe", "/café", "page"), ]); expect(router.match("/café")!.route.id).toBe("cafe"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 3. Wildcard Matching // ═══════════════════════════════════════════════════════════════════════════ describe("Wildcard Matching", () => { test("matches wildcard with remaining path", () => { const router = createRouter([ makeRoute("files", "/files/*"), ]); const result = router.match("/files/a/b/c"); expect(result).not.toBeNull(); expect(result!.route.id).toBe("files"); expect(result!.params).toEqual({ [WILDCARD_PARAM_KEY]: "a/b/c" }); }); test("matches named wildcard with remaining path", () => { const router = createRouter([ makeRoute("docs", "/docs/:path*"), ]); const result = router.match("/docs/a/b/c"); expect(result).not.toBeNull(); expect(result!.route.id).toBe("docs"); expect(result!.params).toEqual({ path: "a/b/c" }); }); test("optional wildcard matches base path without param", () => { const router = createRouter([ makeRoute("docs", "/docs/:path*?"), ]); const result = router.match("/docs"); expect(result).not.toBeNull(); expect(result!.route.id).toBe("docs"); expect(result!.params).toEqual({ path: "" }); }); test("optional wildcard matches with remaining path", () => { const router = createRouter([ makeRoute("docs", "/docs/:path*?"), ]); const result = router.match("/docs/intro"); expect(result).not.toBeNull(); expect(result!.params).toEqual({ path: "intro" }); }); test("wildcard with single segment", () => { const router = createRouter([ makeRoute("docs", "/docs/*"), ]); const result = router.match("/docs/readme"); expect(result!.params).toEqual({ [WILDCARD_PARAM_KEY]: "readme" }); }); test("Policy A: wildcard does NOT match base path", () => { const router = createRouter([ makeRoute("files", "/files/*"), ]); // /files/* should NOT match /files expect(router.match("/files")).toBeNull(); expect(router.match("/files/")).toBeNull(); // normalized to /files }); test("static route takes precedence over wildcard", () => { const router = createRouter([ makeRoute("files-wildcard", "/files/*"), makeRoute("files-readme", "/files/readme"), ]); expect(router.match("/files/readme")!.route.id).toBe("files-readme"); expect(router.match("/files/other")!.route.id).toBe("files-wildcard"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 4. Security (URI Encoding) // ═══════════════════════════════════════════════════════════════════════════ describe("Security", () => { test("blocks %2F (encoded slash) in path segments", () => { const router = createRouter([ makeRoute("user", "/user/:name"), ]); // a%2Fb = a/b encoded const result = router.match("/user/a%2Fb"); expect(result).toBeNull(); }); test("blocks double-encoded slash (%252F)", () => { const router = createRouter([ makeRoute("user", "/user/:name"), ]); // %252F decodes to %2F const result = router.match("/user/%252F"); expect(result).toBeNull(); }); test("blocks malformed UTF-8 encoding", () => { const router = createRouter([ makeRoute("user", "/user/:name"), ]); // Invalid UTF-8 sequence const result = router.match("/user/%C0%AE"); expect(result).toBeNull(); }); test("allows valid percent-encoded characters", () => { const router = createRouter([ makeRoute("search", "/search/:query"), ]); // hello%20world = "hello world" const result = router.match("/search/hello%20world"); expect(result).not.toBeNull(); expect(result!.params).toEqual({ query: "hello world" }); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 5. Validation Errors // ═══════════════════════════════════════════════════════════════════════════ describe("Validation Errors", () => { test("throws DUPLICATE_PATTERN for same pattern", () => { expect(() => { createRouter([ makeRoute("route1", "/api/users"), makeRoute("route2", "/api/users"), ]); }).toThrow(RouterError); try { createRouter([ makeRoute("route1", "/api/users"), makeRoute("route2", "/api/users"), ]); } catch (e) { expect(e).toBeInstanceOf(RouterError); expect((e as RouterError).code).toBe("DUPLICATE_PATTERN"); expect((e as RouterError).routeId).toBe("route2"); expect((e as RouterError).conflictsWith).toBe("route1"); } }); test("throws DUPLICATE_PATTERN for normalized duplicates (trailing slash)", () => { expect(() => { createRouter([ makeRoute("route1", "/api/users"), makeRoute("route2", "/api/users/"), ]); }).toThrow(RouterError); try { createRouter([ makeRoute("route1", "/api/users"), makeRoute("route2", "/api/users/"), ]); } catch (e) { expect((e as RouterError).code).toBe("DUPLICATE_PATTERN"); } }); test("throws PARAM_NAME_CONFLICT for same-depth param mismatch", () => { expect(() => { createRouter([ makeRoute("users", "/users/:id"), makeRoute("users-by-name", "/users/:name"), ]); }).toThrow(RouterError); try { createRouter([ makeRoute("users", "/users/:id"), makeRoute("users-by-name", "/users/:name"), ]); } catch (e) { expect((e as RouterError).code).toBe("PARAM_NAME_CONFLICT"); } }); test("allows same param name across different paths", () => { // These should NOT conflict - different parent paths const router = createRouter([ makeRoute("users", "/users/:id"), makeRoute("posts", "/posts/:id"), ]); expect(router.match("/users/1")!.params).toEqual({ id: "1" }); expect(router.match("/posts/2")!.params).toEqual({ id: "2" }); }); test("throws WILDCARD_NOT_LAST for non-terminal wildcard", () => { expect(() => { createRouter([ makeRoute("invalid", "/files/*/more"), ]); }).toThrow(RouterError); try { createRouter([ makeRoute("invalid", "/files/*/more"), ]); } catch (e) { expect((e as RouterError).code).toBe("WILDCARD_NOT_LAST"); } }); test("throws ROUTE_CONFLICT for wildcard conflicts", () => { expect(() => { createRouter([ makeRoute("files-legacy", "/files/*"), makeRoute("files-named", "/files/:path*"), ]); }).toThrow(RouterError); try { createRouter([ makeRoute("files-legacy", "/files/*"), makeRoute("files-named", "/files/:path*"), ]); } catch (e) { expect((e as RouterError).code).toBe("ROUTE_CONFLICT"); } }); }); // ═══════════════════════════════════════════════════════════════════════════ // 6. Router API // ═══════════════════════════════════════════════════════════════════════════ describe("Router API", () => { test("getStats returns correct counts", () => { const router = createRouter([ makeRoute("home", "/"), makeRoute("health", "/api/health"), makeRoute("todos-item", "/api/todos/:id"), makeRoute("files", "/files/*"), ]); const stats = router.getStats(); expect(stats.staticCount).toBe(2); // / and /api/health expect(stats.dynamicCount).toBe(2); // /api/todos/:id and /files/* expect(stats.totalRoutes).toBe(4); }); test("getRoutes returns all registered routes", () => { const routes = [ makeRoute("home", "/"), makeRoute("users", "/users/:id"), ]; const router = createRouter(routes); const retrieved = router.getRoutes(); expect(retrieved.length).toBe(2); expect(retrieved.map((r) => r.id).sort()).toEqual(["home", "users"]); }); test("addRoute adds route to existing router", () => { const router = createRouter([ makeRoute("home", "/"), ]); router.addRoute(makeRoute("about", "/about")); expect(router.match("/about")).not.toBeNull(); expect(router.getStats().totalRoutes).toBe(2); }); test("addRoute validates against existing routes", () => { const router = createRouter([ makeRoute("home", "/"), ]); expect(() => { router.addRoute(makeRoute("home2", "/")); }).toThrow(RouterError); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 7. Edge Cases // ═══════════════════════════════════════════════════════════════════════════ describe("Edge Cases", () => { test("empty routes", () => { const router = createRouter([]); expect(router.match("/")).toBeNull(); expect(router.getStats().totalRoutes).toBe(0); }); test("deep nested paths", () => { const router = createRouter([ makeRoute("deep", "/a/b/c/d/e/:id"), ]); const result = router.match("/a/b/c/d/e/123"); expect(result!.params).toEqual({ id: "123" }); }); test("consecutive params", () => { const router = createRouter([ makeRoute("date", "/calendar/:year/:month/:day"), ]); const result = router.match("/calendar/2025/01/30"); expect(result!.params).toEqual({ year: "2025", month: "01", day: "30", }); }); test("param followed by static", () => { const router = createRouter([ makeRoute("user-posts", "/users/:id/posts"), ]); const result = router.match("/users/42/posts"); expect(result!.route.id).toBe("user-posts"); expect(result!.params).toEqual({ id: "42" }); }); test("trailing slash normalization", () => { const router = createRouter([ makeRoute("api", "/api"), ]); expect(router.match("/api")).not.toBeNull(); expect(router.match("/api/")).not.toBeNull(); // normalized to /api }); });