Files
Signage/tests/support/fake-storage.ts
2026-08-28 16:41:54 -05:00

131 lines
4.6 KiB
TypeScript

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<string, { bytes: Uint8Array; contentType?: string }>;
/** 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<string, { bytes: Uint8Array; contentType?: string }>();
const failingPuts = new Set<string>();
const readUrlsIssued: string[] = [];
function makeBucket(id: string): ModuleStorageBucket {
return {
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
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<void> {
objects.delete(`${id}/${key}`);
return Promise.resolve();
},
get(key: string): Promise<ModuleStorageObject> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(stored.bytes);
controller.close();
},
}),
contentLength: stored.bytes.byteLength,
key,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
},
head(key: string): Promise<ModuleStorageObjectMetadata> {
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<ModuleStorageListing> {
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<ModuleStorageObjectMetadata> {
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,
};
}