Files
Ekklesia/apps/api/tests/lifecycle.test.ts
T
ekklesia 0e308eb9ad feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
2026-07-25 20:57:32 -05:00

927 lines
37 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`.
vi.mock("../src/app.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/app.js")>();
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
});
import { parseModuleManifest } from "@ekklesia/contracts";
import {
type DiscoveredModule,
ModuleDiscoveryError,
ModulePermissionCompositionError,
} from "@ekklesia/core/modules";
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
return createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
}
const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-modules-"));
temporaryDirectories.push(directory);
return directory;
}
/** Writes a minimal, structurally valid module fixture (package.json + ekklesia.module.json +
* server entrypoint) directly to disk — real discovery, not a hand-built double. The server
* entrypoint throws immediately if ever imported, so a test that reaches a successful assertion
* without that throw firing is direct evidence permission composition never executed it. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 3,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition must never execute a module server entrypoint");',
),
]);
}
const eventSymbols: symbol[] = [];
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
* cannot close over this test file's local variables, so lifecycle events cross that boundary
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
* source pushes into, so test code can both read it directly and push its own markers (pool
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
* reference (such as `context.services.database`) rather than a string label, so a test can prove
* two values pushed from different, independently dynamically-imported invocations (a module's own
* `start` hook and one of its HTTP handlers) are the exact same object. */
function createReferenceRecorder(): { values: unknown[]; expression: string } {
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const values: unknown[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
eventSymbols.push(symbol);
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
interface WorkingFixtureModuleOptions {
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
readonly withRoute?: boolean;
readonly registerThrows?: boolean;
readonly startThrows?: boolean;
readonly stopThrows?: boolean;
readonly dependencies?: Record<string, unknown>;
}
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
* runtime startup actually executes and in what order, relative to pool/server construction. */
async function createWorkingFixtureModule(
modulesDirectory: string,
id: string,
recorder: { expression: string },
options: WorkingFixtureModuleOptions = {},
): Promise<void> {
const permissionId = `${id}.ping.read`;
await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute
? [{ baseline: "public", description: "Ping.", id: permissionId }]
: [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
});
const routeSource = options.withRoute
? `http.route({
method: "GET",
path: "/ping",
permission: ${JSON.stringify(permissionId)},
handler: async () => new Response("pong", { status: 200 }),
});`
: "";
const serverSource = `export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${routeSource}
${options.registerThrows ? 'throw new Error("register failed");' : ""}
},
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
${options.startThrows ? 'throw new Error("start failed");' : ""}
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
},
};`;
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
}
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
* unreachable through real filesystem discovery (module ids are unique and every permission id is
* namespaced by its own module's id, so no two genuinely discovered modules can ever collide). */
function fakeDiscoveredModule(
id: string,
permissions: ReadonlyArray<{
id: string;
baseline: "public" | "authenticated" | "restricted";
description: string;
}>,
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 3,
name: `${id} module`,
permissions,
version: "0.1.0",
});
return {
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
callback?.();
return server;
}),
listening: true,
once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") {
errorHandler = handler;
}
return server;
}),
};
return {
server,
triggerError: (error: Error) => errorHandler?.(error),
};
}
/** Resolves the listening callback asynchronously, matching real `@hono/node-server` behavior
* (the callback fires on the `listening` event, after `serve()` itself has already returned) —
* resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */
function mockSuccessfulServe() {
const { server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { server, triggerError };
}
function mockFailingServe(error: Error) {
const { server, triggerError } = fakeServer();
serveMock.mockImplementation(() => {
queueMicrotask(() => triggerError(error));
return server;
});
return server;
}
/** Captures signal handlers registered via `process.on` without ever installing a real one, so a
* test can trigger "SIGINT"/"SIGTERM" by calling the captured function directly. */
function mockProcessOn(): { handlers: Record<string, () => void>; restore: () => void } {
const handlers: Record<string, () => void> = {};
const fake = vi.fn((event: string, handler: () => void) => {
handlers[event] = handler;
return process;
});
const onSpy = vi.spyOn(process, "on").mockImplementation(fake as unknown as typeof process.on);
return { handlers, restore: () => onSpy.mockRestore() };
}
describe("Ekklesia API import safety", () => {
const originalEnv = { ...process.env };
const originalProcessOn = process.on.bind(process);
beforeEach(() => {
poolConstructor.mockClear();
betterAuthMock.mockClear();
drizzleAdapterMock.mockClear();
serveMock.mockClear();
processOnSpy.mockClear();
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
process.env = { ...originalEnv };
process.on = originalProcessOn;
});
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing the app factory", async () => {
await import("../src/app.js");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing configuration", async () => {
await import("../src/config.js");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read environment variables, connect to PostgreSQL, construct Better Auth, start a server, or install signal handlers merely by importing the executable server module", async () => {
// Deliberately leave every runtime-required environment variable unset. If importing this
// module performed any runtime composition, missing configuration would throw here.
delete process.env.DATABASE_URL;
delete process.env.EKKLESIA_IDENTITY_SECRET;
delete process.env.EKKLESIA_IDENTITY_BASE_URL;
delete process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const onSpy = vi.spyOn(process, "on").mockImplementation(processOnSpy);
await expect(import("../src/server.js")).resolves.toBeDefined();
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(drizzleAdapterMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(processOnSpy).not.toHaveBeenCalled();
onSpy.mockRestore();
});
it("exposes startApi and main as explicit functions without invoking them at import time", async () => {
const module = await import("../src/server.js");
expect(typeof module.startApi).toBe("function");
expect(typeof module.main).toBe("function");
expect(serveMock).not.toHaveBeenCalled();
});
});
describe("Ekklesia API shutdown lifecycle", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await Promise.all([running.shutdown(), running.shutdown(), running.shutdown()]);
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
// Identity (via Better Auth's Drizzle adapter) and authorization both read from the same
// running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool
// composition in apps/api/src/database.ts never falls back to a second, independent pool.
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await running.shutdown();
await running.shutdown();
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x");
});
await expect(startApi(validConfig())).rejects.toMatchObject({
code: "IDENTITY_CONSTRUCTION_FAILED",
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockFailingServe(new Error("EADDRINUSE"));
await expect(startApi(validConfig())).rejects.toThrow();
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("wires SIGINT/SIGTERM to the same shutdown lifecycle and only calls the injected exit, never the real process.exit", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockResolvedValue(undefined);
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
expect(handlers.SIGINT).toEqual(expect.any(Function));
expect(handlers.SIGTERM).toEqual(expect.any(Function));
handlers.SIGINT?.();
await Promise.resolve();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(0);
// A second signal after the first must not trigger a second shutdown.
handlers.SIGTERM?.();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledTimes(1);
restore();
});
it("exits with a failure code, without throwing, when signal-triggered shutdown itself fails", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockRejectedValue(new Error("pool close failed"));
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
handlers.SIGTERM?.();
await Promise.resolve();
await Promise.resolve();
expect(exit).toHaveBeenCalledWith(1);
restore();
});
});
describe("Ekklesia API module permission composition at startup", () => {
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("discovers and composes permissions before constructing the database pool, Better Auth, or the HTTP server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// An incompatible platform version fails discovery itself (MODULE_PLATFORM_INCOMPATIBLE),
// before permission composition — and, since composition runs before any pool exists, before
// any pool, Better Auth instance, or HTTP server could ever be constructed.
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup when a module claims the reserved core.* permission namespace, creating no pool, Better Auth instance, or server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// Only a module whose own id is literally "core" can produce a "core."-prefixed permission
// that still passes the manifest's own module-namespace check.
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{ baseline: "restricted", description: "Not really Core's.", id: "core.rogue.permission" },
],
});
let captured: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModulePermissionCompositionError);
expect(captured).toMatchObject({ code: "MODULE_PERMISSION_RESERVED_NAMESPACE" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup on a duplicate permission declaration, creating no pool, Better Auth instance, or server", async () => {
const first = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const second = fakeDiscoveredModule("calendar", [
{ baseline: "public", description: "Also read events.", id: "calendar.events.read" },
]);
let captured: unknown;
try {
await startApi(validConfig(), { discoveredModulesOverride: [first, second] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PermissionRegistryError);
expect(captured).toMatchObject({ code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("keeps a reserved-namespace failure and a generic duplicate-declaration failure distinguishable", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{
baseline: "restricted",
description: "Attempting to shadow Core's own permission.",
id: "core.authorization.grants.manage",
},
],
});
let reservedNamespaceError: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
reservedNamespaceError = error;
}
const duplicateModule = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const duplicateModuleAgain = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events, again.", id: "calendar.events.read" },
]);
let duplicateError: unknown;
try {
await startApi(validConfig(), {
discoveredModulesOverride: [duplicateModule, duplicateModuleAgain],
});
} catch (error) {
duplicateError = error;
}
// Even though the first scenario's module attempted to exactly collide with an existing
// Core-owned permission id, it is reported as a reserved-namespace violation, never as a
// generic duplicate — the two failure families never overlap.
expect(reservedNamespaceError).toBeInstanceOf(ModulePermissionCompositionError);
expect(reservedNamespaceError).not.toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).not.toBeInstanceOf(ModulePermissionCompositionError);
});
it("still constructs exactly one pool and succeeds when zero modules are installed", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js");
expect(typeof module.composeStartupPermissionRegistry).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
});
});
describe("Ekklesia API module runtime startup and shutdown", () => {
afterEach(async () => {
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
vi.mocked(createApiApp).mockClear();
});
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd };
}
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(recorder.events).toEqual([
"alpha:register",
"beta:register",
"alpha:start",
"beta:start",
]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual([
"GET /api/v1/modules/alpha/ping",
"GET /api/v1/modules/beta/ping",
]);
await running.shutdown();
});
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const refs = createReferenceRecorder();
await createFixtureModule(modulesDirectory, "db-identity", {
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
});
await writeFile(
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
`export default {
id: "db-identity",
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/ping",
permission: "db-identity.ping.read",
handler: async (_request, context) => {
${refs.expression}.push(context.services.database);
return new Response(null, { status: 204 });
},
});
},
start(context) {
${refs.expression}.push(context.services.database);
}
};`,
);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
const response = await running.app.request("/api/v1/modules/db-identity/ping");
expect(response.status).toBe(204);
expect(refs.values).toHaveLength(2);
expect(refs.values[0]).toBeDefined();
expect(refs.values[0]).toBe(refs.values[1]);
await running.shutdown();
});
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "broken-export");
await writeFile(
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
"export default { thisIsNotAValidRuntimeDefinition: true };",
);
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
registerThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
});
expect(recorder.events).toEqual(["register-fails:register"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
startThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
});
expect(recorder.events).toEqual([
"one:register",
"two:register",
"one:start",
"two:start",
"one:stop",
]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
vi.mocked(createApiApp).mockImplementationOnce(() => {
throw new Error("forced app construction failure");
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow("forced app construction failure");
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
mockFailingServe(new Error("EADDRINUSE"));
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow();
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
poolConstructor.mockImplementation(function fakePool() {
return {
end: vi.fn(async () => {
recorder.events.push("pool:end");
}),
on: vi.fn(),
};
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const originalClose = server.close;
server.close = vi.fn((callback?: () => void) => {
recorder.events.push("server:close");
return originalClose(callback);
});
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
recorder.events.length = 0;
await running.shutdown();
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
});
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const refs = createReferenceRecorder();
await createFixtureModule(modulesDirectory, "db-stop-identity");
await writeFile(
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
`export default {
id: "db-stop-identity",
version: "0.1.0",
start(context) { ${refs.expression}.push(context.services.database); },
stop(context) { ${refs.expression}.push(context.services.database); }
};`,
);
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
await running.shutdown();
expect(refs.values).toHaveLength(2);
expect(refs.values[0]).toBeDefined();
expect(refs.values[0]).toBe(refs.values[1]);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
stopThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
await expect(running.shutdown()).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
});
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
await running.shutdown();
});
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
const module = await import("../src/module-http-routes.js");
expect(typeof module.mountModuleHttpRoutes).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
});