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; }; const internals = (service: MediaProbeService): ProbeInternals => service as unknown as ProbeInternals; describe('MediaProbeService', () => { const values: Record = {}; 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(); }); });