131 lines
4.6 KiB
TypeScript
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,
|
|
};
|
|
}
|