@quatrain/worker 1.2.6 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const node_fs_1 = __importDefault(require("node:fs"));
40
+ const node_path_1 = __importDefault(require("node:path"));
41
+ const FileSystem_1 = require("./FileSystem");
42
+ const axios_1 = __importDefault(require("axios"));
43
+ const node_fetch_native_1 = __importDefault(require("node-fetch-native"));
44
+ const ffmpeg = __importStar(require("fluent-ffmpeg"));
45
+ const node_stream_1 = require("node:stream");
46
+ jest.mock('axios');
47
+ jest.mock('node-fetch-native');
48
+ jest.mock('fluent-ffmpeg', () => ({
49
+ ffprobe: jest.fn()
50
+ }));
51
+ // Mock @quatrain/log globally to completely silence all logging calls across the monorepo graph during worker tests.
52
+ // Returns a valid dummy logger object to satisfy Core.addLogger and Worker.addLogger definitions.
53
+ jest.mock('@quatrain/log', () => {
54
+ const dummyLoggerInstance = {
55
+ log: jest.fn(),
56
+ info: jest.fn(),
57
+ debug: jest.fn(),
58
+ error: jest.fn(),
59
+ warn: jest.fn()
60
+ };
61
+ return {
62
+ Log: {
63
+ info: jest.fn(),
64
+ debug: jest.fn(),
65
+ error: jest.fn(),
66
+ warn: jest.fn(),
67
+ addLogger: jest.fn().mockReturnValue(dummyLoggerInstance)
68
+ },
69
+ LogLevel: {
70
+ INFO: 1,
71
+ DEBUG: 0,
72
+ ERROR: 3,
73
+ WARN: 2
74
+ },
75
+ DefaultLoggerAdapter: jest.fn().mockImplementation(() => dummyLoggerInstance)
76
+ };
77
+ });
78
+ const originalExistsSync = node_fs_1.default.existsSync;
79
+ describe('FileSystem Utilities (Worker)', () => {
80
+ const testBaseDir = node_path_1.default.resolve(__dirname, 'temp_test_dir');
81
+ beforeEach(() => {
82
+ // Mock createWriteStream and createReadStream to return in-memory streams.
83
+ // This completely prevents physical file descriptor locks and background ENOENT races.
84
+ jest.spyOn(node_fs_1.default, 'createWriteStream').mockImplementation(() => {
85
+ return new node_stream_1.PassThrough();
86
+ });
87
+ jest.spyOn(node_fs_1.default, 'createReadStream').mockImplementation(() => {
88
+ return node_stream_1.Readable.from(Buffer.alloc(35 * 1024));
89
+ });
90
+ jest.spyOn(node_fs_1.default, 'existsSync').mockImplementation((p) => {
91
+ if (typeof p === 'string' && (p.endsWith('test.txt') || p.endsWith('small.mp4') || p.endsWith('large.mp4') || p.endsWith('large-fail.mp4'))) {
92
+ return true;
93
+ }
94
+ return originalExistsSync(p);
95
+ });
96
+ jest.spyOn(node_fs_1.default, 'statSync').mockImplementation((p) => {
97
+ if (typeof p === 'string') {
98
+ if (p.endsWith('small.mp4')) {
99
+ return { size: 13 };
100
+ }
101
+ if (p.endsWith('large.mp4') || p.endsWith('large-fail.mp4')) {
102
+ return { size: 35 * 1024 };
103
+ }
104
+ }
105
+ return (jest.requireActual('node:fs').statSync)(p);
106
+ });
107
+ // Setup base temp folder if needed
108
+ if (originalExistsSync(testBaseDir)) {
109
+ node_fs_1.default.rmSync(testBaseDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ afterEach(() => {
113
+ // Clean up base temp folder
114
+ if (originalExistsSync(testBaseDir)) {
115
+ node_fs_1.default.rmSync(testBaseDir, { recursive: true, force: true });
116
+ }
117
+ jest.clearAllMocks();
118
+ jest.restoreAllMocks();
119
+ });
120
+ describe('prepare and Directory Actions', () => {
121
+ it('should prepare standard workspace layouts', () => {
122
+ // Restore real fs methods for actual directory testing
123
+ jest.restoreAllMocks();
124
+ FileSystem_1.FileSystem.prepare(testBaseDir);
125
+ expect(node_fs_1.default.existsSync(testBaseDir)).toBe(true);
126
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(testBaseDir, 'images'))).toBe(true);
127
+ expect(node_fs_1.default.existsSync(node_path_1.default.join(testBaseDir, 'vecto'))).toBe(true);
128
+ });
129
+ it('should create directories using makeFolder', () => {
130
+ jest.restoreAllMocks();
131
+ node_fs_1.default.mkdirSync(testBaseDir);
132
+ const subDir = node_path_1.default.join(testBaseDir, 'custom');
133
+ FileSystem_1.FileSystem.makeFolder(subDir);
134
+ expect(node_fs_1.default.existsSync(subDir)).toBe(true);
135
+ });
136
+ it('should recursively clear directories', () => {
137
+ jest.restoreAllMocks();
138
+ FileSystem_1.FileSystem.prepare(testBaseDir);
139
+ const sub = node_path_1.default.join(testBaseDir, 'images', 'nested');
140
+ node_fs_1.default.mkdirSync(sub);
141
+ node_fs_1.default.writeFileSync(node_path_1.default.join(sub, 'file.txt'), 'hello');
142
+ node_fs_1.default.writeFileSync(node_path_1.default.join(testBaseDir, 'rootfile.txt'), 'world');
143
+ expect(node_fs_1.default.existsSync(sub)).toBe(true);
144
+ FileSystem_1.FileSystem.removeFolder(testBaseDir, true);
145
+ expect(node_fs_1.default.existsSync(testBaseDir)).toBe(false);
146
+ });
147
+ it('should throw an error on non-recursive deletion containing directories', () => {
148
+ jest.restoreAllMocks();
149
+ node_fs_1.default.mkdirSync(testBaseDir);
150
+ node_fs_1.default.mkdirSync(node_path_1.default.join(testBaseDir, 'nested'));
151
+ expect(() => FileSystem_1.FileSystem.removeFolder(testBaseDir, false)).toThrow('Folder contains folder');
152
+ });
153
+ });
154
+ describe('downloadFile', () => {
155
+ it('should download files to target local paths', async () => {
156
+ const mockStream = {
157
+ pipe: jest.fn((writer) => {
158
+ process.nextTick(() => {
159
+ writer.end();
160
+ });
161
+ })
162
+ };
163
+ axios_1.default.get.mockResolvedValue({
164
+ data: mockStream
165
+ });
166
+ const filepath = node_path_1.default.join(testBaseDir, 'test.txt');
167
+ const result = await FileSystem_1.FileSystem.downloadFile('https://example.com/test.txt', filepath);
168
+ expect(result).toBe(true);
169
+ expect(axios_1.default.get).toHaveBeenCalledWith('https://example.com/test.txt', {
170
+ responseType: 'stream'
171
+ });
172
+ expect(node_fs_1.default.existsSync(filepath)).toBe(true);
173
+ });
174
+ it('should catch and propagate download failures', async () => {
175
+ axios_1.default.get.mockRejectedValue(new Error('Network error'));
176
+ const filepath = node_path_1.default.join(testBaseDir, 'test.txt');
177
+ await expect(FileSystem_1.FileSystem.downloadFile('https://example.com/test.txt', filepath)).rejects.toThrow('Network error');
178
+ });
179
+ });
180
+ describe('safeString', () => {
181
+ it('should convert spaces to underscores', () => {
182
+ expect(FileSystem_1.FileSystem.safeString('hello world test')).toBe('hello_world_test');
183
+ expect(FileSystem_1.FileSystem.safeString('clean-name')).toBe('clean-name');
184
+ });
185
+ });
186
+ describe('getInfo (Media probe)', () => {
187
+ it('should resolve metadata from video extensions', async () => {
188
+ const mockMetadata = {
189
+ streams: [
190
+ {
191
+ width: 1920,
192
+ height: 1080,
193
+ duration: '10.5',
194
+ bit_rate: '5000',
195
+ nb_frames: '300'
196
+ }
197
+ ]
198
+ };
199
+ ffmpeg.ffprobe.mockImplementation((file, callback) => {
200
+ callback(null, mockMetadata);
201
+ });
202
+ const info = await FileSystem_1.FileSystem.getInfo('video.mp4');
203
+ expect(info).toEqual({
204
+ width: 1920,
205
+ height: 1080,
206
+ framerate: 29,
207
+ duration: 10,
208
+ bitrate: '5000'
209
+ });
210
+ });
211
+ it('should bypass non-video extensions silently', async () => {
212
+ const info = await FileSystem_1.FileSystem.getInfo('document.pdf');
213
+ expect(info).toEqual({});
214
+ });
215
+ it('should bubble up probe errors', async () => {
216
+ ffmpeg.ffprobe.mockImplementation((file, callback) => {
217
+ callback(new Error('Probe failed'));
218
+ });
219
+ await expect(FileSystem_1.FileSystem.getInfo('video.mp4')).rejects.toThrow('Probe failed');
220
+ });
221
+ });
222
+ describe('uploadFile', () => {
223
+ it('should upload small files (< 32KB) using full read buffers', async () => {
224
+ const testFile = node_path_1.default.join(testBaseDir, 'small.mp4');
225
+ const mockMetadata = {
226
+ streams: [{ width: 320, height: 240, duration: '1', bit_rate: '100', nb_frames: '10' }]
227
+ };
228
+ ffmpeg.ffprobe.mockImplementation((file, callback) => {
229
+ callback(null, mockMetadata);
230
+ });
231
+ node_fetch_native_1.default.mockResolvedValue({
232
+ ok: true
233
+ });
234
+ // Stub readFileSync for small content read
235
+ jest.spyOn(node_fs_1.default, 'readFileSync').mockReturnValue(Buffer.from('small content'));
236
+ const meta = {
237
+ bucket: 'mock-bucket',
238
+ ref: 'remote/small.mp4',
239
+ uploadUrl: 'https://upload.com/small.mp4'
240
+ };
241
+ const result = await FileSystem_1.FileSystem.uploadFile(testFile, meta);
242
+ expect(result.size).toBe(13);
243
+ expect(result.width).toBe(320);
244
+ expect(result.uploadUrl).toBeUndefined();
245
+ expect(node_fetch_native_1.default).toHaveBeenCalledWith('https://upload.com/small.mp4', expect.any(Object));
246
+ });
247
+ it('should upload large files (>= 32KB) using readable streams', async () => {
248
+ const testFile = node_path_1.default.join(testBaseDir, 'large.mp4');
249
+ const mockMetadata = {
250
+ streams: [{ width: 1280, height: 720, duration: '5', bit_rate: '2000', nb_frames: '150' }]
251
+ };
252
+ ffmpeg.ffprobe.mockImplementation((file, callback) => {
253
+ callback(null, mockMetadata);
254
+ });
255
+ node_fetch_native_1.default.mockResolvedValue({
256
+ ok: true
257
+ });
258
+ const meta = {
259
+ bucket: 'mock-bucket',
260
+ ref: 'remote/large.mp4',
261
+ uploadUrl: 'https://upload.com/large.mp4'
262
+ };
263
+ const result = await FileSystem_1.FileSystem.uploadFile(testFile, meta);
264
+ expect(result.size).toBe(35 * 1024);
265
+ expect(result.uploadUrl).toBeUndefined();
266
+ expect(node_fetch_native_1.default).toHaveBeenCalledWith('https://upload.com/large.mp4', expect.any(Object));
267
+ });
268
+ it('should reject large uploads if HTTP request fails', async () => {
269
+ const testFile = node_path_1.default.join(testBaseDir, 'large-fail.mp4');
270
+ const mockMetadata = {
271
+ streams: [{ width: 1280, height: 720, duration: '5', bit_rate: '2000', nb_frames: '150' }]
272
+ };
273
+ ffmpeg.ffprobe.mockImplementation((file, callback) => {
274
+ callback(null, mockMetadata);
275
+ });
276
+ node_fetch_native_1.default.mockRejectedValue(new Error('Connection abort'));
277
+ const meta = {
278
+ bucket: 'mock-bucket',
279
+ ref: 'remote/large-fail.mp4',
280
+ uploadUrl: 'https://upload.com/large-fail.mp4'
281
+ };
282
+ await expect(FileSystem_1.FileSystem.uploadFile(testFile, meta)).rejects.toThrow('Connection abort');
283
+ });
284
+ });
285
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const Helpers_1 = require("./Helpers");
4
+ const Worker_1 = require("./Worker");
5
+ jest.mock('./Worker', () => {
6
+ const original = jest.requireActual('./Worker');
7
+ return {
8
+ Worker: {
9
+ ...original.Worker,
10
+ getSystemCommandPath: jest.fn().mockResolvedValue('/usr/bin/ffmpeg'),
11
+ execPromise: jest.fn().mockResolvedValue(true),
12
+ info: jest.fn(),
13
+ }
14
+ };
15
+ });
16
+ describe('Helpers', () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ });
20
+ it('should generate video thumbnail parameters correctly and execute ffmpeg', async () => {
21
+ const videoPath = 'test.mp4';
22
+ const outputPath = 'output.jpg';
23
+ const result = await Helpers_1.Helpers.generateVideoThumbnail(videoPath, outputPath, 5, 640);
24
+ expect(result).toBe(true);
25
+ expect(Worker_1.Worker.execPromise).toHaveBeenCalledWith('/usr/bin/ffmpeg', [
26
+ '-i',
27
+ 'test.mp4',
28
+ '-vframes',
29
+ '1',
30
+ '-vf',
31
+ 'select=gte(n\\,5)',
32
+ '-s',
33
+ '640x480',
34
+ '-ss',
35
+ '1',
36
+ 'output.jpg',
37
+ '-y'
38
+ ]);
39
+ });
40
+ it('should use default values for frame and width', async () => {
41
+ const videoPath = 'test.mp4';
42
+ const outputPath = 'output.jpg';
43
+ const result = await Helpers_1.Helpers.generateVideoThumbnail(videoPath, outputPath);
44
+ expect(result).toBe(true);
45
+ expect(Worker_1.Worker.execPromise).toHaveBeenCalledWith('/usr/bin/ffmpeg', [
46
+ '-i',
47
+ 'test.mp4',
48
+ '-vframes',
49
+ '1',
50
+ '-vf',
51
+ 'select=gte(n\\,0)',
52
+ '-s',
53
+ '320x240',
54
+ '-ss',
55
+ '1',
56
+ 'output.jpg',
57
+ '-y'
58
+ ]);
59
+ });
60
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quatrain/worker",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Container Worker helpers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@quatrain/core": "^1.2.14",
38
38
  "@quatrain/queue": "^1.2.3",
39
- "@quatrain/storage": "^1.2.8",
39
+ "@quatrain/storage": "^1.2.9",
40
40
  "axios": "^1.7.7",
41
41
  "fluent-ffmpeg": "^2.1.2",
42
42
  "fs-extra": "^11.2.0",
@@ -0,0 +1,295 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { FileSystem } from './FileSystem'
4
+ import axios from 'axios'
5
+ import fetch from 'node-fetch-native'
6
+ import * as ffmpeg from 'fluent-ffmpeg'
7
+ import { PassThrough, Readable } from 'node:stream'
8
+
9
+ jest.mock('axios')
10
+ jest.mock('node-fetch-native')
11
+ jest.mock('fluent-ffmpeg', () => ({
12
+ ffprobe: jest.fn()
13
+ }))
14
+
15
+ // Mock @quatrain/log globally to completely silence all logging calls across the monorepo graph during worker tests.
16
+ // Returns a valid dummy logger object to satisfy Core.addLogger and Worker.addLogger definitions.
17
+ jest.mock('@quatrain/log', () => {
18
+ const dummyLoggerInstance = {
19
+ log: jest.fn(),
20
+ info: jest.fn(),
21
+ debug: jest.fn(),
22
+ error: jest.fn(),
23
+ warn: jest.fn()
24
+ }
25
+ return {
26
+ Log: {
27
+ info: jest.fn(),
28
+ debug: jest.fn(),
29
+ error: jest.fn(),
30
+ warn: jest.fn(),
31
+ addLogger: jest.fn().mockReturnValue(dummyLoggerInstance)
32
+ },
33
+ LogLevel: {
34
+ INFO: 1,
35
+ DEBUG: 0,
36
+ ERROR: 3,
37
+ WARN: 2
38
+ },
39
+ DefaultLoggerAdapter: jest.fn().mockImplementation(() => dummyLoggerInstance)
40
+ }
41
+ })
42
+
43
+ const originalExistsSync = fs.existsSync
44
+
45
+ describe('FileSystem Utilities (Worker)', () => {
46
+ const testBaseDir = path.resolve(__dirname, 'temp_test_dir')
47
+
48
+ beforeEach(() => {
49
+ // Mock createWriteStream and createReadStream to return in-memory streams.
50
+ // This completely prevents physical file descriptor locks and background ENOENT races.
51
+ jest.spyOn(fs, 'createWriteStream').mockImplementation(() => {
52
+ return new PassThrough() as any
53
+ })
54
+
55
+ jest.spyOn(fs, 'createReadStream').mockImplementation(() => {
56
+ return Readable.from(Buffer.alloc(35 * 1024)) as any
57
+ })
58
+
59
+ jest.spyOn(fs, 'existsSync').mockImplementation((p) => {
60
+ if (typeof p === 'string' && (p.endsWith('test.txt') || p.endsWith('small.mp4') || p.endsWith('large.mp4') || p.endsWith('large-fail.mp4'))) {
61
+ return true
62
+ }
63
+ return originalExistsSync(p)
64
+ })
65
+
66
+ jest.spyOn(fs, 'statSync').mockImplementation((p) => {
67
+ if (typeof p === 'string') {
68
+ if (p.endsWith('small.mp4')) {
69
+ return { size: 13 } as any
70
+ }
71
+ if (p.endsWith('large.mp4') || p.endsWith('large-fail.mp4')) {
72
+ return { size: 35 * 1024 } as any
73
+ }
74
+ }
75
+ return (jest.requireActual('node:fs').statSync)(p)
76
+ })
77
+
78
+ // Setup base temp folder if needed
79
+ if (originalExistsSync(testBaseDir)) {
80
+ fs.rmSync(testBaseDir, { recursive: true, force: true })
81
+ }
82
+ })
83
+
84
+ afterEach(() => {
85
+ // Clean up base temp folder
86
+ if (originalExistsSync(testBaseDir)) {
87
+ fs.rmSync(testBaseDir, { recursive: true, force: true })
88
+ }
89
+ jest.clearAllMocks()
90
+ jest.restoreAllMocks()
91
+ })
92
+
93
+ describe('prepare and Directory Actions', () => {
94
+ it('should prepare standard workspace layouts', () => {
95
+ // Restore real fs methods for actual directory testing
96
+ jest.restoreAllMocks()
97
+ FileSystem.prepare(testBaseDir)
98
+
99
+ expect(fs.existsSync(testBaseDir)).toBe(true)
100
+ expect(fs.existsSync(path.join(testBaseDir, 'images'))).toBe(true)
101
+ expect(fs.existsSync(path.join(testBaseDir, 'vecto'))).toBe(true)
102
+ })
103
+
104
+ it('should create directories using makeFolder', () => {
105
+ jest.restoreAllMocks()
106
+ fs.mkdirSync(testBaseDir)
107
+ const subDir = path.join(testBaseDir, 'custom')
108
+ FileSystem.makeFolder(subDir)
109
+ expect(fs.existsSync(subDir)).toBe(true)
110
+ })
111
+
112
+ it('should recursively clear directories', () => {
113
+ jest.restoreAllMocks()
114
+ FileSystem.prepare(testBaseDir)
115
+ const sub = path.join(testBaseDir, 'images', 'nested')
116
+ fs.mkdirSync(sub)
117
+ fs.writeFileSync(path.join(sub, 'file.txt'), 'hello')
118
+ fs.writeFileSync(path.join(testBaseDir, 'rootfile.txt'), 'world')
119
+
120
+ expect(fs.existsSync(sub)).toBe(true)
121
+
122
+ FileSystem.removeFolder(testBaseDir, true)
123
+ expect(fs.existsSync(testBaseDir)).toBe(false)
124
+ })
125
+
126
+ it('should throw an error on non-recursive deletion containing directories', () => {
127
+ jest.restoreAllMocks()
128
+ fs.mkdirSync(testBaseDir)
129
+ fs.mkdirSync(path.join(testBaseDir, 'nested'))
130
+
131
+ expect(() => FileSystem.removeFolder(testBaseDir, false)).toThrow('Folder contains folder')
132
+ })
133
+ })
134
+
135
+ describe('downloadFile', () => {
136
+ it('should download files to target local paths', async () => {
137
+ const mockStream = {
138
+ pipe: jest.fn((writer) => {
139
+ process.nextTick(() => {
140
+ writer.end()
141
+ })
142
+ })
143
+ };
144
+
145
+ (axios.get as jest.Mock).mockResolvedValue({
146
+ data: mockStream
147
+ })
148
+
149
+ const filepath = path.join(testBaseDir, 'test.txt')
150
+
151
+ const result = await FileSystem.downloadFile('https://example.com/test.txt', filepath)
152
+ expect(result).toBe(true)
153
+ expect(axios.get).toHaveBeenCalledWith('https://example.com/test.txt', {
154
+ responseType: 'stream'
155
+ })
156
+ expect(fs.existsSync(filepath)).toBe(true)
157
+ })
158
+
159
+ it('should catch and propagate download failures', async () => {
160
+ (axios.get as jest.Mock).mockRejectedValue(new Error('Network error'))
161
+
162
+ const filepath = path.join(testBaseDir, 'test.txt')
163
+
164
+ await expect(FileSystem.downloadFile('https://example.com/test.txt', filepath)).rejects.toThrow('Network error')
165
+ })
166
+ })
167
+
168
+ describe('safeString', () => {
169
+ it('should convert spaces to underscores', () => {
170
+ expect(FileSystem.safeString('hello world test')).toBe('hello_world_test')
171
+ expect(FileSystem.safeString('clean-name')).toBe('clean-name')
172
+ })
173
+ })
174
+
175
+ describe('getInfo (Media probe)', () => {
176
+ it('should resolve metadata from video extensions', async () => {
177
+ const mockMetadata = {
178
+ streams: [
179
+ {
180
+ width: 1920,
181
+ height: 1080,
182
+ duration: '10.5',
183
+ bit_rate: '5000',
184
+ nb_frames: '300'
185
+ }
186
+ ]
187
+ };
188
+
189
+ (ffmpeg.ffprobe as any).mockImplementation((file: string, callback: any) => {
190
+ callback(null, mockMetadata)
191
+ })
192
+
193
+ const info = await FileSystem.getInfo('video.mp4')
194
+ expect(info).toEqual({
195
+ width: 1920,
196
+ height: 1080,
197
+ framerate: 29,
198
+ duration: 10,
199
+ bitrate: '5000'
200
+ })
201
+ })
202
+
203
+ it('should bypass non-video extensions silently', async () => {
204
+ const info = await FileSystem.getInfo('document.pdf')
205
+ expect(info).toEqual({})
206
+ })
207
+
208
+ it('should bubble up probe errors', async () => {
209
+ (ffmpeg.ffprobe as any).mockImplementation((file: string, callback: any) => {
210
+ callback(new Error('Probe failed'))
211
+ })
212
+
213
+ await expect(FileSystem.getInfo('video.mp4')).rejects.toThrow('Probe failed')
214
+ })
215
+ })
216
+
217
+ describe('uploadFile', () => {
218
+ it('should upload small files (< 32KB) using full read buffers', async () => {
219
+ const testFile = path.join(testBaseDir, 'small.mp4')
220
+
221
+ const mockMetadata = {
222
+ streams: [{ width: 320, height: 240, duration: '1', bit_rate: '100', nb_frames: '10' }]
223
+ };
224
+ (ffmpeg.ffprobe as any).mockImplementation((file: string, callback: any) => {
225
+ callback(null, mockMetadata)
226
+ });
227
+
228
+ (fetch as any).mockResolvedValue({
229
+ ok: true
230
+ })
231
+
232
+ // Stub readFileSync for small content read
233
+ jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from('small content'))
234
+
235
+ const meta = {
236
+ bucket: 'mock-bucket',
237
+ ref: 'remote/small.mp4',
238
+ uploadUrl: 'https://upload.com/small.mp4'
239
+ }
240
+
241
+ const result = await FileSystem.uploadFile(testFile, meta)
242
+ expect(result.size).toBe(13)
243
+ expect(result.width).toBe(320)
244
+ expect(result.uploadUrl).toBeUndefined()
245
+ expect(fetch).toHaveBeenCalledWith('https://upload.com/small.mp4', expect.any(Object))
246
+ })
247
+
248
+ it('should upload large files (>= 32KB) using readable streams', async () => {
249
+ const testFile = path.join(testBaseDir, 'large.mp4')
250
+
251
+ const mockMetadata = {
252
+ streams: [{ width: 1280, height: 720, duration: '5', bit_rate: '2000', nb_frames: '150' }]
253
+ };
254
+ (ffmpeg.ffprobe as any).mockImplementation((file: string, callback: any) => {
255
+ callback(null, mockMetadata)
256
+ });
257
+
258
+ (fetch as any).mockResolvedValue({
259
+ ok: true
260
+ })
261
+
262
+ const meta = {
263
+ bucket: 'mock-bucket',
264
+ ref: 'remote/large.mp4',
265
+ uploadUrl: 'https://upload.com/large.mp4'
266
+ }
267
+
268
+ const result = await FileSystem.uploadFile(testFile, meta)
269
+ expect(result.size).toBe(35 * 1024)
270
+ expect(result.uploadUrl).toBeUndefined()
271
+ expect(fetch).toHaveBeenCalledWith('https://upload.com/large.mp4', expect.any(Object))
272
+ })
273
+
274
+ it('should reject large uploads if HTTP request fails', async () => {
275
+ const testFile = path.join(testBaseDir, 'large-fail.mp4')
276
+
277
+ const mockMetadata = {
278
+ streams: [{ width: 1280, height: 720, duration: '5', bit_rate: '2000', nb_frames: '150' }]
279
+ };
280
+ (ffmpeg.ffprobe as any).mockImplementation((file: string, callback: any) => {
281
+ callback(null, mockMetadata)
282
+ });
283
+
284
+ (fetch as any).mockRejectedValue(new Error('Connection abort'))
285
+
286
+ const meta = {
287
+ bucket: 'mock-bucket',
288
+ ref: 'remote/large-fail.mp4',
289
+ uploadUrl: 'https://upload.com/large-fail.mp4'
290
+ }
291
+
292
+ await expect(FileSystem.uploadFile(testFile, meta)).rejects.toThrow('Connection abort')
293
+ })
294
+ })
295
+ })
@@ -0,0 +1,66 @@
1
+ import { Helpers } from './Helpers'
2
+ import { Worker } from './Worker'
3
+
4
+ jest.mock('./Worker', () => {
5
+ const original = jest.requireActual('./Worker')
6
+ return {
7
+ Worker: {
8
+ ...original.Worker,
9
+ getSystemCommandPath: jest.fn().mockResolvedValue('/usr/bin/ffmpeg'),
10
+ execPromise: jest.fn().mockResolvedValue(true),
11
+ info: jest.fn(),
12
+ }
13
+ }
14
+ })
15
+
16
+ describe('Helpers', () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks()
19
+ })
20
+
21
+ it('should generate video thumbnail parameters correctly and execute ffmpeg', async () => {
22
+ const videoPath = 'test.mp4'
23
+ const outputPath = 'output.jpg'
24
+
25
+ const result = await Helpers.generateVideoThumbnail(videoPath, outputPath, 5, 640)
26
+
27
+ expect(result).toBe(true)
28
+ expect(Worker.execPromise).toHaveBeenCalledWith('/usr/bin/ffmpeg', [
29
+ '-i',
30
+ 'test.mp4',
31
+ '-vframes',
32
+ '1',
33
+ '-vf',
34
+ 'select=gte(n\\,5)',
35
+ '-s',
36
+ '640x480',
37
+ '-ss',
38
+ '1',
39
+ 'output.jpg',
40
+ '-y'
41
+ ])
42
+ })
43
+
44
+ it('should use default values for frame and width', async () => {
45
+ const videoPath = 'test.mp4'
46
+ const outputPath = 'output.jpg'
47
+
48
+ const result = await Helpers.generateVideoThumbnail(videoPath, outputPath)
49
+
50
+ expect(result).toBe(true)
51
+ expect(Worker.execPromise).toHaveBeenCalledWith('/usr/bin/ffmpeg', [
52
+ '-i',
53
+ 'test.mp4',
54
+ '-vframes',
55
+ '1',
56
+ '-vf',
57
+ 'select=gte(n\\,0)',
58
+ '-s',
59
+ '320x240',
60
+ '-ss',
61
+ '1',
62
+ 'output.jpg',
63
+ '-y'
64
+ ])
65
+ })
66
+ })