// src/routes/flyer.routes.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import supertest from 'supertest'; import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories'; import { NotFoundError } from '../services/db/errors.db'; import { createTestApp } from '../tests/utils/createTestApp'; // 1. Mock the Service Layer directly. vi.mock('../services/db/index.db', () => ({ flyerRepo: { getFlyerById: vi.fn(), getFlyers: vi.fn(), getFlyerItems: vi.fn(), getFlyerItemsForFlyers: vi.fn(), countFlyerItemsForFlyers: vi.fn(), trackFlyerItemInteraction: vi.fn(), }, })); // Import the router and mocked DB AFTER all mocks are defined. import flyerRouter from './flyer.routes'; import * as db from '../services/db/index.db'; import { mockLogger } from '../tests/utils/mockLogger'; // Mock the logger to keep test output clean vi.mock('../services/logger.server', () => ({ logger: mockLogger, })); // Define a reusable matcher for the logger object. const expectLogger = expect.objectContaining({ info: expect.any(Function), error: expect.any(Function), }); describe('Flyer Routes (/api/flyers)', () => { beforeEach(() => { vi.clearAllMocks(); }); const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' }); describe('GET /', () => { it('should return a list of flyers on success', async () => { const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })]; vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue(mockFlyers); const response = await supertest(app).get('/api/flyers'); expect(response.status).toBe(200); expect(response.body).toEqual(mockFlyers); }); it('should pass limit and offset query parameters to the db function', async () => { vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]); await supertest(app).get('/api/flyers?limit=15&offset=30'); expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30); }); it('should return 500 if the database call fails', async () => { const dbError = new Error('DB Error'); vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError); const response = await supertest(app).get('/api/flyers'); expect(response.status).toBe(500); expect(response.body.message).toBe('DB Error'); expect(mockLogger.error).toHaveBeenCalledWith( { error: dbError }, 'Error fetching flyers in /api/flyers:', ); }); it('should return 400 for invalid query parameters', async () => { const response = await supertest(app).get('/api/flyers?limit=abc&offset=-5'); expect(response.status).toBe(400); expect(response.body.errors).toBeDefined(); expect(response.body.errors.length).toBe(2); }); }); describe('GET /:id', () => { it('should return a single flyer on success', async () => { const mockFlyer = createMockFlyer({ flyer_id: 123 }); vi.mocked(db.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer); const response = await supertest(app).get('/api/flyers/123'); expect(response.status).toBe(200); expect(response.body).toEqual(mockFlyer); expect(db.flyerRepo.getFlyerById).toHaveBeenCalledWith(123); }); it('should return 404 if the flyer is not found', async () => { // FIX: Instead of mocking a rejection, we mock the *result* of the database query // to have zero rows. This allows the `getFlyerById` method's own internal logic // to correctly throw the `NotFoundError`, making the test more realistic. vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue( new NotFoundError(`Flyer with ID 999 not found.`), ); const response = await supertest(app).get('/api/flyers/999'); expect(response.status).toBe(404); expect(response.body.message).toContain('not found'); }); it('should return 400 for an invalid flyer ID', async () => { const response = await supertest(app).get('/api/flyers/abc'); expect(response.status).toBe(400); // Zod coercion results in NaN for "abc", which triggers a type error before our custom message expect(response.body.errors[0].message).toMatch( /Invalid flyer ID provided|expected number, received NaN/, ); }); it('should return 500 if the database call fails', async () => { const dbError = new Error('DB Error'); vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(dbError); const response = await supertest(app).get('/api/flyers/123'); expect(response.status).toBe(500); expect(response.body.message).toBe('DB Error'); expect(mockLogger.error).toHaveBeenCalledWith( { error: dbError, flyerId: 123 }, 'Error fetching flyer by ID:', ); }); }); describe('GET /:id/items', () => { it('should return items for a specific flyer', async () => { const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123 })]; vi.mocked(db.flyerRepo.getFlyerItems).mockResolvedValue(mockFlyerItems); const response = await supertest(app).get('/api/flyers/123/items'); expect(response.status).toBe(200); expect(response.body).toEqual(mockFlyerItems); }); it('should return 400 for an invalid flyer ID', async () => { const response = await supertest(app).get('/api/flyers/abc/items'); expect(response.status).toBe(400); expect(response.body.errors[0].message).toMatch( /Invalid flyer ID provided|expected number, received NaN/, ); }); it('should return 500 if the database call fails', async () => { const dbError = new Error('DB Error'); vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(dbError); const response = await supertest(app).get('/api/flyers/123/items'); expect(response.status).toBe(500); expect(response.body.message).toBe('DB Error'); expect(mockLogger.error).toHaveBeenCalledWith( { error: dbError }, 'Error fetching flyer items in /api/flyers/:id/items:', ); }); }); describe('POST /items/batch-fetch', () => { it('should return items for multiple flyers', async () => { const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1 })]; vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems); const response = await supertest(app) .post('/api/flyers/items/batch-fetch') .send({ flyerIds: [1, 2] }); expect(response.status).toBe(200); expect(response.body).toEqual(mockFlyerItems); }); it('should return 400 if flyerIds is not an array', async () => { const response = await supertest(app) .post('/api/flyers/items/batch-fetch') .send({ flyerIds: 'not-an-array' }); expect(response.status).toBe(400); expect(response.body.errors[0].message).toMatch(/expected array/); }); it('should return 400 if flyerIds is an empty array, as per schema validation', async () => { const response = await supertest(app) .post('/api/flyers/items/batch-fetch') .send({ flyerIds: [] }); expect(response.status).toBe(400); // Check for the specific Zod error message. expect(response.body.errors[0].message).toBe('flyerIds must be a non-empty array.'); }); it('should return 500 if the database call fails', async () => { vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error')); const response = await supertest(app) .post('/api/flyers/items/batch-fetch') .send({ flyerIds: [1] }); expect(response.status).toBe(500); expect(response.body.message).toBe('DB Error'); }); }); describe('POST /items/batch-count', () => { it('should return the count of items for multiple flyers', async () => { vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(42); const response = await supertest(app) .post('/api/flyers/items/batch-count') .send({ flyerIds: [1, 2, 3] }); expect(response.status).toBe(200); expect(response.body).toEqual({ count: 42 }); }); it('should return 400 if flyerIds is not an array', async () => { const response = await supertest(app) .post('/api/flyers/items/batch-count') .send({ flyerIds: 'not-an-array' }); expect(response.status).toBe(400); }); it('should return a count of 0 if flyerIds is empty', async () => { vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(0); const response = await supertest(app) .post('/api/flyers/items/batch-count') .send({ flyerIds: [] }); expect(response.status).toBe(200); expect(response.body).toEqual({ count: 0 }); }); it('should return 500 if the database call fails', async () => { vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error')); const response = await supertest(app) .post('/api/flyers/items/batch-count') .send({ flyerIds: [1] }); expect(response.status).toBe(500); expect(response.body.message).toBe('DB Error'); }); }); describe('POST /items/:itemId/track', () => { it('should return 202 Accepted and call the tracking function for "click"', async () => { const response = await supertest(app) .post('/api/flyers/items/99/track') .send({ type: 'click' }); expect(response.status).toBe(202); expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith( 99, 'click', expectLogger, ); }); it('should return 202 Accepted and call the tracking function for "view"', async () => { const response = await supertest(app) .post('/api/flyers/items/101/track') .send({ type: 'view' }); expect(response.status).toBe(202); expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith( 101, 'view', expectLogger, ); }); it('should return 400 for an invalid item ID', async () => { const response = await supertest(app) .post('/api/flyers/items/abc/track') .send({ type: 'click' }); expect(response.status).toBe(400); }); it('should return 400 for an invalid interaction type', async () => { const response = await supertest(app) .post('/api/flyers/items/99/track') .send({ type: 'invalid' }); expect(response.status).toBe(400); }); }); });