Files
back_end_oudelaa/src/infrastructure/storage/media-probe.service.spec.ts
T

143 lines
6.7 KiB
TypeScript

import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { MediaProbeService } from './media-probe.service';
jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), rm: jest.fn(), writeFile: jest.fn() }));
type FakeChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: jest.Mock;
};
const fakeChild = (): FakeChild => {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
};
type ProbeInternals = {
runCommand(command: string, args: string[], timeout: number): Promise<string>;
};
const internals = (service: MediaProbeService): ProbeInternals =>
service as unknown as ProbeInternals;
describe('MediaProbeService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/media-probe');
(writeFile as jest.Mock).mockResolvedValue(undefined);
(rm as jest.Mock).mockResolvedValue(undefined);
});
it('derives ffprobe beside configured ffmpeg on Windows and Unix', () => {
values['videoProcessing.ffmpegPath'] = ' C:\\ffmpeg\\ffmpeg.exe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('C:\\ffmpeg\\ffprobe.exe');
values['videoProcessing.ffmpegPath'] = '/usr/local/bin/ffmpeg';
expect(new MediaProbeService(config).getFfprobePath()).toBe('/usr/local/bin/ffprobe');
values['videoProcessing.ffprobePath'] = ' custom-probe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('custom-probe');
values['videoProcessing.ffprobePath'] = '';
values['videoProcessing.ffmpegPath'] = 'wrapper';
expect(new MediaProbeService(config).getFfprobePath()).toBe('ffprobe');
});
it('caches successful and failed command probes', async () => {
const success = new MediaProbeService(config);
const run = jest.spyOn(internals(success), 'runCommand').mockResolvedValue('ffmpeg version 7\nother');
await expect(success.checkFfmpeg()).resolves.toEqual({ path: 'ffmpeg', available: true, version: 'ffmpeg version 7' });
await success.checkFfmpeg();
expect(run).toHaveBeenCalledTimes(1);
const failure = new MediaProbeService(config);
const fail = jest.spyOn(internals(failure), 'runCommand').mockRejectedValue('not installed');
await expect(failure.checkFfprobe()).resolves.toEqual({ path: 'ffprobe', available: false, version: '', error: 'unknown command error' });
await failure.checkFfprobe();
expect(fail).toHaveBeenCalledTimes(1);
});
it('extracts rounded positive durations and rejects unusable output', async () => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: true, version: '1' });
const run = jest.spyOn(internals(service), 'runCommand');
run.mockResolvedValueOnce('12.6\n').mockResolvedValueOnce('0').mockResolvedValueOnce('NaN').mockRejectedValueOnce(new Error('bad media'));
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBe(13);
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: false, version: '' });
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
});
it.each([
['clip.mp4', undefined, '.mp4'], [undefined, 'video/quicktime', '.mov'],
[undefined, 'video/webm', '.webm'], [undefined, 'audio/mpeg', '.mp3'],
[undefined, 'audio/x-m4a', '.m4a'], [undefined, 'audio/x-wav', '.wav'],
[undefined, 'audio/aac', '.aac'], [undefined, 'audio/ogg', '.ogg'],
[undefined, 'other', '.media'],
])('uses safe buffer extension for %p/%p', async (originalname, mimetype, expected) => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'extractDurationSeconds').mockResolvedValue(8);
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('content'), { originalname, mimetype })).resolves.toBe(8);
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining(expected), Buffer.from('content'));
expect(rm).toHaveBeenCalledWith('/tmp/media-probe', { recursive: true, force: true });
});
it('handles empty buffers and temporary file failures', async () => {
const service = new MediaProbeService(config);
await expect(service.extractDurationSecondsFromBuffer(Buffer.alloc(0))).resolves.toBeNull();
expect(mkdtemp).not.toHaveBeenCalled();
(writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('x'), { originalname: 'bad.mp4' })).resolves.toBeNull();
expect(rm).toHaveBeenCalled();
});
it('runs commands and handles output, process errors, and nonzero exits', async () => {
const service = new MediaProbeService(config);
const invoke = internals(service).runCommand.bind(service);
const success = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(success);
const successPromise = invoke('probe', ['-version'], 100);
success.stdout.emit('data', Buffer.from('version'));
success.stderr.emit('data', Buffer.from('warning'));
success.emit('close', 0);
await expect(successPromise).resolves.toBe('version');
const failed = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(failed);
const failedPromise = invoke('probe', [], 100);
failed.stderr.emit('data', Buffer.from('invalid'));
failed.emit('close', 2);
await expect(failedPromise).rejects.toThrow('invalid');
const errored = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(errored);
const errorPromise = invoke('probe', [], 100);
errored.emit('error', new Error('spawn error'));
await expect(errorPromise).rejects.toThrow('spawn error');
});
it('kills commands that exceed their timeout', async () => {
jest.useFakeTimers();
const service = new MediaProbeService(config);
const child = fakeChild();
(spawn as jest.Mock).mockReturnValue(child);
const promise = internals(service).runCommand('slow', [], 10);
jest.advanceTimersByTime(11);
await expect(promise).rejects.toThrow('timed out after 10ms');
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
jest.useRealTimers();
});
});