- 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>
322 lines
No EOL
10 KiB
TypeScript
322 lines
No EOL
10 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
}); |