diff --git a/jest.config.js b/jest.config.js index 10a17f8..e767a46 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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'], diff --git a/src/routes/i18n.ts b/src/routes/i18n.ts index 35ecc5c..555252e 100644 --- a/src/routes/i18n.ts +++ b/src/routes/i18n.ts @@ -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; } diff --git a/tests/integration/routes/i18n.test.ts b/tests/integration/routes/i18n.test.ts new file mode 100644 index 0000000..2a63bd3 --- /dev/null +++ b/tests/integration/routes/i18n.test.ts @@ -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; + +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 = { + '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(); + }); + }); +}); \ No newline at end of file