From b30010eb6fae0e3f577bf1786e5274af0b12f14e Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 2 May 2026 00:47:46 +0200 Subject: [PATCH] test(upload): unit tests for backgroundProcessor + processPhoto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the two new pieces of the async pipeline: backgroundProcessor.claimNextPhoto - returns null when no pending rows - returns the row + flips status under postgres FOR UPDATE SKIP LOCKED - returns null when SQLite UPDATE-with-guard loses the race - returns the row when the SQLite guard wins photoProcessor.processPhoto - happy path: writes thumbnail / dimensions / EXIF capture date and marks 'complete'; fires watermark queue + photo.uploaded webhook with the right payload - video path: writes ffmpeg duration / codec / dimensions; does NOT queue watermark (image-only) - throws cleanly when the photo row no longer exists Mocks db / imageProcessor / videoProcessor / storage / sharp / watermarkGeneratorService / webhookService / logger so the tests run without a real DB or any image library calls — fast and deterministic. --- .../services/backgroundProcessor.test.js | 109 +++++++++ .../photoProcessor.processPhoto.test.js | 216 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 backend/__tests__/services/backgroundProcessor.test.js create mode 100644 backend/__tests__/services/photoProcessor.processPhoto.test.js diff --git a/backend/__tests__/services/backgroundProcessor.test.js b/backend/__tests__/services/backgroundProcessor.test.js new file mode 100644 index 00000000..2819dfd7 --- /dev/null +++ b/backend/__tests__/services/backgroundProcessor.test.js @@ -0,0 +1,109 @@ +/** + * Unit tests for backgroundProcessor.claimNextPhoto. + * + * Mocks the db so we don't need a live postgres/sqlite — focuses on + * the claim contract: returns null when no rows, returns row + flips + * status to 'processing' when one is available, returns null when a + * race loses the UPDATE-with-guard. + */ + +jest.mock('../../src/services/photoProcessor', () => ({ + processPhoto: jest.fn(), + processUploadedPhotos: jest.fn(), + queueFilesForProcessing: jest.fn(), +})); + +// Build a fake knex instance whose .transaction() takes a callback we can +// drive from the test, and whose query-builder records calls. +function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) { + const queries = []; + + const builder = () => { + const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false }; + queries.push(recorded); + const chain = { + where: jest.fn(function (...args) { + recorded.wheres.push(args); + return chain; + }), + orderBy: jest.fn(function () { + recorded.ordered = true; + return chain; + }), + forUpdate: jest.fn(function () { + recorded.locked = true; + return chain; + }), + skipLocked: jest.fn(function () { + recorded.skipped = true; + return chain; + }), + first: jest.fn(async function () { + // Only the SELECT chain returns the pending row; the UPDATE chain + // never calls .first(). + return pendingRow ? { ...pendingRow } : null; + }), + update: jest.fn(async function (data) { + recorded.updates = data; + return updateResult; + }), + }; + return chain; + }; + + const trxFn = (table) => builder(table); + trxFn.client = { config: { client: clientName } }; + trxFn.transaction = async (cb) => cb(trxFn); + + // Top-level db('photos') returns same builder for the janitor test path. + const db = trxFn; + return { db, queries }; +} + +describe('backgroundProcessor.claimNextPhoto', () => { + function loadProcessor(db) { + jest.resetModules(); + jest.doMock('../../src/database/db', () => ({ db })); + return require('../../src/services/backgroundProcessor'); + } + + it('returns null when there are no pending photos (postgres path)', async () => { + const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toBeNull(); + }); + + it('returns the claimed row and flips status (postgres path)', async () => { + const pendingRow = { id: 42, processing_status: 'pending' }; + const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toEqual(pendingRow); + // The first query is the SELECT FOR UPDATE SKIP LOCKED. + expect(queries[0].locked).toBe(true); + expect(queries[0].skipped).toBe(true); + // The second query is the status update. + expect(queries[1].updates.processing_status).toBe('processing'); + expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date); + }); + + it('returns null when the SQLite UPDATE-with-guard loses the race', async () => { + const pendingRow = { id: 7 }; + const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toBeNull(); + }); + + it('returns the row when SQLite UPDATE-with-guard wins', async () => { + const pendingRow = { id: 7 }; + const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toEqual(pendingRow); + // SQLite path: no FOR UPDATE / SKIP LOCKED. + expect(queries[0].locked).toBe(false); + expect(queries[0].skipped).toBe(false); + }); +}); diff --git a/backend/__tests__/services/photoProcessor.processPhoto.test.js b/backend/__tests__/services/photoProcessor.processPhoto.test.js new file mode 100644 index 00000000..763b213e --- /dev/null +++ b/backend/__tests__/services/photoProcessor.processPhoto.test.js @@ -0,0 +1,216 @@ +/** + * 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