diff --git a/apps/backend/controllers/scanner.controller.ts b/apps/backend/controllers/scanner.controller.ts index 953883c..0124016 100644 --- a/apps/backend/controllers/scanner.controller.ts +++ b/apps/backend/controllers/scanner.controller.ts @@ -99,6 +99,30 @@ export const upcLookup: RequestHandler = async (req, res, next) => { } }; +/** + * GET /library/scan/batch + * + * Lists batch scan jobs for the authenticated user with pagination. + * + * Query params: + * - limit: max jobs to return (default 20, max 100) + * - offset: number of jobs to skip (default 0) + */ +export const listBatchJobs: RequestHandler = async (req, res, next) => { + try { + const userId = req.auth!.id!; + const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 20, 1), 100); + const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); + + const result = await batchService.listJobs(userId, limit, offset); + + res.status(200).json(result); + } catch (error) { + console.error('Error listing batch jobs:', error); + next(error); + } +}; + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const MAX_BATCH_ITEMS = 10; diff --git a/apps/backend/routes/scanner.route.ts b/apps/backend/routes/scanner.route.ts index 89229af..8071a99 100644 --- a/apps/backend/routes/scanner.route.ts +++ b/apps/backend/routes/scanner.route.ts @@ -29,6 +29,8 @@ scanner_route.post( scannerController.createBatchScan ); +scanner_route.get('/batch', requirePermissions({ catalog: ['read'] }), scannerController.listBatchJobs); + scanner_route.get('/batch/:jobId', requirePermissions({ catalog: ['read'] }), scannerController.getBatchStatus); scanner_route.post('/upc-lookup', requirePermissions({ catalog: ['read'] }), scannerController.upcLookup); diff --git a/apps/backend/services/scanner/batch.ts b/apps/backend/services/scanner/batch.ts index 5cf1b33..8dbd1e3 100644 --- a/apps/backend/services/scanner/batch.ts +++ b/apps/backend/services/scanner/batch.ts @@ -6,7 +6,7 @@ * Gemini extraction pipeline. */ -import { eq, asc, sql, inArray } from 'drizzle-orm'; +import { eq, asc, desc, sql, count, inArray } from 'drizzle-orm'; import { db, scan_jobs, scan_results, library_artist_view } from '@wxyc/database'; import { processImages } from './processor.js'; import { ScanContext } from './types.js'; @@ -70,6 +70,72 @@ export interface BatchJobStatus { updatedAt: Date; } +/** + * Summary of a batch job (without individual results). + */ +export interface BatchJobSummary { + jobId: string; + status: string; + totalItems: number; + completedItems: number; + failedItems: number; + createdAt: Date; + updatedAt: Date; +} + +/** + * Paginated list of batch job summaries. + */ +export interface PaginatedJobList { + jobs: BatchJobSummary[]; + total: number; + limit: number; + offset: number; +} + +/** + * List batch jobs for a user with pagination. + * + * Returns job summaries (without individual results) ordered by most recent first. + * + * @param userId - Authenticated user ID + * @param limit - Maximum number of jobs to return + * @param offset - Number of jobs to skip + * @returns Paginated list of job summaries + */ +export async function listJobs(userId: string, limit: number, offset: number): Promise { + const [jobs, totalResult] = await Promise.all([ + db + .select() + .from(scan_jobs) + .where(eq(scan_jobs.user_id, userId)) + .orderBy(desc(scan_jobs.created_at)) + .limit(limit) + .offset(offset) + .execute(), + db + .select({ count: count() }) + .from(scan_jobs) + .where(eq(scan_jobs.user_id, userId)) + .execute(), + ]); + + return { + jobs: jobs.map((job) => ({ + jobId: job.id, + status: job.status, + totalItems: job.total_items, + completedItems: job.completed_items, + failedItems: job.failed_items, + createdAt: job.created_at, + updatedAt: job.updated_at, + })), + total: totalResult[0]?.count ?? 0, + limit, + offset, + }; +} + /** * Create a new batch scan job. * diff --git a/tests/unit/controllers/scanner.controller.test.ts b/tests/unit/controllers/scanner.controller.test.ts index 6f054f2..f0a90c7 100644 --- a/tests/unit/controllers/scanner.controller.test.ts +++ b/tests/unit/controllers/scanner.controller.test.ts @@ -18,10 +18,12 @@ const mockCreateBatchJob = jest.fn< }> >(); const mockGetJobStatus = jest.fn<(jobId: string, userId: string) => Promise>(); +const mockListJobs = jest.fn<(userId: string, limit: number, offset: number) => Promise>(); jest.mock('../../../apps/backend/services/scanner/batch', () => ({ createBatchJob: mockCreateBatchJob, getJobStatus: mockGetJobStatus, + listJobs: mockListJobs, })); // Mock the processor (for scanImages handler which we're not testing here but need for the import) @@ -36,7 +38,7 @@ jest.mock('../../../apps/backend/services/discogs/discogs.service', () => ({ }, })); -import { createBatchScan, getBatchStatus } from '../../../apps/backend/controllers/scanner.controller'; +import { createBatchScan, getBatchStatus, listBatchJobs } from '../../../apps/backend/controllers/scanner.controller'; // Helper to create mock Express req/res/next const createMockRes = () => { @@ -163,6 +165,58 @@ describe('scanner.controller', () => { }); }); + describe('listBatchJobs', () => { + it('returns 200 with paginated job list', async () => { + const jobList = { + jobs: [{ jobId: 'job-1', status: 'completed', totalItems: 2, completedItems: 2, failedItems: 0 }], + total: 1, + limit: 20, + offset: 0, + }; + const req = { + query: {}, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + mockListJobs.mockResolvedValue(jobList); + + await listBatchJobs(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(jobList); + expect(mockListJobs).toHaveBeenCalledWith('user-123', 20, 0); + }); + + it('uses default limit and offset when not provided', async () => { + const req = { + query: {}, + auth: { id: 'user-456' }, + } as unknown as Request; + const res = createMockRes(); + + mockListJobs.mockResolvedValue({ jobs: [], total: 0, limit: 20, offset: 0 }); + + await listBatchJobs(req, res as Response, mockNext); + + expect(mockListJobs).toHaveBeenCalledWith('user-456', 20, 0); + }); + + it('clamps limit to max 100', async () => { + const req = { + query: { limit: '500' }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + mockListJobs.mockResolvedValue({ jobs: [], total: 0, limit: 100, offset: 0 }); + + await listBatchJobs(req, res as Response, mockNext); + + expect(mockListJobs).toHaveBeenCalledWith('user-123', 100, 0); + }); + }); + describe('getBatchStatus', () => { it('returns 400 for invalid UUID', async () => { const req = { diff --git a/tests/unit/services/scanner/batch.test.ts b/tests/unit/services/scanner/batch.test.ts index f52cf60..1202f41 100644 --- a/tests/unit/services/scanner/batch.test.ts +++ b/tests/unit/services/scanner/batch.test.ts @@ -12,6 +12,8 @@ const mockSelect = jest.fn().mockReturnThis(); const mockFrom = jest.fn().mockReturnThis(); const mockWhere = jest.fn().mockReturnThis(); const mockOrderBy = jest.fn().mockReturnThis(); +const mockLimit = jest.fn().mockReturnThis(); +const mockOffset = jest.fn().mockReturnThis(); const mockUpdate = jest.fn().mockReturnThis(); const mockSet = jest.fn().mockReturnThis(); const mockExecute = jest.fn<() => Promise>().mockResolvedValue([]); @@ -28,6 +30,7 @@ jest.mock('@wxyc/database', () => ({ status: 'status', completed_items: 'completed_items', failed_items: 'failed_items', + created_at: 'created_at', updated_at: 'updated_at', }, scan_results: { @@ -53,16 +56,17 @@ jest.mock('@wxyc/database', () => ({ }, })); -// Wire up the chain: insert().values().returning() and select().from().where().orderBy().execute() +// Wire up the chain: insert().values().returning() and select().from().where().orderBy().limit().offset().execute() mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning }); mockSelect.mockReturnValue({ from: mockFrom }); mockFrom.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); -mockOrderBy.mockReturnValue({ execute: mockExecute }); +mockOrderBy.mockReturnValue({ limit: mockLimit, execute: mockExecute }); +mockLimit.mockReturnValue({ offset: mockOffset }); +mockOffset.mockReturnValue({ execute: mockExecute }); mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); -mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); // Mock the processor module const mockProcessImages = jest.fn< @@ -84,6 +88,8 @@ jest.mock('drizzle-orm', () => ({ eq: jest.fn((a, b) => ({ eq: [a, b] })), asc: jest.fn((col) => ({ asc: col })), inArray: jest.fn((col, values) => ({ inArray: [col, values] })), + desc: jest.fn((col) => ({ desc: col })), + count: jest.fn(() => ({ count: 'count(*)' })), sql: Object.assign( jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: strings, values })), { @@ -96,7 +102,7 @@ jest.mock('drizzle-orm', () => ({ const mockUUID = '550e8400-e29b-41d4-a716-446655440000'; jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUUID as `${string}-${string}-${string}-${string}-${string}`); -import { createBatchJob, getJobStatus, processJobItems } from '../../../../apps/backend/services/scanner/batch'; +import { createBatchJob, getJobStatus, listJobs, processJobItems } from '../../../../apps/backend/services/scanner/batch'; describe('batch service', () => { beforeEach(() => { @@ -108,7 +114,9 @@ describe('batch service', () => { mockSelect.mockReturnValue({ from: mockFrom }); mockFrom.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); - mockOrderBy.mockReturnValue({ execute: mockExecute }); + mockOrderBy.mockReturnValue({ limit: mockLimit, execute: mockExecute }); + mockLimit.mockReturnValue({ offset: mockOffset }); + mockOffset.mockReturnValue({ execute: mockExecute }); mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); @@ -316,6 +324,89 @@ describe('batch service', () => { }); }); + describe('listJobs', () => { + const userId = 'user-123'; + + it('returns empty list when no jobs exist', async () => { + mockExecute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ count: 0 }]); + + const result = await listJobs(userId, 20, 0); + + expect(result.jobs).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.limit).toBe(20); + expect(result.offset).toBe(0); + }); + + it('returns job summaries sorted by created_at descending', async () => { + const jobRows = [ + { + id: 'job-2', + user_id: userId, + status: 'completed', + total_items: 3, + completed_items: 3, + failed_items: 0, + created_at: new Date('2026-03-02'), + updated_at: new Date('2026-03-02'), + }, + { + id: 'job-1', + user_id: userId, + status: 'pending', + total_items: 1, + completed_items: 0, + failed_items: 0, + created_at: new Date('2026-03-01'), + updated_at: new Date('2026-03-01'), + }, + ]; + mockExecute.mockResolvedValueOnce(jobRows).mockResolvedValueOnce([{ count: 2 }]); + + const result = await listJobs(userId, 20, 0); + + expect(result.jobs).toHaveLength(2); + expect(result.jobs[0].jobId).toBe('job-2'); + expect(result.jobs[1].jobId).toBe('job-1'); + expect(result.total).toBe(2); + }); + + it('passes limit and offset to the query', async () => { + mockExecute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ count: 0 }]); + + await listJobs(userId, 5, 10); + + expect(mockLimit).toHaveBeenCalledWith(5); + expect(mockOffset).toHaveBeenCalledWith(10); + }); + + it('maps job rows to BatchJobSummary correctly', async () => { + const jobRow = { + id: 'job-abc', + user_id: userId, + status: 'completed', + total_items: 2, + completed_items: 1, + failed_items: 1, + created_at: new Date('2026-02-15'), + updated_at: new Date('2026-02-16'), + }; + mockExecute.mockResolvedValueOnce([jobRow]).mockResolvedValueOnce([{ count: 1 }]); + + const result = await listJobs(userId, 20, 0); + + expect(result.jobs[0]).toEqual({ + jobId: 'job-abc', + status: 'completed', + totalItems: 2, + completedItems: 1, + failedItems: 1, + createdAt: new Date('2026-02-15'), + updatedAt: new Date('2026-02-16'), + }); + }); + }); + describe('processJobItems', () => { const jobId = mockUUID; const items = [