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:
parent
a6f4830ab8
commit
a537072d3d
3 changed files with 353 additions and 32 deletions
|
@ -17,8 +17,7 @@ module.exports = {
|
|||
'!src/server.ts', // Skip main server as it's integration-focused
|
||||
'!src/frontend/**/*', // Skip frontend files (use DOM types, tested separately)
|
||||
'!src/i18n/**/*', // Skip i18n files (utility functions, tested separately)
|
||||
'!src/services/MapImageService.ts', // Skip map service (requires external API, tested separately)
|
||||
'!src/routes/i18n.ts' // Skip i18n routes (utility endpoints, tested separately)
|
||||
'!src/services/MapImageService.ts' // Skip map service (requires external API, tested separately)
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
|
|
|
@ -4,6 +4,36 @@ import { i18nService } from '../i18n';
|
|||
export function createI18nRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Get available locales and their display names
|
||||
* GET /api/i18n
|
||||
*/
|
||||
router.get('/', (req: Request, res: Response): void => {
|
||||
const locales = i18nService.getAvailableLocales().map(locale => ({
|
||||
code: locale,
|
||||
name: i18nService.getLocaleDisplayName(locale)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
default: i18nService.getDefaultLocale(),
|
||||
available: locales
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect user's preferred locale based on Accept-Language header
|
||||
* GET /api/i18n/detect
|
||||
*/
|
||||
router.get('/detect', (req: Request, res: Response): void => {
|
||||
const acceptLanguage = req.get('Accept-Language');
|
||||
const detectedLocale = i18nService.detectLocale(acceptLanguage);
|
||||
|
||||
res.json({
|
||||
detected: detectedLocale,
|
||||
displayName: i18nService.getLocaleDisplayName(detectedLocale)
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get translations for a specific locale
|
||||
* GET /api/i18n/:locale
|
||||
|
@ -40,36 +70,6 @@ export function createI18nRoutes(): Router {
|
|||
res.json(translations);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get available locales and their display names
|
||||
* GET /api/i18n
|
||||
*/
|
||||
router.get('/', (req: Request, res: Response): void => {
|
||||
const locales = i18nService.getAvailableLocales().map(locale => ({
|
||||
code: locale,
|
||||
name: i18nService.getLocaleDisplayName(locale)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
default: i18nService.getDefaultLocale(),
|
||||
available: locales
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect user's preferred locale based on Accept-Language header
|
||||
* GET /api/i18n/detect
|
||||
*/
|
||||
router.get('/detect', (req: Request, res: Response): void => {
|
||||
const acceptLanguage = req.get('Accept-Language');
|
||||
const detectedLocale = i18nService.detectLocale(acceptLanguage);
|
||||
|
||||
res.json({
|
||||
detected: detectedLocale,
|
||||
displayName: i18nService.getLocaleDisplayName(detectedLocale)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
|
322
tests/integration/routes/i18n.test.ts
Normal file
322
tests/integration/routes/i18n.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue