import { type ModuleStorage, type ModuleStorageBucket, ModuleStorageError, type ModuleStorageListing, type ModuleStorageObject, type ModuleStorageObjectMetadata, type ModuleStorageReadUrl, } from "@ekklesia/module-sdk"; /** * An in-memory `ModuleStorage`, scoped exactly as the real one is. * * `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately * construct one — and doing so demonstrates the portability claim directly: the host supplying this * object could be Ekklesia over S3-compatible storage, or a future standalone host over something * else entirely, and Signage cannot tell. * * It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally * pass by reaching a bucket the manifest never declared. */ export interface FakeStorage extends ModuleStorage { readonly objects: Map; /** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */ failPut(key: string): void; readonly readUrlsIssued: string[]; } export function createFakeStorage( options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {}, ): FakeStorage { const declared = new Set(options.declared ?? ["native-images"]); const now = options.now ?? (() => new Date()); const objects = new Map(); const failingPuts = new Set(); const readUrlsIssued: string[] = []; function makeBucket(id: string): ModuleStorageBucket { return { createReadUrl(key: string): Promise { readUrlsIssued.push(key); // Opaque by contract: shape, host, and query are the host's business. A module that parsed // one would be welded to a deployment. return Promise.resolve({ expiresAt: new Date(now().getTime() + 15 * 60 * 1000), url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`, }); }, delete(key: string): Promise { objects.delete(`${id}/${key}`); return Promise.resolve(); }, get(key: string): Promise { const stored = objects.get(`${id}/${key}`); if (!stored) { return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key)); } return Promise.resolve({ body: new ReadableStream({ start(controller) { controller.enqueue(stored.bytes); controller.close(); }, }), contentLength: stored.bytes.byteLength, key, ...(stored.contentType === undefined ? {} : { contentType: stored.contentType }), }); }, head(key: string): Promise { const stored = objects.get(`${id}/${key}`); if (!stored) { return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key)); } return Promise.resolve({ contentLength: stored.bytes.byteLength, key }); }, id, list(options_?: { readonly prefix?: string }): Promise { const prefix = `${id}/${options_?.prefix ?? ""}`; return Promise.resolve({ objects: [...objects.entries()] .filter(([key]) => key.startsWith(prefix)) .map(([key, value]) => ({ contentLength: value.bytes.byteLength, key: key.slice(id.length + 1), })), }); }, put( key: string, body: unknown, putOptions?: { readonly contentType?: string }, ): Promise { if (failingPuts.has(key)) { failingPuts.delete(key); return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key)); } if (!(body instanceof Uint8Array)) { return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key)); } objects.set(`${id}/${key}`, { bytes: body, ...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }), }); return Promise.resolve({ contentLength: body.byteLength, key }); }, }; } return { bucket(logicalBucketId: string): ModuleStorageBucket { if (!declared.has(logicalBucketId)) { throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId); } return makeBucket(logicalBucketId); }, buckets: () => [...declared].sort(), failPut(key: string): void { failingPuts.add(key); }, objects, readUrlsIssued, }; }