b30010eb6f
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.
110 lines
3.9 KiB
JavaScript
110 lines
3.9 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|