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) {} async listReadyFiles(): Promise { return Array.from(this.files.entries()).map(([relativePath, content]) => ({ relativePath, bytes: content.length })); } async pullFile(file: RemoteRecordingFile, localPath: string): Promise { const content = this.files.get(file.relativePath); if (!content) { throw new Error('missing remote file'); } await writeFile(localPath, content); } async deleteFile(file: RemoteRecordingFile): Promise { 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 { 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 }); } }); });