import { parseModuleManifest } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import type { DiscoveredModule } from "../src/modules/discovery.js"; import { isStorageBlockedDetail, resolveModuleEligibility, resolveModuleLifecycleView, } from "../src/modules/lifecycle.js"; /** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real * discovery would reject can never stand in for one it would accept. */ function fakeModule( id: string, options: { readonly buckets?: readonly string[]; readonly dependencies?: Record; readonly configuration?: readonly Record[]; } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ manifestVersion: 6, id, name: `${id} module`, version: "1.0.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, ...(options.configuration ? { configuration: options.configuration } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.buckets ? { storage: { buckets: options.buckets.map((id) => ({ id })) } } : {}), entrypoints: { server: "./dist/does-not-exist/index.js" }, }); return { directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, manifestPath: `/modules/${id}/ekklesia.module.json`, packageName: `@test/${id}`, }; } function enablement(...records: readonly (readonly [string, boolean])[]) { return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) }; } const noConfiguration = { settings: { records: [] } }; const withStorage = { providerConfigured: true }; const withoutStorage = { providerConfigured: false }; function ids(modules: readonly DiscoveredModule[]): readonly string[] { return modules.map((module) => module.manifest.id); } function entry( resolution: ReturnType, moduleId: string, ): ReturnType["entries"][number] { const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId); if (!found) { throw new Error(`No eligibility entry for ${moduleId}.`); } return found; } describe("declared storage decides eligibility", () => { it("keeps a module requiring storage eligible when a provider is configured", () => { const resolution = resolveModuleEligibility( [fakeModule("announcements", { buckets: ["announcement-assets"] })], enablement(), noConfiguration, withStorage, ); expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); expect(entry(resolution, "announcements").blocked).toBeUndefined(); }); it("blocks a module requiring storage when the host has no provider, naming the logical bucket", () => { const resolution = resolveModuleEligibility( [fakeModule("announcements", { buckets: ["announcement-assets"] })], enablement(), noConfiguration, withoutStorage, ); expect(resolution.eligibleModules).toEqual([]); expect(entry(resolution, "announcements").blocked).toEqual({ reason: "storage_unavailable", storageBucketId: "announcement-assets", }); }); it("leaves a module declaring no buckets entirely unaffected by storage", () => { const resolution = resolveModuleEligibility( [fakeModule("calendar")], enablement(), noConfiguration, withoutStorage, ); // An installation with no object storage is a complete, valid deployment. expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); expect(entry(resolution, "calendar").blocked).toBeUndefined(); }); it("does not evaluate a disabled module's storage requirement", () => { const resolution = resolveModuleEligibility( [fakeModule("announcements", { buckets: ["announcement-assets"] })], enablement(["announcements", false]), noConfiguration, withoutStorage, ); // Disabled is not blocked: reporting a storage problem for something nobody asked to run would // be noise. expect(entry(resolution, "announcements").blocked).toBeUndefined(); expect(entry(resolution, "announcements").configured.enabled).toBe(false); }); it("defaults to no provider when the caller supplies no storage input at all", () => { // Every pre-existing caller keeps its exact behaviour: the parameter is optional, and a module // that declares no bucket is unaffected either way. const withoutArgument = resolveModuleEligibility([fakeModule("calendar")], enablement()); expect(ids(withoutArgument.eligibleModules)).toEqual(["calendar"]); const requiring = resolveModuleEligibility( [fakeModule("announcements", { buckets: ["assets"] })], enablement(), ); expect(requiring.eligibleModules).toEqual([]); }); it("names only the logical bucket — never an endpoint, credential, or physical bucket", () => { const resolution = resolveModuleEligibility( [fakeModule("announcements", { buckets: ["announcement-assets"] })], enablement(), noConfiguration, withoutStorage, ); const serialized = JSON.stringify(resolution.entries); expect(serialized).toContain("announcement-assets"); expect(serialized).not.toContain("ekklesia-announcements--"); expect(serialized).not.toContain("http"); expect(serialized).not.toMatch(/accessKey|secretKey|endpoint/iu); }); }); describe("storage blocking propagates through the dependency graph", () => { const graph = [ fakeModule("media-storage", { buckets: ["objects"] }), fakeModule("media", { dependencies: { modules: { required: { "media-storage": "^1.0.0" } } } }), fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }), ]; it("blocks required dependents of a storage-blocked module, transitively", () => { const resolution = resolveModuleEligibility( graph, enablement(), noConfiguration, withoutStorage, ); expect(resolution.eligibleModules).toEqual([]); // One mechanism, not two: the chain terminates at the module whose own requirement is // unsatisfiable, exactly as it terminates at a disabled or misconfigured module. expect(entry(resolution, "media-storage").blocked).toEqual({ reason: "storage_unavailable", storageBucketId: "objects", }); expect(entry(resolution, "media").blocked).toEqual({ dependency: { kind: "module", moduleId: "media-storage" }, reason: "required_dependency_blocked", }); expect(entry(resolution, "announcements").blocked).toEqual({ dependency: { kind: "module", moduleId: "media" }, reason: "required_dependency_blocked", }); }); it("unblocks the whole chain once a provider is configured", () => { const resolution = resolveModuleEligibility(graph, enablement(), noConfiguration, withStorage); expect(ids(resolution.eligibleModules)).toEqual(["media-storage", "media", "announcements"]); }); it("never blocks an unrelated module because another module needs storage", () => { const resolution = resolveModuleEligibility( [...graph, fakeModule("calendar")], enablement(), noConfiguration, withoutStorage, ); expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); }); }); describe("storage and configuration blocking coexist without a second mechanism", () => { it("reports the storage requirement first when both are unsatisfiable", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { buckets: ["assets"], configuration: [{ key: "bucketLabel", type: "string", required: true }], }), ], enablement(), noConfiguration, withoutStorage, ); // Deterministic ordering — storage is evaluated before configuration — so an operator always // sees the same reason for the same state rather than one that depends on evaluation order. const blocked = entry(resolution, "announcements").blocked; expect(blocked && isStorageBlockedDetail(blocked)).toBe(true); }); it("falls through to the configuration reason once storage is available", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { buckets: ["assets"], configuration: [{ key: "bucketLabel", type: "string", required: true }], }), ], enablement(), noConfiguration, withStorage, ); expect(entry(resolution, "announcements").blocked).toEqual({ configurationKey: "bucketLabel", reason: "configuration_missing", }); }); }); describe("the lifecycle view reports storage state honestly", () => { const discoveredModules = [fakeModule("announcements", { buckets: ["announcement-assets"] })]; function bootSnapshot(storage: { providerConfigured: boolean }) { const resolution = resolveModuleEligibility( discoveredModules, enablement(), noConfiguration, storage, ); return { discoveredModules, eligibility: resolution.entries, startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id), }; } it("reports a storage-blocked module with the logical bucket and no pending restart", () => { const view = resolveModuleLifecycleView( bootSnapshot(withoutStorage), enablement(), noConfiguration, withoutStorage, ); expect(view[0]?.runtime).toEqual({ reason: "storage_unavailable", state: "blocked", storageBucketId: "announcement-assets", }); expect(view[0]?.restartRequired).toBe(false); }); it("reports a pending restart once a provider is configured for a blocked module", () => { const view = resolveModuleLifecycleView( bootSnapshot(withoutStorage), enablement(), noConfiguration, withStorage, ); // The next boot really will start it, so the honest answer is that a restart is pending — while // the runtime line still describes what *this* process did. expect(view[0]?.runtime.state).toBe("blocked"); expect(view[0]?.restartRequired).toBe(true); }); it("reports a running module with storage as ordinary and unremarkable", () => { const view = resolveModuleLifecycleView( bootSnapshot(withStorage), enablement(), noConfiguration, withStorage, ); expect(view[0]?.runtime).toEqual({ state: "running" }); expect(view[0]?.restartRequired).toBe(false); }); it("puts no infrastructure detail into the view", () => { const view = resolveModuleLifecycleView( bootSnapshot(withoutStorage), enablement(), noConfiguration, withoutStorage, ); const serialized = JSON.stringify(view); expect(serialized).toContain("announcement-assets"); expect(serialized).not.toContain("ekklesia-announcements--"); expect(serialized).not.toMatch(/http|accessKey|secretKey|seaweed/iu); }); });