143 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|