217 lines
6.7 KiB
JavaScript
217 lines
6.7 KiB
JavaScript
/**
|
|
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
|
|
* point that runs after a row has been claimed by the background
|
|
* processor. Mocks every external dependency and validates the
|
|
* happy-path DB updates and side-effect ordering.
|
|
*
|
|
* jest.mock factories are evaluated before any local variables exist,
|
|
* so collaborators are kept inside the mock factories themselves and
|
|
* the test reaches into them via require() once they're set up.
|
|
*/
|
|
|
|
const path = require('path');
|
|
|
|
jest.mock('../../src/database/db', () => {
|
|
const recorded = { whereCalls: [], updateCalls: [] };
|
|
let pendingWhere = null;
|
|
const photosState = { row: null };
|
|
const eventsState = { row: null };
|
|
|
|
function makePhotoQuery() {
|
|
return {
|
|
where(args) {
|
|
pendingWhere = args;
|
|
recorded.whereCalls.push(args);
|
|
return this;
|
|
},
|
|
async first() {
|
|
return photosState.row;
|
|
},
|
|
async update(data) {
|
|
recorded.updateCalls.push({ where: pendingWhere, data });
|
|
return 1;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeEventsQuery() {
|
|
return {
|
|
where() {
|
|
return this;
|
|
},
|
|
async first() {
|
|
return eventsState.row;
|
|
},
|
|
};
|
|
}
|
|
|
|
function dbFn(table) {
|
|
if (table === 'photos') return makePhotoQuery();
|
|
if (table === 'events') return makeEventsQuery();
|
|
throw new Error(`Unexpected table: ${table}`);
|
|
}
|
|
dbFn.client = { config: { client: 'pg' } };
|
|
|
|
return {
|
|
db: dbFn,
|
|
__setPhoto: (row) => { photosState.row = row; },
|
|
__setEvent: (row) => { eventsState.row = row; },
|
|
__reset: () => {
|
|
recorded.whereCalls = [];
|
|
recorded.updateCalls = [];
|
|
photosState.row = null;
|
|
eventsState.row = null;
|
|
},
|
|
__recorded: () => recorded,
|
|
};
|
|
});
|
|
|
|
jest.mock('../../src/services/imageProcessor', () => {
|
|
const mockGenerateThumbnail = jest.fn();
|
|
const mockExtractCaptureDate = jest.fn();
|
|
return {
|
|
generateThumbnail: mockGenerateThumbnail,
|
|
extractCaptureDate: mockExtractCaptureDate,
|
|
withLocalCopy: jest.fn(async (key, fn) =>
|
|
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
|
),
|
|
};
|
|
});
|
|
|
|
jest.mock('../../src/services/videoProcessor', () => ({
|
|
processUploadedVideo: jest.fn(),
|
|
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
|
|
}));
|
|
|
|
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
|
|
|
|
jest.mock('../../src/services/photoResolver', () => ({
|
|
resolvePhotoStorageKey: jest.fn(
|
|
(event, photo) => `events/active/${event.slug}/${photo.filename}`
|
|
),
|
|
}));
|
|
|
|
jest.mock('../../src/utils/filenameSanitizer', () => ({
|
|
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
|
|
}));
|
|
|
|
jest.mock('../../src/services/watermarkGeneratorService', () => ({
|
|
generateForPhoto: jest.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
jest.mock('../../src/services/webhookService', () => ({
|
|
fire: jest.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
jest.mock('../../src/utils/logger', () => ({
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
debug: jest.fn(),
|
|
}));
|
|
|
|
// Stub sharp so we don't actually read any image off disk.
|
|
jest.mock('sharp', () => {
|
|
const mock = jest.fn(() => ({
|
|
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
|
|
}));
|
|
return mock;
|
|
});
|
|
|
|
const dbModule = require('../../src/database/db');
|
|
const imageProcessor = require('../../src/services/imageProcessor');
|
|
const videoProcessor = require('../../src/services/videoProcessor');
|
|
const watermarkService = require('../../src/services/watermarkGeneratorService');
|
|
const webhookService = require('../../src/services/webhookService');
|
|
|
|
beforeEach(() => {
|
|
dbModule.__reset();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('photoProcessor.processPhoto', () => {
|
|
it('marks an image complete with thumbnail and dimensions', async () => {
|
|
dbModule.__setPhoto({
|
|
id: 101,
|
|
event_id: 5,
|
|
filename: 'wedding-001.jpg',
|
|
original_filename: 'IMG_0001.jpg',
|
|
mime_type: 'image/jpeg',
|
|
media_type: 'image',
|
|
size_bytes: 12345,
|
|
captured_at: null,
|
|
processing_status: 'processing',
|
|
});
|
|
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
|
|
|
|
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
|
|
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
|
|
|
|
const { processPhoto } = require('../../src/services/photoProcessor');
|
|
await processPhoto(101);
|
|
|
|
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
|
expect(finalUpdate.data.processing_status).toBe('complete');
|
|
expect(finalUpdate.data.processing_error).toBeNull();
|
|
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
|
|
expect(finalUpdate.data.width).toBe(1920);
|
|
expect(finalUpdate.data.height).toBe(1080);
|
|
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
|
|
|
|
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
|
|
expect(webhookService.fire).toHaveBeenCalledWith(
|
|
'photo.uploaded',
|
|
expect.objectContaining({
|
|
event: expect.objectContaining({ slug: 'wedding' }),
|
|
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('handles videos with ffmpeg metadata path', async () => {
|
|
dbModule.__setPhoto({
|
|
id: 202,
|
|
event_id: 9,
|
|
filename: 'wedding-video-001.mp4',
|
|
original_filename: 'movie.mp4',
|
|
mime_type: 'video/mp4',
|
|
media_type: 'video',
|
|
size_bytes: 99999,
|
|
captured_at: null,
|
|
});
|
|
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
|
|
|
|
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
|
|
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
|
|
metadata: {
|
|
duration: 12.5,
|
|
videoCodec: 'h264',
|
|
audioCodec: 'aac',
|
|
width: 1280,
|
|
height: 720,
|
|
},
|
|
});
|
|
|
|
const { processPhoto } = require('../../src/services/photoProcessor');
|
|
await processPhoto(202);
|
|
|
|
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
|
expect(finalUpdate.data.processing_status).toBe('complete');
|
|
expect(finalUpdate.data.duration).toBe(12.5);
|
|
expect(finalUpdate.data.video_codec).toBe('h264');
|
|
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
|
|
|
|
// Watermark queue is image-only.
|
|
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws when the photo row no longer exists', async () => {
|
|
dbModule.__setPhoto(null);
|
|
dbModule.__setEvent({ id: 1 });
|
|
const { processPhoto } = require('../../src/services/photoProcessor');
|
|
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
|
|
});
|
|
});
|
|
|
|
void path; // referenced indirectly via mocks
|