All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m49s
375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
// 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().mockResolvedValue(undefined),
|
|
},
|
|
}));
|
|
|
|
// 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', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).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/v1/flyers)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const app = createTestApp({ router: flyerRouter, basePath: '/api/v1/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/v1/flyers');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockFlyers);
|
|
// Also assert that the default limit and offset were used.
|
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
|
|
});
|
|
|
|
it('should pass limit and offset query parameters to the db function', async () => {
|
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
|
await supertest(app).get('/api/v1/flyers?limit=15&offset=30');
|
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
|
|
});
|
|
|
|
it('should use default for offset when only limit is provided', async () => {
|
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
|
await supertest(app).get('/api/v1/flyers?limit=5');
|
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 5, 0);
|
|
});
|
|
|
|
it('should use default for limit when only offset is provided', async () => {
|
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
|
await supertest(app).get('/api/v1/flyers?offset=10');
|
|
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 10);
|
|
});
|
|
|
|
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/v1/flyers');
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error in /api/v1/flyers:');
|
|
});
|
|
|
|
it('should return 400 for invalid query parameters', async () => {
|
|
const response = await supertest(app).get('/api/v1/flyers?limit=abc&offset=-5');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details).toBeDefined();
|
|
expect(response.body.error.details.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/v1/flyers/123');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).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/v1/flyers/999');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error.message).toContain('not found');
|
|
});
|
|
|
|
it('should return 400 for an invalid flyer ID', async () => {
|
|
const response = await supertest(app).get('/api/v1/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.error.details[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/v1/flyers/123');
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.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/v1/flyers/123/items');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockFlyerItems);
|
|
});
|
|
|
|
it('should return 400 for an invalid flyer ID', async () => {
|
|
const response = await supertest(app).get('/api/v1/flyers/abc/items');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[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/v1/flyers/123/items');
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: dbError, flyerId: 123 },
|
|
'Error in /api/v1/flyers/123/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/v1/flyers/items/batch-fetch')
|
|
.send({ flyerIds: [1, 2] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockFlyerItems);
|
|
});
|
|
|
|
it('should return 400 if flyerIds is not an array', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/batch-fetch')
|
|
.send({ flyerIds: 'not-an-array' });
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[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/v1/flyers/items/batch-fetch')
|
|
.send({ flyerIds: [] });
|
|
expect(response.status).toBe(400);
|
|
// Check for the specific Zod error message.
|
|
expect(response.body.error.details[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/v1/flyers/items/batch-fetch')
|
|
.send({ flyerIds: [1] });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.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/v1/flyers/items/batch-count')
|
|
.send({ flyerIds: [1, 2, 3] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual({ count: 42 });
|
|
});
|
|
|
|
it('should return 400 if flyerIds is not an array', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/v1/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/v1/flyers/items/batch-count')
|
|
.send({ flyerIds: [] });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).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/v1/flyers/items/batch-count')
|
|
.send({ flyerIds: [1] });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.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/v1/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/v1/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/v1/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/v1/flyers/items/99/track')
|
|
.send({ type: 'invalid' });
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 202 and log an error if the tracking function fails', async () => {
|
|
const trackingError = new Error('Tracking DB is down');
|
|
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockRejectedValue(trackingError);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/99/track')
|
|
.send({ type: 'click' });
|
|
|
|
expect(response.status).toBe(202);
|
|
|
|
// Allow the event loop to process the unhandled promise rejection from the fire-and-forget call
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: trackingError, itemId: 99 },
|
|
'Flyer item interaction tracking failed',
|
|
);
|
|
});
|
|
|
|
it('should return 500 if the tracking function throws synchronously', async () => {
|
|
const syncError = new Error('Sync error in tracking');
|
|
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockImplementation(() => {
|
|
throw syncError;
|
|
});
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/99/track')
|
|
.send({ type: 'click' });
|
|
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting', () => {
|
|
it('should apply publicReadLimiter to GET /', async () => {
|
|
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
|
const response = await supertest(app)
|
|
.get('/api/v1/flyers')
|
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
|
|
});
|
|
|
|
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
|
|
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue([]);
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/batch-fetch')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send({ flyerIds: [1] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
|
|
});
|
|
|
|
it('should apply batchLimiter to POST /items/batch-count', async () => {
|
|
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(0);
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/batch-count')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send({ flyerIds: [1] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
|
|
});
|
|
|
|
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
|
|
// Mock fire-and-forget promise
|
|
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/v1/flyers/items/1/track')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send({ type: 'view' });
|
|
|
|
expect(response.status).toBe(202);
|
|
expect(response.headers).toHaveProperty('ratelimit-limit');
|
|
expect(parseInt(response.headers['ratelimit-limit'])).toBe(200);
|
|
});
|
|
});
|
|
});
|