All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m56s
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
// src/middleware/apiVersion.middleware.test.ts
|
|
/**
|
|
* @file Unit tests for API version detection middleware (ADR-008 Phase 2).
|
|
* @see src/middleware/apiVersion.middleware.ts
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import {
|
|
detectApiVersion,
|
|
extractApiVersionFromPath,
|
|
hasApiVersion,
|
|
getRequestApiVersion,
|
|
VERSION_ERROR_CODES,
|
|
} from './apiVersion.middleware';
|
|
import { DEFAULT_VERSION, SUPPORTED_VERSIONS } from '../config/apiVersions';
|
|
import { createMockRequest } from '../tests/utils/createMockRequest';
|
|
import { createMockLogger } from '../tests/utils/mockLogger';
|
|
|
|
describe('apiVersion.middleware', () => {
|
|
let mockRequest: Partial<Request>;
|
|
let mockResponse: Partial<Response>;
|
|
let mockNext: NextFunction & Mock;
|
|
let mockJson: Mock;
|
|
let mockStatus: Mock;
|
|
|
|
beforeEach(() => {
|
|
// Reset mocks before each test
|
|
mockJson = vi.fn().mockReturnThis();
|
|
mockStatus = vi.fn().mockReturnValue({ json: mockJson });
|
|
mockNext = vi.fn();
|
|
|
|
mockResponse = {
|
|
status: mockStatus,
|
|
json: mockJson,
|
|
};
|
|
});
|
|
|
|
describe('detectApiVersion', () => {
|
|
it('should extract v1 from req.params.version and attach to req.apiVersion', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'v1' },
|
|
path: '/users',
|
|
method: 'GET',
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockNext).toHaveBeenCalledWith();
|
|
expect(mockRequest.apiVersion).toBe('v1');
|
|
expect(mockRequest.versionDeprecation).toBeDefined();
|
|
expect(mockRequest.versionDeprecation?.deprecated).toBe(false);
|
|
});
|
|
|
|
it('should extract v2 from req.params.version and attach to req.apiVersion', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'v2' },
|
|
path: '/flyers',
|
|
method: 'POST',
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe('v2');
|
|
expect(mockRequest.versionDeprecation).toBeDefined();
|
|
});
|
|
|
|
it('should default to v1 when no version parameter is present', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
params: {},
|
|
path: '/users',
|
|
method: 'GET',
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
|
expect(mockRequest.versionDeprecation).toBeDefined();
|
|
});
|
|
|
|
it('should return 404 with UNSUPPORTED_VERSION for invalid version v99', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'v99' },
|
|
path: '/users',
|
|
method: 'GET',
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
expect(mockStatus).toHaveBeenCalledWith(404);
|
|
expect(mockJson).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
error: expect.objectContaining({
|
|
code: VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
|
|
message: expect.stringContaining("API version 'v99' is not supported"),
|
|
details: expect.objectContaining({
|
|
requestedVersion: 'v99',
|
|
supportedVersions: expect.arrayContaining(['v1', 'v2']),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return 404 for non-versioned format like "latest"', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'latest' },
|
|
path: '/users',
|
|
method: 'GET',
|
|
ip: '192.168.1.1',
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
expect(mockStatus).toHaveBeenCalledWith(404);
|
|
expect(mockJson).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
error: expect.objectContaining({
|
|
code: VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
|
|
message: expect.stringContaining("API version 'latest' is not supported"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should log warning when invalid version is requested', () => {
|
|
// Arrange
|
|
const childLogger = createMockLogger();
|
|
const mockLog = createMockLogger();
|
|
vi.mocked(mockLog.child).mockReturnValue(
|
|
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
|
);
|
|
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'v999' },
|
|
path: '/test',
|
|
method: 'GET',
|
|
ip: '10.0.0.1',
|
|
log: mockLog,
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockLog.child).toHaveBeenCalledWith({ middleware: 'detectApiVersion' });
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
attemptedVersion: 'v999',
|
|
supportedVersions: SUPPORTED_VERSIONS,
|
|
}),
|
|
'Invalid API version requested',
|
|
);
|
|
});
|
|
|
|
it('should log debug when valid version is detected', () => {
|
|
// Arrange
|
|
const childLogger = createMockLogger();
|
|
const mockLog = createMockLogger();
|
|
vi.mocked(mockLog.child).mockReturnValue(
|
|
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
|
);
|
|
|
|
mockRequest = createMockRequest({
|
|
params: { version: 'v1' },
|
|
path: '/users',
|
|
method: 'GET',
|
|
log: mockLog,
|
|
});
|
|
|
|
// Act
|
|
detectApiVersion(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(childLogger.debug).toHaveBeenCalledWith(
|
|
{ apiVersion: 'v1' },
|
|
'API version detected from URL',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('extractApiVersionFromPath', () => {
|
|
it('should extract v1 from /v1/users path', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
path: '/v1/users',
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe('v1');
|
|
expect(mockRequest.versionDeprecation).toBeDefined();
|
|
});
|
|
|
|
it('should extract v2 from /v2/flyers/123 path', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
path: '/v2/flyers/123',
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe('v2');
|
|
});
|
|
|
|
it('should default to v1 for unversioned paths', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
path: '/users',
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
|
});
|
|
|
|
it('should default to v1 for paths without leading slash', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({
|
|
path: 'v1/users', // No leading slash - won't match regex
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
|
});
|
|
|
|
it('should use default for unsupported version numbers in path', () => {
|
|
// Arrange
|
|
const childLogger = createMockLogger();
|
|
const mockLog = createMockLogger();
|
|
vi.mocked(mockLog.child).mockReturnValue(
|
|
childLogger as unknown as ReturnType<typeof mockLog.child>,
|
|
);
|
|
|
|
mockRequest = createMockRequest({
|
|
path: '/v99/users',
|
|
params: {},
|
|
log: mockLog,
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
attemptedVersion: 'v99',
|
|
supportedVersions: SUPPORTED_VERSIONS,
|
|
}),
|
|
'Unsupported API version in path, falling back to default',
|
|
);
|
|
});
|
|
|
|
it('should handle paths with only version segment', () => {
|
|
// Arrange: Path like "/v1/" (just version, no resource)
|
|
mockRequest = createMockRequest({
|
|
path: '/v1/',
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe('v1');
|
|
});
|
|
|
|
it('should NOT extract version from path like /users/v1 (not at start)', () => {
|
|
// Arrange: Version appears later in path, not at the start
|
|
mockRequest = createMockRequest({
|
|
path: '/users/v1/profile',
|
|
params: {},
|
|
});
|
|
|
|
// Act
|
|
extractApiVersionFromPath(mockRequest as Request, mockResponse as Response, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
expect(mockRequest.apiVersion).toBe(DEFAULT_VERSION);
|
|
});
|
|
});
|
|
|
|
describe('hasApiVersion', () => {
|
|
it('should return true when apiVersion is set to valid version', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
mockRequest.apiVersion = 'v1';
|
|
|
|
// Act & Assert
|
|
expect(hasApiVersion(mockRequest as Request)).toBe(true);
|
|
});
|
|
|
|
it('should return false when apiVersion is undefined', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
mockRequest.apiVersion = undefined;
|
|
|
|
// Act & Assert
|
|
expect(hasApiVersion(mockRequest as Request)).toBe(false);
|
|
});
|
|
|
|
it('should return false when apiVersion is invalid', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
// Force an invalid version (bypassing TypeScript) - eslint-disable-next-line
|
|
(mockRequest as unknown as { apiVersion: string }).apiVersion = 'v99';
|
|
|
|
// Act & Assert
|
|
expect(hasApiVersion(mockRequest as Request)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getRequestApiVersion', () => {
|
|
it('should return the request apiVersion when set', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
mockRequest.apiVersion = 'v2';
|
|
|
|
// Act
|
|
const version = getRequestApiVersion(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(version).toBe('v2');
|
|
});
|
|
|
|
it('should return DEFAULT_VERSION when apiVersion is undefined', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
mockRequest.apiVersion = undefined;
|
|
|
|
// Act
|
|
const version = getRequestApiVersion(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(version).toBe(DEFAULT_VERSION);
|
|
});
|
|
|
|
it('should return DEFAULT_VERSION when apiVersion is invalid', () => {
|
|
// Arrange
|
|
mockRequest = createMockRequest({});
|
|
// Force an invalid version - eslint-disable-next-line
|
|
(mockRequest as unknown as { apiVersion: string }).apiVersion = 'invalid';
|
|
|
|
// Act
|
|
const version = getRequestApiVersion(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(version).toBe(DEFAULT_VERSION);
|
|
});
|
|
});
|
|
|
|
describe('VERSION_ERROR_CODES', () => {
|
|
it('should have UNSUPPORTED_VERSION error code', () => {
|
|
expect(VERSION_ERROR_CODES.UNSUPPORTED_VERSION).toBe('UNSUPPORTED_VERSION');
|
|
});
|
|
});
|
|
});
|