181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
assertNoDuplicateApiRoutes,
|
|
formatApiRouteKeys,
|
|
scanApiRoutesFromFile,
|
|
scanApiRoutesFromSource,
|
|
type ApiRouteKey,
|
|
} from "./scan-api-routes";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const apiAppPath = path.join(repoRoot, "apps/api/src/app.ts");
|
|
|
|
const expectedCurrentRouteKeys: ApiRouteKey[] = [
|
|
"GET /healthz",
|
|
"GET /v1/health",
|
|
"GET /readyz",
|
|
"GET /v1/openapi.json",
|
|
"GET /v1/docs",
|
|
"GET /v1/session/current-user",
|
|
"POST /v1/auth/login",
|
|
"POST /v1/auth/refresh-token",
|
|
"POST /v1/auth/logout",
|
|
"POST /v1/api-keys/validate",
|
|
"GET /v1/accounts",
|
|
"GET /v1/accounts/:id",
|
|
"POST /v1/analytics/events",
|
|
"POST /v1/analytics/page-views",
|
|
"GET /v1/analytics/public",
|
|
"GET /v1/analytics/admin",
|
|
"GET /v1/notifications/stream",
|
|
"GET /v1/workspaces/:tenantId/auth/providers",
|
|
"POST /v1/workspaces/:tenantId/context/revalidate",
|
|
"GET /v1/workspaces/:tenantId/invites",
|
|
"POST /v1/workspaces/:tenantId/invites",
|
|
"POST /v1/workspaces/:tenantId/invites/:inviteId/accept",
|
|
"POST /v1/workspaces/:tenantId/invites/:inviteId/revoke",
|
|
"POST /v1/ai/upload",
|
|
"POST /v1/support/inbound-email",
|
|
"POST /v1/email-sequences/mailjet/webhooks",
|
|
"POST /v1/events/webhooks/attempts/:id",
|
|
"POST /v1/maintenance/export-retention/enqueue",
|
|
"GET /v1/exports/:exportJobId/download-url",
|
|
"GET /v1/exports/:exportJobId/progress",
|
|
"GET /v1/entities/:entity/rows",
|
|
"POST /v1/entities/:entity/rows",
|
|
"GET /v1/entities/:entity/rows/:rowId",
|
|
"PATCH /v1/entities/:entity/rows/:rowId",
|
|
"DELETE /v1/entities/:entity/rows/:rowId",
|
|
"GET /v1/entities/:entity",
|
|
"POST /v1/relationships",
|
|
"GET /v1/relationships/:id",
|
|
"DELETE /v1/relationships/:id",
|
|
"POST /v1/devices/register",
|
|
"POST /v1/devices/revoke",
|
|
"GET /status",
|
|
"GET /metrics",
|
|
"GET /v1/usage",
|
|
"POST /v1/billing/stripe/webhooks",
|
|
];
|
|
|
|
describe("scanApiRoutesFromSource", () => {
|
|
it("returns the complete current route-key set from apps/api/src/app.ts and registered route modules", async () => {
|
|
const result = await scanApiRoutesFromFile(apiAppPath);
|
|
|
|
expect(result.duplicateRoutes).toEqual([]);
|
|
expect(result.unresolvedMountedRouters).toEqual([]);
|
|
expect(formatApiRouteKeys(result.routes)).toEqual(expectedCurrentRouteKeys);
|
|
});
|
|
|
|
it("finds multiline Hono app route registrations", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
app.post(
|
|
"/v1/multiline",
|
|
multilineHandler
|
|
);
|
|
app.delete(
|
|
'/v1/multiline/:id',
|
|
deleteHandler
|
|
);
|
|
app.put(
|
|
"/v1/multiline-put",
|
|
putHandler
|
|
);
|
|
app.options(
|
|
"/v1/multiline-options",
|
|
optionsHandler
|
|
);
|
|
app.all(
|
|
"/api/v1/*",
|
|
redirectHandler
|
|
);
|
|
`);
|
|
|
|
expect(formatApiRouteKeys(result.routes)).toEqual([
|
|
"POST /v1/multiline",
|
|
"DELETE /v1/multiline/:id",
|
|
"PUT /v1/multiline-put",
|
|
"OPTIONS /v1/multiline-options",
|
|
"ALL /api/v1/*",
|
|
]);
|
|
});
|
|
|
|
it("finds simple Hono subrouter registrations mounted through app.route", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
const workspaceRoutes = new Hono();
|
|
workspaceRoutes.get("/workspaces/:tenantId/invites", listInvites);
|
|
workspaceRoutes.post(
|
|
"/workspaces/:tenantId/invites",
|
|
createInvite
|
|
);
|
|
const ignoredRoutes = new Hono();
|
|
ignoredRoutes.get("/not-mounted", handler);
|
|
app.route("/v1", workspaceRoutes);
|
|
`);
|
|
|
|
expect(formatApiRouteKeys(result.routes)).toEqual([
|
|
"GET /v1/workspaces/:tenantId/invites",
|
|
"POST /v1/workspaces/:tenantId/invites",
|
|
]);
|
|
});
|
|
|
|
it("finds Hono app.on route registrations", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
app.on("GET", "/v1/on-single", handler);
|
|
app.on(["POST", "DELETE"], "/v1/on-array/:id", handler);
|
|
`);
|
|
|
|
expect(formatApiRouteKeys(result.routes)).toEqual(["GET /v1/on-single", "POST /v1/on-array/:id", "DELETE /v1/on-array/:id"]);
|
|
});
|
|
|
|
it("reports mounted routers that cannot be resolved from the scanned source", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
import { userRoutes } from "./users";
|
|
app.route("/v1/users", userRoutes);
|
|
`);
|
|
|
|
expect(result.routes).toEqual([]);
|
|
expect(result.unresolvedMountedRouters).toEqual([
|
|
{
|
|
receiver: "userRoutes",
|
|
prefix: "/v1/users",
|
|
location: { line: 3, column: 7 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports duplicate route keys without collapsing them through a Set", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
app.get("/v1/dup", firstHandler);
|
|
app.get("/v1/dup", secondHandler);
|
|
`);
|
|
|
|
expect(formatApiRouteKeys(result.routes)).toEqual(["GET /v1/dup", "GET /v1/dup"]);
|
|
expect(result.duplicateRoutes).toEqual([
|
|
{
|
|
key: "GET /v1/dup",
|
|
locations: [
|
|
{ line: 2, column: 7 },
|
|
{ line: 3, column: 7 },
|
|
],
|
|
},
|
|
]);
|
|
expect(() => assertNoDuplicateApiRoutes(result)).toThrow(/Duplicate API route keys found: GET \/v1\/dup/);
|
|
});
|
|
|
|
it("ignores non-Hono-app source text", () => {
|
|
const result = scanApiRoutesFromSource(`
|
|
something.get("/v1/not-a-route", handler);
|
|
router.post("/v1/not-a-route", handler);
|
|
const text = 'app.delete("/v1/not-a-real-route")';
|
|
const templateText = \`app.post("/v1/not-a-real-route")\`;
|
|
const objectText = { value: "app.patch('/v1/not-a-real-route')" };
|
|
`);
|
|
|
|
expect(result.routes).toEqual([]);
|
|
expect(result.duplicateRoutes).toEqual([]);
|
|
});
|
|
});
|