5559cd333d
Stable twin of #1194. generateThumbnail, generateHeroImage and generatePreviewImage went straight from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 — routine for portrait shots on bodies that tag rather than rotate the sensor data — was resized from the raw frame and came out sideways. The same pipelines then call .withMetadata(false), stripping the tag from the output, so nothing downstream could correct it either. The download path already had this right, which is why the same photo looked correct on download and rotated in the gallery. watermarkService had it too, and it is the one a guest actually sees: gallery.js serves photos.watermark_path ahead of the original when branding watermarking is on. Two details there — metadata() is read from a separate unrotated handle, because .rotate() does not change what it reports and every use of those numbers is positioning; and the composite offsets are floored, because getPositionCoordinates returns fractional pixels, sharp rejects them, and applyWatermark catches its own error and silently returns the image unwatermarked. The rotate is unconditional in the thumbnail and hero generators — neither passes `animated: true`, so both already flatten a multi-frame source and guarding there would protect an animation that was being discarded anyway. generatePreviewImage keeps the guard, since it genuinely does preserve animation. photos.width/height were stored from sharp's metadata, which reports pixels as STORED, not displayed. For orientation 5-8 those are swapped, so a portrait photo landed in the database as landscape and masonry sized its tile with the wrong aspect ratio on top of the image being unrotated. A shared orientedDimensions() helper now does the conversion at all eight image ingest sites. The video path is deliberately untouched: its dimensions come from ffprobe, where EXIF orientation does not apply. Existing rows keep their pre-rotation dimensions until reprocessed; the backfill for those is #1199 on main and is not ported here yet. Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
226 lines
7.2 KiB
JavaScript
226 lines
7.2 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,
|
|
// processPhoto routes stored dimensions through this to get the displayed
|
|
// ones (#1185). Mirrored rather than requireActual'd, because pulling the
|
|
// real module in here would drag its database dependency into the mock
|
|
// factory. Kept faithful to imageProcessor.orientedDimensions.
|
|
orientedDimensions: jest.fn((m) => {
|
|
if (!m || !m.width || !m.height) return { width: null, height: null };
|
|
const swap = m.orientation >= 5 && m.orientation <= 8;
|
|
return { width: swap ? m.height : m.width, height: swap ? m.width : m.height };
|
|
}),
|
|
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
|