Files
flyer-crawler.projectium.com/src/routes/versioned.test.ts
Torben Sorensen f10c6c0cd6
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m56s
Complete ADR-008 Phase 2
2026-01-27 11:06:09 -08:00

749 lines
26 KiB
TypeScript

// src/routes/versioned.test.ts
/**
* @file Unit tests for the version router factory.
* Tests ADR-008 Phase 2: API Versioning Infrastructure.
*
* These tests verify:
* - Router creation for different API versions
* - X-API-Version header on all responses
* - Deprecation headers for deprecated versions
* - Route availability filtering by version
* - Router caching behavior
* - Utility functions
*/
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import express, { Router, Request, Response } from 'express';
import type { Logger } from 'pino';
// --- Hoisted Mock Setup ---
// vi.hoisted() is executed before imports, making values available in vi.mock factories
const { mockLoggerFn, inlineMockLogger, createMockRouterFactory } = vi.hoisted(() => {
const mockLoggerFn = vi.fn();
const inlineMockLogger = {
info: mockLoggerFn,
debug: mockLoggerFn,
error: mockLoggerFn,
warn: mockLoggerFn,
fatal: mockLoggerFn,
trace: mockLoggerFn,
silent: mockLoggerFn,
child: vi.fn().mockReturnThis(),
} as unknown as Logger;
// Factory function to create mock routers
const createMockRouterFactory = (name: string) => {
// Import express Router here since we're in hoisted context
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Router: ExpressRouter } = require('express');
const router = ExpressRouter();
router.get('/test', (req: Request, res: Response) => {
res.json({ router: name, version: (req as { apiVersion?: string }).apiVersion });
});
return router;
};
return { mockLoggerFn, inlineMockLogger, createMockRouterFactory };
});
// --- Mock Setup ---
// Mock the logger before any imports that use it
vi.mock('../services/logger.server', () => ({
createScopedLogger: vi.fn(() => inlineMockLogger),
logger: inlineMockLogger,
}));
// Mock all domain routers with minimal test routers
// This isolates the versioned.ts tests from actual route implementations
vi.mock('./auth.routes', () => ({ default: createMockRouterFactory('auth') }));
vi.mock('./health.routes', () => ({ default: createMockRouterFactory('health') }));
vi.mock('./system.routes', () => ({ default: createMockRouterFactory('system') }));
vi.mock('./user.routes', () => ({ default: createMockRouterFactory('user') }));
vi.mock('./ai.routes', () => ({ default: createMockRouterFactory('ai') }));
vi.mock('./admin.routes', () => ({ default: createMockRouterFactory('admin') }));
vi.mock('./budget.routes', () => ({ default: createMockRouterFactory('budget') }));
vi.mock('./gamification.routes', () => ({ default: createMockRouterFactory('gamification') }));
vi.mock('./flyer.routes', () => ({ default: createMockRouterFactory('flyer') }));
vi.mock('./recipe.routes', () => ({ default: createMockRouterFactory('recipe') }));
vi.mock('./personalization.routes', () => ({
default: createMockRouterFactory('personalization'),
}));
vi.mock('./price.routes', () => ({ default: createMockRouterFactory('price') }));
vi.mock('./stats.routes', () => ({ default: createMockRouterFactory('stats') }));
vi.mock('./upc.routes', () => ({ default: createMockRouterFactory('upc') }));
vi.mock('./inventory.routes', () => ({ default: createMockRouterFactory('inventory') }));
vi.mock('./receipt.routes', () => ({ default: createMockRouterFactory('receipt') }));
vi.mock('./deals.routes', () => ({ default: createMockRouterFactory('deals') }));
vi.mock('./reactions.routes', () => ({ default: createMockRouterFactory('reactions') }));
vi.mock('./store.routes', () => ({ default: createMockRouterFactory('store') }));
vi.mock('./category.routes', () => ({ default: createMockRouterFactory('category') }));
// Import types and modules AFTER mocks are set up
import type { ApiVersion, VersionConfig } from '../config/apiVersions';
import { DEPRECATION_HEADERS } from '../middleware/deprecation.middleware';
import { errorHandler } from '../middleware/errorHandler';
// Import the module under test
import {
createVersionedRouter,
createApiRouter,
getRegisteredPaths,
getRouteByPath,
getRoutesForVersion,
clearRouterCache,
refreshRouterCache,
ROUTES,
} from './versioned';
import { API_VERSIONS, VERSION_CONFIGS } from '../config/apiVersions';
// --- Test Utilities ---
/**
* Creates a test Express app with the given router mounted at the specified path.
*/
function createTestApp(router: Router, basePath = '/api') {
const app = express();
app.use(express.json());
// Inject mock logger into requests
app.use((req, res, next) => {
req.log = inlineMockLogger;
next();
});
app.use(basePath, router);
app.use(errorHandler);
return app;
}
/**
* Stores original VERSION_CONFIGS for restoration after tests that modify it.
*/
let originalVersionConfigs: Record<ApiVersion, VersionConfig>;
// --- Tests ---
describe('Versioned Router Factory', () => {
beforeAll(() => {
// Store original configs before any tests modify them
originalVersionConfigs = JSON.parse(JSON.stringify(VERSION_CONFIGS));
});
beforeEach(() => {
vi.clearAllMocks();
// Clear router cache before each test to ensure fresh state
clearRouterCache();
});
afterEach(() => {
// Restore original VERSION_CONFIGS after each test
Object.assign(VERSION_CONFIGS, originalVersionConfigs);
});
afterAll(() => {
// Final restoration
Object.assign(VERSION_CONFIGS, originalVersionConfigs);
});
// =========================================================================
// createVersionedRouter() Tests
// =========================================================================
describe('createVersionedRouter()', () => {
describe('route registration', () => {
it('should create router with all expected routes for v1', () => {
// Act
const router = createVersionedRouter('v1');
// Assert - router should be created
expect(router).toBeDefined();
expect(typeof router).toBe('function'); // Express routers are functions
});
it('should create router with all expected routes for v2', () => {
// Act
const router = createVersionedRouter('v2');
// Assert
expect(router).toBeDefined();
expect(typeof router).toBe('function');
});
it('should register routes in the expected order', () => {
// The ROUTES array defines registration order
const expectedOrder = [
'auth',
'health',
'system',
'users',
'ai',
'admin',
'budgets',
'achievements',
'flyers',
'recipes',
'personalization',
'price-history',
'stats',
'upc',
'inventory',
'receipts',
'deals',
'reactions',
'stores',
'categories',
];
// Assert order matches
const registeredPaths = ROUTES.map((r) => r.path);
expect(registeredPaths).toEqual(expectedOrder);
});
it('should skip routes not available for specified version', () => {
// Assert - getRoutesForVersion should filter correctly
const v1Routes = getRoutesForVersion('v1');
const v2Routes = getRoutesForVersion('v2');
// All routes without versions restriction should be in both
expect(v1Routes.length).toBe(ROUTES.length);
expect(v2Routes.length).toBe(ROUTES.length);
// If we had a version-restricted route, it would only appear in that version
// This tests the filtering logic via getRoutesForVersion
expect(v1Routes.some((r) => r.path === 'auth')).toBe(true);
expect(v2Routes.some((r) => r.path === 'auth')).toBe(true);
});
});
describe('X-API-Version header', () => {
it('should add X-API-Version header to all v1 responses', async () => {
// Arrange
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
});
it('should add X-API-Version header to all v2 responses', async () => {
// Arrange
const router = createVersionedRouter('v2');
const app = createTestApp(router, '/api/v2');
// Act
const response = await supertest(app).get('/api/v2/health/test');
// Assert
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
});
it('should NOT add X-API-Version header when deprecation middleware is disabled', async () => {
// Arrange - create router with deprecation headers disabled
const router = createVersionedRouter('v1', { applyDeprecationHeaders: false });
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert - header should NOT be present when deprecation middleware is disabled
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBeUndefined();
});
});
describe('deprecation headers', () => {
it('should not add deprecation headers for active versions', async () => {
// Arrange - v1 is active by default
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert - no deprecation headers
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBeUndefined();
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBeUndefined();
expect(
response.headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()],
).toBeUndefined();
});
it('should add deprecation headers when version is deprecated', async () => {
// Arrange - Temporarily mark v1 as deprecated
VERSION_CONFIGS.v1 = {
version: 'v1',
status: 'deprecated',
sunsetDate: '2027-01-01T00:00:00Z',
successorVersion: 'v2',
};
// Clear cache and create fresh router with updated config
clearRouterCache();
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert - deprecation headers present
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe(
'2027-01-01T00:00:00Z',
);
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
'</api/v2>; rel="successor-version"',
);
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION_NOTICE.toLowerCase()]).toContain(
'deprecated',
);
});
it('should include sunset date in deprecation headers when provided', async () => {
// Arrange
VERSION_CONFIGS.v1 = {
version: 'v1',
status: 'deprecated',
sunsetDate: '2028-06-15T00:00:00Z',
successorVersion: 'v2',
};
clearRouterCache();
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBe(
'2028-06-15T00:00:00Z',
);
});
it('should include successor version link when provided', async () => {
// Arrange
VERSION_CONFIGS.v1 = {
version: 'v1',
status: 'deprecated',
successorVersion: 'v2',
};
clearRouterCache();
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.headers[DEPRECATION_HEADERS.LINK.toLowerCase()]).toBe(
'</api/v2>; rel="successor-version"',
);
});
it('should not include sunset date when not provided', async () => {
// Arrange - deprecated without sunset date
VERSION_CONFIGS.v1 = {
version: 'v1',
status: 'deprecated',
// No sunsetDate
};
clearRouterCache();
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.headers[DEPRECATION_HEADERS.DEPRECATION.toLowerCase()]).toBe('true');
expect(response.headers[DEPRECATION_HEADERS.SUNSET.toLowerCase()]).toBeUndefined();
});
});
describe('router options', () => {
it('should apply deprecation headers middleware by default', async () => {
// Arrange
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert - X-API-Version should be set (proves middleware ran)
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
});
it('should skip deprecation headers middleware when disabled', async () => {
// Arrange
const router = createVersionedRouter('v1', { applyDeprecationHeaders: false });
const app = createTestApp(router, '/api/v1');
// Act
const response = await supertest(app).get('/api/v1/health/test');
// Assert - X-API-Version should NOT be set
expect(response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBeUndefined();
});
});
});
// =========================================================================
// createApiRouter() Tests
// =========================================================================
describe('createApiRouter()', () => {
it('should mount all supported versions', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act & Assert - v1 should work
const v1Response = await supertest(app).get('/api/v1/health/test');
expect(v1Response.status).toBe(200);
expect(v1Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v1');
// Act & Assert - v2 should work
const v2Response = await supertest(app).get('/api/v2/health/test');
expect(v2Response.status).toBe(200);
expect(v2Response.headers[DEPRECATION_HEADERS.API_VERSION.toLowerCase()]).toBe('v2');
});
it('should return 404 for unsupported versions', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act
const response = await supertest(app).get('/api/v99/health/test');
// Assert
expect(response.status).toBe(404);
expect(response.body.error.code).toBe('UNSUPPORTED_VERSION');
expect(response.body.error.message).toContain('v99');
expect(response.body.error.details.supportedVersions).toEqual(['v1', 'v2']);
});
it('should route to correct versioned router based on URL version', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act
const v1Response = await supertest(app).get('/api/v1/health/test');
const v2Response = await supertest(app).get('/api/v2/health/test');
// Assert - each response should indicate the correct version
expect(v1Response.body.version).toBe('v1');
expect(v2Response.body.version).toBe('v2');
});
it('should handle requests to various domain routers', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act & Assert - multiple domain routers
const authResponse = await supertest(app).get('/api/v1/auth/test');
expect(authResponse.status).toBe(200);
expect(authResponse.body.router).toBe('auth');
const flyerResponse = await supertest(app).get('/api/v1/flyers/test');
expect(flyerResponse.status).toBe(200);
expect(flyerResponse.body.router).toBe('flyer');
const storeResponse = await supertest(app).get('/api/v1/stores/test');
expect(storeResponse.status).toBe(200);
expect(storeResponse.body.router).toBe('store');
});
});
// =========================================================================
// Utility Functions Tests
// =========================================================================
describe('getRegisteredPaths()', () => {
it('should return all registered route paths', () => {
// Act
const paths = getRegisteredPaths();
// Assert
expect(paths).toBeInstanceOf(Array);
expect(paths.length).toBe(ROUTES.length);
expect(paths).toContain('auth');
expect(paths).toContain('health');
expect(paths).toContain('flyers');
expect(paths).toContain('stores');
expect(paths).toContain('categories');
});
it('should return paths in registration order', () => {
// Act
const paths = getRegisteredPaths();
// Assert - first and last should match ROUTES order
expect(paths[0]).toBe('auth');
expect(paths[paths.length - 1]).toBe('categories');
});
});
describe('getRouteByPath()', () => {
it('should return correct registration for existing path', () => {
// Act
const authRoute = getRouteByPath('auth');
const healthRoute = getRouteByPath('health');
// Assert
expect(authRoute).toBeDefined();
expect(authRoute?.path).toBe('auth');
expect(authRoute?.description).toContain('Authentication');
expect(healthRoute).toBeDefined();
expect(healthRoute?.path).toBe('health');
expect(healthRoute?.description).toContain('Health');
});
it('should return undefined for non-existent path', () => {
// Act
const result = getRouteByPath('non-existent-path');
// Assert
expect(result).toBeUndefined();
});
it('should return undefined for empty string', () => {
// Act
const result = getRouteByPath('');
// Assert
expect(result).toBeUndefined();
});
});
describe('getRoutesForVersion()', () => {
it('should return all routes when no version restrictions exist', () => {
// Act
const v1Routes = getRoutesForVersion('v1');
const v2Routes = getRoutesForVersion('v2');
// Assert - all routes should be available in both versions
// (since none of the default routes have version restrictions)
expect(v1Routes.length).toBe(ROUTES.length);
expect(v2Routes.length).toBe(ROUTES.length);
});
it('should filter routes based on version restrictions', () => {
// This tests the filtering logic - routes with versions array
// should only appear for versions listed in that array
const v1Routes = getRoutesForVersion('v1');
// All routes should have path and router properties
v1Routes.forEach((route) => {
expect(route.path).toBeDefined();
expect(route.router).toBeDefined();
expect(route.description).toBeDefined();
});
});
it('should include routes without version restrictions in all versions', () => {
// Routes without versions array should appear in all versions
const authRoute = ROUTES.find((r) => r.path === 'auth');
expect(authRoute?.versions).toBeUndefined(); // No version restriction
const v1Routes = getRoutesForVersion('v1');
const v2Routes = getRoutesForVersion('v2');
expect(v1Routes.some((r) => r.path === 'auth')).toBe(true);
expect(v2Routes.some((r) => r.path === 'auth')).toBe(true);
});
});
// =========================================================================
// Router Cache Tests
// =========================================================================
describe('router cache', () => {
it('should cache routers after creation', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act - make multiple requests (should use cached router)
const response1 = await supertest(app).get('/api/v1/health/test');
const response2 = await supertest(app).get('/api/v1/health/test');
// Assert - both requests should succeed (proving cache works)
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
});
it('should clear cache with clearRouterCache()', () => {
// Arrange - create some routers to populate cache
createVersionedRouter('v1');
createVersionedRouter('v2');
// Act
clearRouterCache();
// Assert - cache should be cleared (logger would have logged)
expect(mockLoggerFn).toHaveBeenCalledWith('Versioned router cache cleared');
});
it('should refresh cache with refreshRouterCache()', () => {
// Arrange
createVersionedRouter('v1');
// Act
refreshRouterCache();
// Assert - cache should be refreshed (logger would have logged)
expect(mockLoggerFn).toHaveBeenCalledWith(
expect.objectContaining({ cachedVersions: expect.any(Array) }),
'Versioned router cache refreshed',
);
});
it('should create routers on-demand if not in cache', async () => {
// Arrange
clearRouterCache();
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act - request should trigger on-demand router creation
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.status).toBe(200);
});
});
// =========================================================================
// ROUTES Configuration Tests
// =========================================================================
describe('ROUTES configuration', () => {
it('should have all required properties for each route', () => {
ROUTES.forEach((route) => {
expect(route.path).toBeDefined();
expect(typeof route.path).toBe('string');
expect(route.path.length).toBeGreaterThan(0);
expect(route.router).toBeDefined();
expect(typeof route.router).toBe('function');
expect(route.description).toBeDefined();
expect(typeof route.description).toBe('string');
expect(route.description.length).toBeGreaterThan(0);
// versions is optional
if (route.versions !== undefined) {
expect(Array.isArray(route.versions)).toBe(true);
route.versions.forEach((v) => {
expect(API_VERSIONS).toContain(v);
});
}
});
});
it('should not have duplicate paths', () => {
const paths = ROUTES.map((r) => r.path);
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(paths.length);
});
it('should have expected number of routes', () => {
// This ensures we don't accidentally remove routes
expect(ROUTES.length).toBe(20);
});
it('should include all core domain routers', () => {
const paths = getRegisteredPaths();
const expectedRoutes = [
'auth',
'health',
'system',
'users',
'ai',
'admin',
'budgets',
'achievements',
'flyers',
'recipes',
'personalization',
'price-history',
'stats',
'upc',
'inventory',
'receipts',
'deals',
'reactions',
'stores',
'categories',
];
expectedRoutes.forEach((route) => {
expect(paths).toContain(route);
});
});
});
// =========================================================================
// Edge Cases and Error Handling
// =========================================================================
describe('edge cases', () => {
it('should handle requests to nested routes', async () => {
// Arrange
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act - request to nested path
const response = await supertest(app).get('/api/v1/health/test');
// Assert
expect(response.status).toBe(200);
expect(response.body.router).toBe('health');
});
it('should return 404 for non-existent routes within valid version', async () => {
// Arrange
const router = createVersionedRouter('v1');
const app = createTestApp(router, '/api/v1');
// Act - request to non-existent domain
const response = await supertest(app).get('/api/v1/nonexistent/endpoint');
// Assert
expect(response.status).toBe(404);
});
it('should handle multiple concurrent requests', async () => {
// Arrange
const apiRouter = createApiRouter();
const app = createTestApp(apiRouter, '/api');
// Act - make concurrent requests
const requests = [
supertest(app).get('/api/v1/health/test'),
supertest(app).get('/api/v1/auth/test'),
supertest(app).get('/api/v2/health/test'),
supertest(app).get('/api/v2/flyers/test'),
];
const responses = await Promise.all(requests);
// Assert - all should succeed
responses.forEach((response) => {
expect(response.status).toBe(200);
});
});
});
});