import { OpenAPIHono } from "@hono/zod-openapi"; import { createOpenAPIRegistry } from "@suaveplan/api-openapi"; import { createPluginContext } from "@suaveplan/api-plugin"; import { EventBus } from "@suaveplan/events"; import type { SurrealAdapter } from "@suaveplan/surrealdb"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { CONTACTS_API_CODES, ContactsApiError, } from "../error-codes/error-codes.js"; import { createContactsPlugin, runOnInit } from "./plugin.js"; const noopLogger = { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; function makeFakeDb(): SurrealAdapter { return { async execute() {}, async query() { return [[]]; }, } as unknown as SurrealAdapter; } describe("createContactsPlugin", () => { it("createContactsPlugin returns a typed descriptor with default config", { timeout: 5000, }, () => { const desc = createContactsPlugin(); expect(desc.name).toBe("contacts"); expect(desc.version).toBe("0.1.0"); expect(desc.dependencies).toEqual(["database", "auth"]); expect(desc.defaults.routePrefix).toBe("/"); expect(desc.defaults.maxImportBatchSize).toBe(1000); expect(desc.defaults.maxGroupDepth).toBe(10); expect(desc.defaults.enforceGroupDepth).toBe(true); expect(desc.defaults.applySchemaOnInit).toBe(true); }); it("createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options", { timeout: 5000, }, () => { expect(() => createContactsPlugin({ maxGroupDepth: -1 } as never) ).toThrowError(ContactsApiError); }); it("createContactsPlugin honours a custom routePrefix", { timeout: 5000, }, () => { const desc = createContactsPlugin({ routePrefix: "/api" }); expect(desc.defaults.routePrefix).toBe("/api"); }); it("descriptor advertises name, dependencies, version, tags", { timeout: 5000, }, () => { const desc = createContactsPlugin(); expect(desc.tags).toEqual(["contacts", "groups", "audience"]); }); }); describe("plugin onInit", () => { function makeContext( overrides: { app?: OpenAPIHono | undefined; db?: SurrealAdapter | undefined; registry?: ReturnType | undefined; } = {} ) { const app = overrides.app ?? new OpenAPIHono(); const services = new Map(); if (app !== undefined) services.set("app", app); if (overrides.db !== undefined) services.set("db", overrides.db); if (overrides.registry !== undefined) services.set("openAPIRegistry", overrides.registry); const ctx = createPluginContext({ logger: noopLogger, eventBus: new EventBus(), signal: new AbortController().signal, config: {} as never, services, }); return { ctx, app, services }; } it("throws SERVICE_NOT_AVAILABLE when app missing", { timeout: 5000, }, async () => { const services = new Map(); // Note: no `app` slot. services.set("db", makeFakeDb()); const ctx = createPluginContext({ logger: noopLogger, eventBus: new EventBus(), signal: new AbortController().signal, config: {} as never, services, }); const desc = createContactsPlugin({ applySchemaOnInit: false }); await expect( runOnInit(ctx as never, desc.defaults) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE, }); }); it("throws SERVICE_NOT_AVAILABLE when db missing", { timeout: 5000, }, async () => { const { ctx } = makeContext({}); const desc = createContactsPlugin({ applySchemaOnInit: false }); await expect( runOnInit(ctx as never, desc.defaults) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE, }); }); it("registers the three repositories in the ServiceRegistry", { timeout: 5000, }, async () => { const db = makeFakeDb(); const { ctx } = makeContext({ db }); const desc = createContactsPlugin({ applySchemaOnInit: false }); await runOnInit(ctx as never, desc.defaults); expect(ctx.getService("contactRepository")).toBeDefined(); expect(ctx.getService("groupRepository")).toBeDefined(); expect(ctx.getService("membershipRepository")).toBeDefined(); }); it("logs a warning when openAPIRegistry is absent and still mounts handlers", { timeout: 5000, }, async () => { const db = makeFakeDb(); const messages: string[] = []; const logger = { debug: () => undefined, info: () => undefined, warn: (msg: string) => messages.push(msg), error: () => undefined, }; const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); services.set("db", db); const ctx = createPluginContext({ logger, eventBus: new EventBus(), signal: new AbortController().signal, config: {} as never, services, }); const desc = createContactsPlugin({ applySchemaOnInit: false }); await runOnInit(ctx as never, desc.defaults); expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true); }); it("registers routes when openAPIRegistry is supplied", { timeout: 5000, }, async () => { const db = makeFakeDb(); const registry = createOpenAPIRegistry({ info: { title: "t", version: "0.0.0" }, }); const { ctx } = makeContext({ db, registry }); const desc = createContactsPlugin({ applySchemaOnInit: false }); await runOnInit(ctx as never, desc.defaults); const paths = registry.getRoutes().map((r) => r.path); expect(paths).toContain("/contacts"); expect(paths).toContain("/groups"); }); it("raises ROUTE_PREFIX_COLLISION when /contacts already mounted", { timeout: 5000, }, async () => { const db = makeFakeDb(); const registry = createOpenAPIRegistry({ info: { title: "t", version: "0.0.0" }, }); const { ctx } = makeContext({ db, registry }); const desc = createContactsPlugin({ applySchemaOnInit: false }); await runOnInit(ctx as never, desc.defaults); // Second call should detect collision. await expect( runOnInit(ctx as never, desc.defaults) ).rejects.toMatchObject({ code: CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION, }); }); it("applies SurrealQL schema when applySchemaOnInit is true", { timeout: 5000, }, async () => { const executed: string[] = []; const db = { async execute(sql: string) { executed.push(sql); }, async query() { return [[]]; }, } as unknown as SurrealAdapter; const { ctx } = makeContext({ db }); const desc = createContactsPlugin({ applySchemaOnInit: true }); await runOnInit(ctx as never, desc.defaults); expect(executed.length).toBeGreaterThan(0); expect(executed[0]).toContain("DEFINE TABLE OVERWRITE contact"); }); }); describe("plugin route prefix overrides", () => { it("mounts under /api when routePrefix is /api", { timeout: 5000, }, async () => { const db = makeFakeDb(); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); services.set("db", db); const ctx = createPluginContext({ logger: noopLogger, eventBus: new EventBus(), signal: new AbortController().signal, config: {} as never, services, }); const desc = createContactsPlugin({ routePrefix: "/api", applySchemaOnInit: false, }); await runOnInit(ctx as never, desc.defaults); // The route was registered onto the bare Hono app (no registry path). // Probe the app for a known route under /api. const res = await app.request("/api/contacts"); // Will be 401 (no auth) but indicates handler is mounted. expect([401, 500]).toContain(res.status); }); });