244 lines
8.6 KiB
TypeScript
244 lines
8.6 KiB
TypeScript
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<typeof createOpenAPIRegistry> | undefined;
|
|
} = {}
|
|
) {
|
|
const app = overrides.app ?? new OpenAPIHono();
|
|
const services = new Map<string, unknown>();
|
|
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<string, unknown>();
|
|
// 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<string, unknown>();
|
|
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<string, unknown>();
|
|
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);
|
|
});
|
|
});
|