Files
openmaic/OpenMAIC/packages/@openmaic/storage/test/s3-asset-isolation.test.ts
2026-08-16 14:58:47 +08:00

113 lines
3.7 KiB
TypeScript

import { afterEach, expect, test, vi } from 'vitest';
import { contentHashOf, type ContentHash } from '../src/asset/blob.js';
afterEach(() => {
vi.doUnmock('@aws-sdk/client-s3');
vi.resetModules();
});
async function hashFor(value: string): Promise<ContentHash> {
return (await contentHashOf(new Blob([value]))).contentHash;
}
/**
* How long "the SDK was not resolved" is given to be disproved.
*
* This assertion is a negative, so it needs a window rather than a tick:
* checking the counter on the same turn as the constructor proves nothing,
* because a constructor that fires `import()` and drops the promise also reads
* zero there. A module resolution that has been started completes in a few
* milliseconds, so a resolution still absent after this window was never
* started.
*/
const RESOLUTION_WINDOW_MS = 250;
function quietFor(signal: Promise<'resolved'>): Promise<'resolved' | 'quiet'> {
return Promise.race([
signal,
new Promise<'quiet'>((resolve) => setTimeout(() => resolve('quiet'), RESOLUTION_WINDOW_MS)),
]);
}
test('loading the S3 byte-store module does not resolve the optional AWS SDK', async () => {
const sdkModuleResolved = vi.fn();
vi.doMock('@aws-sdk/client-s3', () => {
sdkModuleResolved();
throw new Error('the SDK must stay unresolved until the loader is called');
});
await expect(import('../src/asset/s3-bytes.js')).resolves.toHaveProperty('S3AssetByteStore');
expect(sdkModuleResolved).not.toHaveBeenCalled();
});
test('a { client, bucket } store resolves the SDK on first use, not at construction', async () => {
let sdkResolutions = 0;
let announceResolution!: () => void;
const resolutionSignal = new Promise<'resolved'>((resolve) => {
announceResolution = () => resolve('resolved');
});
vi.doMock('@aws-sdk/client-s3', () => {
sdkResolutions += 1;
announceResolution();
return {
S3Client: class {},
PutObjectCommand: class {
constructor(readonly input: unknown) {}
},
GetObjectCommand: class {
constructor(readonly input: unknown) {}
},
DeleteObjectCommand: class {
constructor(readonly input: unknown) {}
},
};
});
const { S3AssetByteStore } = await import('../src/asset/s3-bytes.js');
const sent: unknown[] = [];
const store = new S3AssetByteStore({
client: {
send: async (command: unknown) => {
sent.push(command);
return {};
},
},
bucket: 'lazy-commands',
});
// Constructing the store must not reach the optional peer dependency: a
// deployment that configures a bucket but never stores a byte never resolves it.
await expect(quietFor(resolutionSignal)).resolves.toBe('quiet');
expect(sdkResolutions).toBe(0);
const hash = await hashFor('lazy commands');
await store.write(hash, new TextEncoder().encode('lazy commands'));
expect(sdkResolutions).toBe(1);
// Cached: a second call reuses the resolved constructors.
await store.delete(hash);
expect(sdkResolutions).toBe(1);
expect(sent).toHaveLength(2);
});
test('an unresolvable SDK fails the first call by naming the optional dependency', async () => {
vi.doMock('@aws-sdk/client-s3', () => {
throw new Error('Cannot find module');
});
const { S3AssetByteStore } = await import('../src/asset/s3-bytes.js');
const send = vi.fn();
const store = new S3AssetByteStore({ client: { send }, bucket: 'no-sdk' });
const hash = await hashFor('no sdk');
// Named, rather than an undefined-property crash or an opaque "write failed".
for (const operation of [
() => store.write(hash, new TextEncoder().encode('no sdk')),
() => store.read(hash),
() => store.delete(hash),
]) {
await expect(operation()).rejects.toThrow(/@aws-sdk\/client-s3/);
}
expect(send).not.toHaveBeenCalled();
});