Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/backend/controllers/scanner.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/routes/scanner.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
68 changes: 67 additions & 1 deletion apps/backend/services/scanner/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<PaginatedJobList> {
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.
*
Expand Down
56 changes: 55 additions & 1 deletion tests/unit/controllers/scanner.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ const mockCreateBatchJob = jest.fn<
}>
>();
const mockGetJobStatus = jest.fn<(jobId: string, userId: string) => Promise<unknown>>();
const mockListJobs = jest.fn<(userId: string, limit: number, offset: number) => Promise<unknown>>();

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)
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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 = {
Expand Down
101 changes: 96 additions & 5 deletions tests/unit/services/scanner/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown[]>>().mockResolvedValue([]);
Expand All @@ -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: {
Expand All @@ -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<
Expand All @@ -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 })),
{
Expand All @@ -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(() => {
Expand All @@ -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 });

Expand Down Expand Up @@ -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 = [
Expand Down