Add comprehensive tests for i18n routes and fix route ordering

- Add 19 integration tests for i18n API routes with 100% coverage
- Fix Express route ordering: specific routes (/detect) before parameterized routes (/:locale)
- Test all endpoints: locale translations, available locales, locale detection
- Test error cases: unsupported locales, missing translations, malformed headers
- Test HTTP caching headers and content-type validation
- Include i18n routes in coverage collection (was previously excluded)

Coverage improvement: 147 tests total (+19), 79.9% statements (+1.2%)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-07 20:50:34 -04:00
parent a6f4830ab8
commit a537072d3d
3 changed files with 353 additions and 32 deletions

View file

@ -0,0 +1,322 @@
import request from 'supertest';
import express from 'express';
import { createI18nRoutes } from '../../../src/routes/i18n';
import { i18nService } from '../../../src/i18n';
// Mock the i18n service
jest.mock('../../../src/i18n', () => ({
i18nService: {
isLocaleSupported: jest.fn(),
getAvailableLocales: jest.fn(),
getTranslations: jest.fn(),
getDefaultLocale: jest.fn(),
getLocaleDisplayName: jest.fn(),
detectLocale: jest.fn()
}
}));
const mockI18nService = i18nService as jest.Mocked<typeof i18nService>;
describe('I18n API Routes', () => {
let app: express.Application;
beforeEach(() => {
app = express();
app.use('/api/i18n', createI18nRoutes());
// Reset mocks
jest.clearAllMocks();
// Default mock implementations
mockI18nService.getAvailableLocales.mockReturnValue(['en', 'es-MX']);
mockI18nService.getDefaultLocale.mockReturnValue('en');
mockI18nService.getLocaleDisplayName.mockImplementation((locale: string) => {
const names: Record<string, string> = {
'en': 'English',
'es-MX': 'Español (México)'
};
return names[locale] || locale;
});
});
describe('GET /api/i18n/:locale', () => {
const mockTranslations = {
common: {
appName: 'Great Lakes Ice Report',
submit: 'Submit',
cancel: 'Cancel'
},
pages: {
home: {
title: 'Ice Report',
subtitle: 'Community winter road conditions'
}
}
};
it('should return translations for a supported locale', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(true);
mockI18nService.getTranslations.mockReturnValue(mockTranslations);
const response = await request(app)
.get('/api/i18n/en')
.expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=3600');
expect(response.headers['content-type']).toMatch(/application\/json/);
expect(response.body).toEqual(mockTranslations);
expect(mockI18nService.isLocaleSupported).toHaveBeenCalledWith('en');
expect(mockI18nService.getTranslations).toHaveBeenCalledWith('en');
});
it('should return translations for Spanish locale', async () => {
const spanishTranslations = {
common: {
appName: 'Reporte de Hielo de los Grandes Lagos',
submit: 'Enviar',
cancel: 'Cancelar'
}
};
mockI18nService.isLocaleSupported.mockReturnValue(true);
mockI18nService.getTranslations.mockReturnValue(spanishTranslations);
const response = await request(app)
.get('/api/i18n/es-MX')
.expect(200);
expect(response.body).toEqual(spanishTranslations);
expect(mockI18nService.isLocaleSupported).toHaveBeenCalledWith('es-MX');
expect(mockI18nService.getTranslations).toHaveBeenCalledWith('es-MX');
});
it('should reject unsupported locale', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(false);
const response = await request(app)
.get('/api/i18n/fr')
.expect(400);
expect(response.body).toEqual({
error: 'Unsupported locale',
supportedLocales: ['en', 'es-MX']
});
expect(mockI18nService.isLocaleSupported).toHaveBeenCalledWith('fr');
expect(mockI18nService.getAvailableLocales).toHaveBeenCalled();
expect(mockI18nService.getTranslations).not.toHaveBeenCalled();
});
it('should handle missing translations for supported locale', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(true);
mockI18nService.getTranslations.mockReturnValue(null);
const response = await request(app)
.get('/api/i18n/en')
.expect(404);
expect(response.body).toEqual({
error: 'Translations not found for locale',
locale: 'en'
});
expect(mockI18nService.getTranslations).toHaveBeenCalledWith('en');
});
it('should handle special characters in locale parameter', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(false);
await request(app)
.get('/api/i18n/en-US@currency=USD')
.expect(400);
expect(mockI18nService.isLocaleSupported).toHaveBeenCalledWith('en-US@currency=USD');
});
it('should handle empty locale parameter', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(false);
await request(app)
.get('/api/i18n/')
.expect(200); // This will hit the GET / route instead
expect(mockI18nService.isLocaleSupported).not.toHaveBeenCalled();
});
});
describe('GET /api/i18n', () => {
it('should return available locales with display names', async () => {
const response = await request(app)
.get('/api/i18n')
.expect(200);
expect(response.body).toEqual({
default: 'en',
available: [
{ code: 'en', name: 'English' },
{ code: 'es-MX', name: 'Español (México)' }
]
});
expect(mockI18nService.getAvailableLocales).toHaveBeenCalled();
expect(mockI18nService.getDefaultLocale).toHaveBeenCalled();
expect(mockI18nService.getLocaleDisplayName).toHaveBeenCalledWith('en');
expect(mockI18nService.getLocaleDisplayName).toHaveBeenCalledWith('es-MX');
});
it('should handle empty available locales', async () => {
mockI18nService.getAvailableLocales.mockReturnValue([]);
const response = await request(app)
.get('/api/i18n')
.expect(200);
expect(response.body).toEqual({
default: 'en',
available: []
});
});
it('should handle missing display names gracefully', async () => {
mockI18nService.getAvailableLocales.mockReturnValue(['unknown']);
mockI18nService.getLocaleDisplayName.mockReturnValue('unknown');
const response = await request(app)
.get('/api/i18n')
.expect(200);
expect(response.body.available).toEqual([
{ code: 'unknown', name: 'unknown' }
]);
});
});
describe('GET /api/i18n/detect', () => {
it('should detect locale from Accept-Language header', async () => {
mockI18nService.detectLocale.mockReturnValue('es-MX');
const response = await request(app)
.get('/api/i18n/detect')
.set('Accept-Language', 'es-MX,es;q=0.9,en;q=0.8')
.expect(200);
expect(response.body).toEqual({
detected: 'es-MX',
displayName: 'Español (México)'
});
expect(mockI18nService.detectLocale).toHaveBeenCalledWith('es-MX,es;q=0.9,en;q=0.8');
expect(mockI18nService.getLocaleDisplayName).toHaveBeenCalledWith('es-MX');
});
it('should handle missing Accept-Language header', async () => {
mockI18nService.detectLocale.mockReturnValue('en');
const response = await request(app)
.get('/api/i18n/detect')
.expect(200);
expect(response.body).toEqual({
detected: 'en',
displayName: 'English'
});
expect(mockI18nService.detectLocale).toHaveBeenCalledWith(undefined);
});
it('should handle complex Accept-Language header', async () => {
mockI18nService.detectLocale.mockReturnValue('en');
const response = await request(app)
.get('/api/i18n/detect')
.set('Accept-Language', 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7,*;q=0.5')
.expect(200);
expect(response.body.detected).toBe('en');
expect(mockI18nService.detectLocale).toHaveBeenCalledWith('fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7,*;q=0.5');
});
it('should handle malformed Accept-Language header', async () => {
mockI18nService.detectLocale.mockReturnValue('en');
const response = await request(app)
.get('/api/i18n/detect')
.set('Accept-Language', 'invalid;;;malformed,,,')
.expect(200);
expect(response.body.detected).toBe('en');
expect(mockI18nService.detectLocale).toHaveBeenCalledWith('invalid;;;malformed,,,');
});
it('should handle empty Accept-Language header', async () => {
mockI18nService.detectLocale.mockReturnValue('en');
const response = await request(app)
.get('/api/i18n/detect')
.set('Accept-Language', '')
.expect(200);
expect(response.body.detected).toBe('en');
expect(mockI18nService.detectLocale).toHaveBeenCalledWith('');
});
});
describe('Route ordering and conflicts', () => {
it('should prioritize specific routes over detect route', async () => {
// Test that /api/i18n/detect doesn't conflict with /api/i18n/:locale
mockI18nService.detectLocale.mockReturnValue('en');
const response = await request(app)
.get('/api/i18n/detect')
.expect(200);
expect(response.body).toHaveProperty('detected');
expect(response.body).toHaveProperty('displayName');
expect(mockI18nService.detectLocale).toHaveBeenCalled();
});
it('should handle locale named "detect" correctly', async () => {
// This should NOT call the detect endpoint
mockI18nService.isLocaleSupported.mockReturnValue(false);
await request(app)
.get('/api/i18n/detect')
.expect(200); // This hits the detect route, not the locale route
expect(mockI18nService.isLocaleSupported).not.toHaveBeenCalled();
expect(mockI18nService.detectLocale).toHaveBeenCalled();
});
});
describe('HTTP headers and content type', () => {
it('should set correct content-type for translations', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(true);
mockI18nService.getTranslations.mockReturnValue({ test: 'data' });
const response = await request(app)
.get('/api/i18n/en')
.expect(200);
expect(response.headers['content-type']).toMatch(/application\/json/);
});
it('should set cache headers for translations', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(true);
mockI18nService.getTranslations.mockReturnValue({ test: 'data' });
const response = await request(app)
.get('/api/i18n/en')
.expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=3600');
});
it('should not set cache headers for error responses', async () => {
mockI18nService.isLocaleSupported.mockReturnValue(false);
const response = await request(app)
.get('/api/i18n/invalid')
.expect(400);
expect(response.headers['cache-control']).toBeUndefined();
});
});
});