146 lines
5.4 KiB
TypeScript
146 lines
5.4 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
callIdFromStorageKey,
|
|
LocalRecordingClient,
|
|
normalizeRemoteRelativePath,
|
|
type PulledRecordingFile,
|
|
type RecordingStore,
|
|
RecordingTransferService,
|
|
type RemoteRecordingClient,
|
|
type RemoteRecordingFile,
|
|
storageKeyFromReadyPath
|
|
} from './transfer.js';
|
|
|
|
class MemoryRemote implements RemoteRecordingClient {
|
|
readonly deleted: string[] = [];
|
|
|
|
constructor(private readonly files: Map<string, Buffer>) {}
|
|
|
|
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
|
|
return Array.from(this.files.entries()).map(([relativePath, content]) => ({
|
|
relativePath,
|
|
bytes: content.length
|
|
}));
|
|
}
|
|
|
|
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
|
|
const content = this.files.get(file.relativePath);
|
|
if (!content) {
|
|
throw new Error('missing remote file');
|
|
}
|
|
await writeFile(localPath, content);
|
|
}
|
|
|
|
async deleteFile(file: RemoteRecordingFile): Promise<void> {
|
|
this.deleted.push(file.relativePath);
|
|
this.files.delete(file.relativePath);
|
|
}
|
|
}
|
|
|
|
class MemoryStore implements RecordingStore {
|
|
readonly ready: PulledRecordingFile[] = [];
|
|
|
|
async markReady(input: { storageKey: string; storagePath: string; sourceKey: string; sha256: string; bytes: bigint }): Promise<void> {
|
|
this.ready.push({
|
|
relativePath: input.sourceKey,
|
|
bytes: Number(input.bytes),
|
|
localPath: input.storagePath,
|
|
sha256: input.sha256
|
|
});
|
|
}
|
|
}
|
|
|
|
describe('recording transfer', () => {
|
|
it('normalizes ready paths safely', () => {
|
|
expect(normalizeRemoteRelativePath('/2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav.ready');
|
|
expect(storageKeyFromReadyPath('2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav');
|
|
expect(() => normalizeRemoteRelativePath('../call.wav.ready')).toThrow(/Unsafe/);
|
|
expect(() => storageKeyFromReadyPath('2026/06/21/call.wav')).toThrow(/ready/);
|
|
});
|
|
|
|
it('derives call IDs from RTPEngine recording names', () => {
|
|
expect(callIdFromStorageKey('2026/06/22/s28-1782091361841-kntoq1y9%40lisglosips-t-c68d7ba1de875f62-mix.wav')).toBe(
|
|
's28-1782091361841-kntoq1y9@lisglosips-t'
|
|
);
|
|
});
|
|
|
|
it('copies, hashes, marks ready, and deletes source only after verification', async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
|
|
const remote = new MemoryRemote(new Map([['2026/06/21/call.wav.ready', Buffer.from('audio-bytes')]]));
|
|
const store = new MemoryStore();
|
|
const service = new RecordingTransferService(remote, store, {
|
|
localRoot: root,
|
|
deleteSourceAfterCopy: true,
|
|
maxFilesPerScan: 10
|
|
});
|
|
|
|
try {
|
|
const result = await service.scanOnce();
|
|
expect(result).toEqual({ moved: 1, skipped: 0, failed: 0 });
|
|
expect(store.ready).toHaveLength(1);
|
|
expect(store.ready[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(remote.deleted).toEqual(['2026/06/21/call.wav.ready']);
|
|
expect(await readFile(path.join(root, '2026/06/21/call.wav'), 'utf8')).toBe('audio-bytes');
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('lists, copies, and deletes ready files from a local source directory', async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-local-rec-'));
|
|
const readyDir = path.join(root, 'ready');
|
|
const outputDir = path.join(root, 'recordings');
|
|
const sourcePath = path.join(readyDir, '2026', '08', '27', 'call.wav.ready');
|
|
const client = new LocalRecordingClient({ readyDir });
|
|
const store = new MemoryStore();
|
|
const service = new RecordingTransferService(client, store, {
|
|
localRoot: outputDir,
|
|
deleteSourceAfterCopy: true,
|
|
maxFilesPerScan: 10
|
|
});
|
|
|
|
try {
|
|
await mkdir(path.dirname(sourcePath), { recursive: true });
|
|
await writeFile(sourcePath, 'local-audio');
|
|
await writeFile(path.join(readyDir, 'ignored.part'), 'incomplete');
|
|
|
|
await expect(client.listReadyFiles()).resolves.toEqual([
|
|
{ relativePath: '2026/08/27/call.wav.ready', bytes: 11 }
|
|
]);
|
|
await expect(service.scanOnce()).resolves.toEqual({ moved: 1, skipped: 0, failed: 0 });
|
|
await expect(readFile(path.join(outputDir, '2026', '08', '27', 'call.wav'), 'utf8')).resolves.toBe('local-audio');
|
|
await expect(stat(sourcePath)).rejects.toThrow();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps source file when local size verification fails', async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
|
|
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
|
|
const originalList = remote.listReadyFiles.bind(remote);
|
|
remote.listReadyFiles = async () => (await originalList()).map((file) => ({ ...file, bytes: file.bytes + 1 }));
|
|
const store = new MemoryStore();
|
|
const service = new RecordingTransferService(remote, store, {
|
|
localRoot: root,
|
|
deleteSourceAfterCopy: true,
|
|
maxFilesPerScan: 10
|
|
});
|
|
|
|
try {
|
|
const result = await service.scanOnce();
|
|
expect(result).toEqual({ moved: 0, skipped: 0, failed: 1 });
|
|
expect(store.ready).toHaveLength(0);
|
|
expect(remote.deleted).toHaveLength(0);
|
|
await expect(stat(path.join(root, 'bad.wav'))).rejects.toThrow();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|