Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { 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,
|
||||
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('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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user