import { createHash } from "node:crypto"; import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js"; import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js"; import { discoverCoreMigrations } from "../src/migrations/index.js"; import { ModuleDiscoveryError } from "../src/modules/index.js"; import { createMigrationModule } from "./support/migration-module-fixture.js"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); }); async function externalModulesDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-")); temporaryDirectories.push(directory); return directory; } describe("operator commands select the modules directory through host configuration", () => { it("defaults to the repository's own modules directory", () => { expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({ modulesDirectory: "/opt/ekklesia/modules", modulesDirectoryRequired: false, }); }); it("honours EKKLESIA_MODULES_DIR", () => { expect( resolveCommandModulesDirectory( { EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }, "/opt/ekklesia", ), ).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true }); }); it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => { expect(() => resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"), ).toThrow(ModuleDiscoveryError); try { resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"); } catch (error) { expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f"); } }); it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => { // A minimal installed module generated from nothing in the repository, so this test never // depends on build output or ignored files: a clean checkout runs it before anything is built. const modulesDirectory = await externalModulesDirectory(); const installed = await createMigrationModule(modulesDirectory, "example"); expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true); const aggregate = await discoverRepositoryMigrations(repositoryRoot, { EKKLESIA_MODULES_DIR: modulesDirectory, }); expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]); // Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`. const platformCore = await discoverCoreMigrations({ coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"), }); const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) => migrations.map((migration) => `${migration.identity} ${migration.checksum}`); expect(platformCore.length).toBeGreaterThan(0); expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore)); // The module's migration is the one installed outside the repository, byte for byte. const installedSql = await readFile( path.join(installed.migrationDirectory ?? "", "0000_initial.sql"), ); expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([ `example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`, ]); }); it("fails closed when the configured directory does not exist", async () => { const parent = await externalModulesDirectory(); await expect( discoverRepositoryMigrations(repositoryRoot, { EKKLESIA_MODULES_DIR: path.join(parent, "absent"), }), ).rejects.toBeInstanceOf(ModuleDiscoveryError); }); }); describe("no source chooses a modules directory except the resolver", () => { async function sourceFiles(directory: string): Promise { const entries = await readdir(directory, { recursive: true, withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) .map((entry) => path.join(entry.parentPath, entry.name)); } it("never joins `modules` onto a root anywhere else in Core or the API", async () => { const files = [ ...(await sourceFiles(path.join(repositoryRoot, "core", "src"))), ...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))), ]; const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts"); for (const file of files) { if (file === resolver) { continue; } const text = await readFile(file, "utf8"); expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u); } }); });