Files
TREK/server/tests/unit/nest/storage/storage-driver.contract.ts
T

141 lines
5.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Readable } from 'node:stream';
import fs from 'node:fs';
import path from 'node:path';
import {
StorageInvalidKeyError,
StorageNotFoundError,
type ObjectStat,
type StorageDriver,
} from '../../../../src/nest/storage/storage.types';
/**
* The parameterized driver contract suite — the groundwork's real product
* (spec, Testing). Every driver must pass it: LocalDriver and MirrorDriver in
* v1; a future S3 driver imports this same suite (against MinIO/localstack)
* and must pass before touching TREK code.
*
* Not a `.test.ts` on purpose: vitest only collects files matching the
* `*.test.ts` include, so this suite runs exclusively through a driver's own
* spec file.
*/
export interface DriverHarness {
driver: StorageDriver;
/** Create a temp file whose ownership put() may take (LocalTempFile source). */
makeTempFile(contents: string | Buffer): Promise<string>;
cleanup(): Promise<void>;
}
const INVALID_KEYS = ['../x', '/abs', 'a\\b', 'a//b', '.tmp/x', 'a/.h', 'a\u0000b'];
export function describeStorageDriver(name: string, makeHarness: () => Promise<DriverHarness>): void {
describe(`storage driver contract: ${name}`, () => {
let h: DriverHarness;
beforeEach(async () => {
h = await makeHarness();
});
afterEach(async () => {
await h.cleanup();
});
async function readAll(key: string, range?: { start: number; end?: number }): Promise<Buffer> {
const { stream } = await h.driver.getStream(key, range);
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk as Buffer));
return Buffer.concat(chunks);
}
async function listAll(prefix: string): Promise<ObjectStat[]> {
const out: ObjectStat[] = [];
for await (const stat of h.driver.list(prefix)) out.push(stat);
return out;
}
it('roundtrips a stream source and stats its size', async () => {
await h.driver.put('a/stream.bin', Readable.from(Buffer.from('hello storage')));
expect((await readAll('a/stream.bin')).toString()).toBe('hello storage');
const stat = await h.driver.stat('a/stream.bin');
expect(stat).toMatchObject({ key: 'a/stream.bin', size: 13 });
expect(stat!.mtimeMs).toBeGreaterThan(0);
});
it('roundtrips a LocalTempFile source and consumes the temp file', async () => {
const tmp = await h.makeTempFile('from tmp file');
await h.driver.put('a/tmpfile.bin', { tmpPath: tmp });
expect((await readAll('a/tmpfile.bin')).toString()).toBe('from tmp file');
expect(fs.existsSync(tmp)).toBe(false); // ownership transferred
});
it('overwrites an existing key', async () => {
await h.driver.put('a/over.bin', Readable.from('first'));
await h.driver.put('a/over.bin', Readable.from('second!'));
expect((await readAll('a/over.bin')).toString()).toBe('second!');
expect((await h.driver.stat('a/over.bin'))!.size).toBe(7);
});
it('serves closed and open-ended byte ranges', async () => {
await h.driver.put('a/range.bin', Readable.from('0123456789'));
expect((await readAll('a/range.bin', { start: 2, end: 5 })).toString()).toBe('2345');
expect((await readAll('a/range.bin', { start: 7 })).toString()).toBe('789');
});
it('rejects getStream on a missing key with StorageNotFoundError', async () => {
await expect(h.driver.getStream('a/nope.bin')).rejects.toBeInstanceOf(StorageNotFoundError);
});
it('stats a missing key as null', async () => {
expect(await h.driver.stat('a/nope.bin')).toBeNull();
});
it('deletes idempotently', async () => {
await h.driver.put('a/gone.bin', Readable.from('x'));
await h.driver.delete('a/gone.bin');
expect(await h.driver.stat('a/gone.bin')).toBeNull();
await expect(h.driver.delete('a/gone.bin')).resolves.toBeUndefined();
});
it('lists by prefix with full keys and sizes', async () => {
await h.driver.put('a/one.bin', Readable.from('11'));
await h.driver.put('a/sub/two.bin', Readable.from('222'));
await h.driver.put('b/three.bin', Readable.from('3'));
const underA = await listAll('a/');
expect(underA.map((s) => s.key).sort()).toEqual(['a/one.bin', 'a/sub/two.bin']);
expect(underA.find((s) => s.key === 'a/sub/two.bin')!.size).toBe(3);
const everything = await listAll('');
expect(everything.map((s) => s.key).sort()).toEqual(['a/one.bin', 'a/sub/two.bin', 'b/three.bin']);
});
it.each(INVALID_KEYS)('rejects invalid key %j on every method', async (key) => {
await expect(h.driver.put(key, Readable.from('x'))).rejects.toBeInstanceOf(StorageInvalidKeyError);
await expect(h.driver.getStream(key)).rejects.toBeInstanceOf(StorageInvalidKeyError);
await expect(h.driver.stat(key)).rejects.toBeInstanceOf(StorageInvalidKeyError);
await expect(h.driver.delete(key)).rejects.toBeInstanceOf(StorageInvalidKeyError);
});
it('leaves no object and no visible partial when the source stream errors', async () => {
const boom = new Readable({
read() {
this.push('partial bytes');
this.destroy(new Error('source exploded'));
},
});
await expect(h.driver.put('a/broken.bin', boom)).rejects.toThrow();
expect(await h.driver.stat('a/broken.bin')).toBeNull();
expect((await listAll('')).map((s) => s.key)).toEqual([]);
});
it('honors the getSpoolDir contract', async () => {
const spool = h.driver.getSpoolDir?.() ?? null;
if (spool === null) return; // drivers with no local filesystem
expect(fs.existsSync(spool)).toBe(true);
// Spool contents are invisible to the abstraction by construction.
fs.writeFileSync(path.join(spool, 'leftover.part'), 'junk');
expect((await listAll('')).map((s) => s.key)).toEqual([]);
});
});
}