import { 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 })); import { createApiRuntimeConfig } from "../src/config.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js"; function validConfig() { return createApiRuntimeConfig( { databaseUrl: "postgresql://ekklesia:ekklesia@127.0.0.1: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" }, ); } /** 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 void>; restore: () => void } { const handlers: Record 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("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:secret@db.internal.example/x"); }); await expect(startApi(validConfig())).rejects.toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED", }); 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(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(); }); });