test(upload): unit tests for backgroundProcessor + processPhoto
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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user