/** * Plugin Routes Tests * * Tests the v2 route system for: * - Route registration and invocation * - Input validation with Zod schemas * - Error handling (PluginRouteError) * - Route registry management */ import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import type { PluginContextFactoryOptions } from "../../../src/plugins/context.js"; import { EmailPipeline } from "../../../src/plugins/email.js"; import { HookPipeline } from "../../../src/plugins/hooks.js"; import { PluginRouteHandler, PluginRouteRegistry, PluginRouteError, createRouteRegistry, } from "../../../src/plugins/routes.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; /** * Create a minimal resolved plugin for testing */ function createTestPlugin(overrides: Partial = {}): ResolvedPlugin { return { id: overrides.id ?? "test-plugin", version: "1.0.0", capabilities: [], allowedHosts: [], storage: {}, admin: { pages: [], widgets: [], fieldWidgets: {}, }, hooks: {}, routes: {}, ...overrides, }; } /** * Create mock factory options (routes need DB for context) */ function createMockFactoryOptions(): PluginContextFactoryOptions { return { db: {} as any, // Mock DB - routes will fail if they try to use DB features }; } describe("PluginRouteError", () => { describe("constructor", () => { it("creates error with code, message, and status", () => { const error = new PluginRouteError("TEST_ERROR", "Test message", 400); expect(error.code).toBe("TEST_ERROR"); expect(error.message).toBe("Test message"); expect(error.status).toBe(400); expect(error.name).toBe("PluginRouteError"); }); it("defaults status to 400", () => { const error = new PluginRouteError("TEST_ERROR", "Test message"); expect(error.status).toBe(400); }); it("stores optional details", () => { const details = { field: "email", issue: "invalid" }; const error = new PluginRouteError("VALIDATION_ERROR", "Invalid input", 400, details); expect(error.details).toEqual(details); }); }); describe("static factory methods", () => { it("badRequest creates 400 error", () => { const error = PluginRouteError.badRequest("Bad data", { foo: "bar" }); expect(error.code).toBe("BAD_REQUEST"); expect(error.status).toBe(400); expect(error.message).toBe("Bad data"); expect(error.details).toEqual({ foo: "bar" }); }); it("unauthorized creates 401 error", () => { const error = PluginRouteError.unauthorized(); expect(error.code).toBe("UNAUTHORIZED"); expect(error.status).toBe(401); expect(error.message).toBe("Unauthorized"); }); it("forbidden creates 403 error", () => { const error = PluginRouteError.forbidden("Access denied"); expect(error.code).toBe("FORBIDDEN"); expect(error.status).toBe(403); expect(error.message).toBe("Access denied"); }); it("notFound creates 404 error", () => { const error = PluginRouteError.notFound("Resource not found"); expect(error.code).toBe("NOT_FOUND"); expect(error.status).toBe(404); expect(error.message).toBe("Resource not found"); }); it("conflict creates 409 error", () => { const error = PluginRouteError.conflict("Already exists", { id: "123" }); expect(error.code).toBe("CONFLICT"); expect(error.status).toBe(409); expect(error.message).toBe("Already exists"); expect(error.details).toEqual({ id: "123" }); }); it("internal creates 500 error", () => { const error = PluginRouteError.internal("Something broke"); expect(error.code).toBe("INTERNAL_ERROR"); expect(error.status).toBe(500); expect(error.message).toBe("Something broke"); }); }); }); describe("PluginRouteHandler", () => { describe("getRouteMeta", () => { it("returns null for non-existent route", () => { const plugin = createTestPlugin(); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); expect(handler.getRouteMeta("non-existent")).toBeNull(); }); it("returns { public: false } for route without public flag", () => { const plugin = createTestPlugin({ routes: { sync: { handler: vi.fn() }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const meta = handler.getRouteMeta("sync"); expect(meta).toEqual({ public: false }); }); it("returns { public: true } for route with public: true", () => { const plugin = createTestPlugin({ routes: { submit: { public: true, handler: vi.fn() }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const meta = handler.getRouteMeta("submit"); expect(meta).toEqual({ public: true }); }); it("returns { public: false } for route with public: false", () => { const plugin = createTestPlugin({ routes: { admin: { public: false, handler: vi.fn() }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const meta = handler.getRouteMeta("admin"); expect(meta).toEqual({ public: false }); }); }); describe("getRouteNames", () => { it("returns empty array for plugin with no routes", () => { const plugin = createTestPlugin(); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); expect(handler.getRouteNames()).toEqual([]); }); it("returns all route names", () => { const plugin = createTestPlugin({ routes: { sync: { handler: vi.fn() }, webhook: { handler: vi.fn() }, "batch-process": { handler: vi.fn() }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const names = handler.getRouteNames(); expect(names).toContain("sync"); expect(names).toContain("webhook"); expect(names).toContain("batch-process"); expect(names).toHaveLength(3); }); }); describe("hasRoute", () => { it("returns false for non-existent route", () => { const plugin = createTestPlugin(); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); expect(handler.hasRoute("non-existent")).toBe(false); }); it("returns true for existing route", () => { const plugin = createTestPlugin({ routes: { sync: { handler: vi.fn() }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); expect(handler.hasRoute("sync")).toBe(true); }); }); describe("invoke", () => { it("returns 404 for non-existent route", async () => { const plugin = createTestPlugin(); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const result = await handler.invoke("non-existent", { request: new Request("http://test.com"), }); expect(result.success).toBe(false); expect(result.status).toBe(404); expect(result.error?.code).toBe("ROUTE_NOT_FOUND"); }); it("validates input with Zod schema", async () => { const plugin = createTestPlugin({ routes: { create: { input: z.object({ name: z.string().min(1), email: z.string().email(), }), handler: vi.fn(), }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); // Invalid input const result = await handler.invoke("create", { request: new Request("http://test.com"), body: { name: "", email: "not-an-email" }, }); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error?.code).toBe("VALIDATION_ERROR"); }); it("handles PluginRouteError from handler", async () => { const plugin = createTestPlugin({ routes: { fail: { handler: async () => { throw PluginRouteError.forbidden("No access"); }, }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const result = await handler.invoke("fail", { request: new Request("http://test.com"), }); expect(result.success).toBe(false); expect(result.status).toBe(403); expect(result.error?.code).toBe("FORBIDDEN"); expect(result.error?.message).toBe("No access"); }); it("handles unknown errors from handler", async () => { const plugin = createTestPlugin({ routes: { crash: { handler: async () => { throw new Error("Unexpected error"); }, }, }, }); const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); const result = await handler.invoke("crash", { request: new Request("http://test.com"), }); expect(result.success).toBe(false); expect(result.status).toBe(500); expect(result.error?.code).toBe("INTERNAL_ERROR"); expect(result.error?.message).toBe("An internal error occurred"); }); it("includes ctx.email when email pipeline is configured", async () => { const hookPipeline = new HookPipeline([], createMockFactoryOptions()); hookPipeline.setExclusiveSelection("email:deliver", "provider"); const emailPipeline = new EmailPipeline(hookPipeline); const plugin = createTestPlugin({ capabilities: ["email:send"], routes: { checkEmail: { handler: async (ctx) => ({ hasEmail: !!ctx.email, hasSend: typeof ctx.email?.send === "function", }), }, }, }); const handler = new PluginRouteHandler(plugin, { ...createMockFactoryOptions(), emailPipeline, }); const result = await handler.invoke("checkEmail", { request: new Request("http://test.com"), }); expect(result.success).toBe(true); expect(result.data).toEqual({ hasEmail: true, hasSend: true }); }); }); }); describe("PluginRouteRegistry", () => { describe("register/unregister", () => { it("registers a plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin", routes: { sync: { handler: vi.fn() } }, }); registry.register(plugin); expect(registry.getPluginIds()).toContain("my-plugin"); }); it("unregisters a plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin" }); registry.register(plugin); registry.unregister("my-plugin"); expect(registry.getPluginIds()).not.toContain("my-plugin"); }); }); describe("getPluginIds", () => { it("returns empty array initially", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); expect(registry.getPluginIds()).toEqual([]); }); it("returns all registered plugin IDs", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); registry.register(createTestPlugin({ id: "plugin-a" })); registry.register(createTestPlugin({ id: "plugin-b" })); registry.register(createTestPlugin({ id: "plugin-c" })); const ids = registry.getPluginIds(); expect(ids).toContain("plugin-a"); expect(ids).toContain("plugin-b"); expect(ids).toContain("plugin-c"); }); }); describe("getRoutes", () => { it("returns empty array for non-existent plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); expect(registry.getRoutes("non-existent")).toEqual([]); }); it("returns route names for registered plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin", routes: { sync: { handler: vi.fn() }, import: { handler: vi.fn() }, }, }); registry.register(plugin); const routes = registry.getRoutes("my-plugin"); expect(routes).toContain("sync"); expect(routes).toContain("import"); }); }); describe("getRouteMeta", () => { it("returns null for non-existent plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); expect(registry.getRouteMeta("non-existent", "sync")).toBeNull(); }); it("returns null for non-existent route on registered plugin", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin", routes: { sync: { handler: vi.fn() } }, }); registry.register(plugin); expect(registry.getRouteMeta("my-plugin", "non-existent")).toBeNull(); }); it("returns metadata for existing route", () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin", routes: { sync: { handler: vi.fn() }, submit: { public: true, handler: vi.fn() }, }, }); registry.register(plugin); expect(registry.getRouteMeta("my-plugin", "sync")).toEqual({ public: false }); expect(registry.getRouteMeta("my-plugin", "submit")).toEqual({ public: true }); }); }); describe("invoke", () => { it("returns 404 for non-existent plugin", async () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const result = await registry.invoke("non-existent", "sync", { request: new Request("http://test.com"), }); expect(result.success).toBe(false); expect(result.status).toBe(404); expect(result.error?.code).toBe("PLUGIN_NOT_FOUND"); }); it("delegates to plugin handler", async () => { const registry = new PluginRouteRegistry(createMockFactoryOptions()); const plugin = createTestPlugin({ id: "my-plugin", routes: { status: { handler: async () => ({ healthy: true }), }, }, }); registry.register(plugin); // This will fail because handler tries to create context with mock DB // But we can verify it attempts to invoke const result = await registry.invoke("my-plugin", "non-existent", { request: new Request("http://test.com"), }); expect(result.success).toBe(false); expect(result.error?.code).toBe("ROUTE_NOT_FOUND"); }); }); }); describe("createRouteRegistry helper", () => { it("creates a PluginRouteRegistry instance", () => { const registry = createRouteRegistry(createMockFactoryOptions()); expect(registry).toBeInstanceOf(PluginRouteRegistry); }); });