test(web): add middleware + i18n + messages test suites (GOO-60)
Cover frontend middleware auth/locale logic, i18n config/routing/request/navigation, and vi/en message parity — 91 new tests across 6 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
359
apps/web/__tests__/middleware.spec.ts
Normal file
359
apps/web/__tests__/middleware.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Hoist mock so it's available in the vi.mock factory
|
||||
const { mockIntlMiddleware } = vi.hoisted(() => ({
|
||||
mockIntlMiddleware: vi.fn(() => NextResponse.next()),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl/middleware', () => ({
|
||||
default: vi.fn(() => mockIntlMiddleware),
|
||||
}));
|
||||
|
||||
// Mock routing config
|
||||
vi.mock('@/i18n/routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
import { middleware, config } from '../middleware';
|
||||
|
||||
function createRequest(
|
||||
pathname: string,
|
||||
options?: { cookies?: Record<string, string>; origin?: string },
|
||||
): NextRequest {
|
||||
const origin = options?.origin ?? 'http://localhost:3200';
|
||||
const url = new URL(pathname, origin);
|
||||
const request = new NextRequest(url);
|
||||
if (options?.cookies) {
|
||||
for (const [name, value] of Object.entries(options.cookies)) {
|
||||
request.cookies.set(name, value);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
describe('middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIntlMiddleware.mockReturnValue(NextResponse.next());
|
||||
});
|
||||
|
||||
describe('matcher config', () => {
|
||||
it('exports a valid matcher that excludes api and static routes', () => {
|
||||
expect(config).toBeDefined();
|
||||
expect(config.matcher).toEqual([
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matcher pattern matches normal routes', () => {
|
||||
const pattern = new RegExp(config.matcher[0]);
|
||||
expect(pattern.test('/dashboard')).toBe(true);
|
||||
expect(pattern.test('/login')).toBe(true);
|
||||
expect(pattern.test('/vi/search')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public paths — unauthenticated access', () => {
|
||||
it('allows unauthenticated access to the root path /', () => {
|
||||
const request = createRequest('/');
|
||||
const response = middleware(request);
|
||||
// Should not redirect to login — should pass through to intlMiddleware
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /login', () => {
|
||||
const request = createRequest('/login');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /register', () => {
|
||||
const request = createRequest('/register');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /search', () => {
|
||||
const request = createRequest('/search');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /search/anything', () => {
|
||||
const request = createRequest('/search/filters?type=APARTMENT');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /listings', () => {
|
||||
const request = createRequest('/listings');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /listings/:id', () => {
|
||||
const request = createRequest('/listings/abc-123');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /auth/callback', () => {
|
||||
const request = createRequest('/auth/callback');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /auth/callback/google', () => {
|
||||
const request = createRequest('/auth/callback/google');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth redirect — protected routes require authentication', () => {
|
||||
it('redirects unauthenticated users from /dashboard to /login', () => {
|
||||
const request = createRequest('/dashboard');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users from /profile to /login', () => {
|
||||
const request = createRequest('/profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users from /my-listings to /login with redirect param', () => {
|
||||
const request = createRequest('/my-listings');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/my-listings');
|
||||
});
|
||||
|
||||
it('allows authenticated users to access /dashboard', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access /profile', () => {
|
||||
const request = createRequest('/profile', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-only redirect — authenticated users bounce from login/register', () => {
|
||||
it('redirects authenticated users from /login to /dashboard', () => {
|
||||
const request = createRequest('/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('redirects authenticated users from /register to /dashboard', () => {
|
||||
const request = createRequest('/register', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('does NOT redirect authenticated users from other public paths', () => {
|
||||
const request = createRequest('/search', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale stripping — paths with locale prefix', () => {
|
||||
it('strips /vi prefix and allows public path', () => {
|
||||
const request = createRequest('/vi/login');
|
||||
const response = middleware(request);
|
||||
// /login is public, no redirect to /login (would be self-redirect)
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips /en prefix and allows public path', () => {
|
||||
const request = createRequest('/en/search');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips /vi prefix and redirects protected path to login', () => {
|
||||
const request = createRequest('/vi/dashboard');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('strips /en prefix and redirects protected path to login', () => {
|
||||
const request = createRequest('/en/profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
|
||||
});
|
||||
|
||||
it('strips /vi prefix for auth-only redirect for authenticated users', () => {
|
||||
const request = createRequest('/vi/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('strips /en prefix for auth-only redirect for authenticated users', () => {
|
||||
const request = createRequest('/en/register', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('intl middleware delegation', () => {
|
||||
it('delegates to intlMiddleware for authenticated public paths', () => {
|
||||
const request = createRequest('/search', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('delegates to intlMiddleware for unauthenticated public paths', () => {
|
||||
const request = createRequest('/');
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('delegates to intlMiddleware for authenticated protected paths', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('does NOT delegate to intlMiddleware when redirecting to login', () => {
|
||||
const request = createRequest('/dashboard');
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delegate to intlMiddleware when redirecting to dashboard', () => {
|
||||
const request = createRequest('/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles path with trailing slash', () => {
|
||||
const request = createRequest('/dashboard/');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('handles deeply nested protected path', () => {
|
||||
const request = createRequest('/dashboard/analytics/reports');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe(
|
||||
'/dashboard/analytics/reports',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles root path with locale prefix /vi', () => {
|
||||
const request = createRequest('/vi');
|
||||
const response = middleware(request);
|
||||
// /vi -> stripped to / -> public, no redirect
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles root path with locale prefix /en', () => {
|
||||
const request = createRequest('/en');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat partial locale match as locale prefix', () => {
|
||||
// /video should NOT strip "vi" prefix — "vi" must be followed by / or end
|
||||
const request = createRequest('/video');
|
||||
const response = middleware(request);
|
||||
// /video is not public, so should redirect to login
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('preserves redirect path through query string in login redirect', () => {
|
||||
const request = createRequest('/settings?tab=profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
// The redirect param should contain the stripped path
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/settings');
|
||||
});
|
||||
|
||||
it('handles cookie with empty string value as having the cookie', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: '' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
// cookies.has() returns true even with empty value
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
apps/web/i18n/__tests__/config.spec.ts
Normal file
49
apps/web/i18n/__tests__/config.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { locales, defaultLocale } from '../config';
|
||||
import type { Locale } from '../config';
|
||||
|
||||
describe('i18n/config', () => {
|
||||
describe('locales', () => {
|
||||
it('exports an array of supported locales', () => {
|
||||
expect(Array.isArray(locales)).toBe(true);
|
||||
expect(locales.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('includes Vietnamese (vi) as a supported locale', () => {
|
||||
expect(locales).toContain('vi');
|
||||
});
|
||||
|
||||
it('includes English (en) as a supported locale', () => {
|
||||
expect(locales).toContain('en');
|
||||
});
|
||||
|
||||
it('only contains vi and en', () => {
|
||||
expect([...locales]).toEqual(['vi', 'en']);
|
||||
});
|
||||
|
||||
it('is a readonly tuple', () => {
|
||||
// TypeScript const assertion — runtime check that it behaves as expected
|
||||
const copy = [...locales];
|
||||
expect(copy).toEqual(['vi', 'en']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultLocale', () => {
|
||||
it('is set to Vietnamese (vi)', () => {
|
||||
expect(defaultLocale).toBe('vi');
|
||||
});
|
||||
|
||||
it('is one of the supported locales', () => {
|
||||
expect(locales).toContain(defaultLocale);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locale type', () => {
|
||||
it('accepts valid locale values', () => {
|
||||
const vi: Locale = 'vi';
|
||||
const en: Locale = 'en';
|
||||
expect(vi).toBe('vi');
|
||||
expect(en).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/web/i18n/__tests__/navigation.spec.ts
Normal file
51
apps/web/i18n/__tests__/navigation.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockCreateNavigation } = vi.hoisted(() => ({
|
||||
mockCreateNavigation: vi.fn(() => ({
|
||||
Link: 'MockLink',
|
||||
redirect: 'mockRedirect',
|
||||
usePathname: 'mockUsePathname',
|
||||
useRouter: 'mockUseRouter',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock routing config
|
||||
vi.mock('../routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next-intl/navigation', () => ({
|
||||
createNavigation: mockCreateNavigation,
|
||||
}));
|
||||
|
||||
import { Link, redirect, usePathname, useRouter } from '../navigation';
|
||||
|
||||
describe('i18n/navigation', () => {
|
||||
it('calls createNavigation with the routing config', () => {
|
||||
expect(mockCreateNavigation).toHaveBeenCalledWith({
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
});
|
||||
});
|
||||
|
||||
it('exports Link from createNavigation', () => {
|
||||
expect(Link).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports redirect from createNavigation', () => {
|
||||
expect(redirect).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports usePathname from createNavigation', () => {
|
||||
expect(usePathname).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports useRouter from createNavigation', () => {
|
||||
expect(useRouter).toBeDefined();
|
||||
});
|
||||
});
|
||||
121
apps/web/i18n/__tests__/request.spec.ts
Normal file
121
apps/web/i18n/__tests__/request.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the routing module
|
||||
vi.mock('../routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
// Track getRequestConfig calls
|
||||
let capturedConfigFn: ((params: { requestLocale: Promise<string | undefined> }) => Promise<{
|
||||
locale: string;
|
||||
messages: Record<string, unknown>;
|
||||
}>) | null = null;
|
||||
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getRequestConfig: (fn: typeof capturedConfigFn) => {
|
||||
capturedConfigFn = fn;
|
||||
return fn;
|
||||
},
|
||||
}));
|
||||
|
||||
// Provide message mocks
|
||||
vi.mock('../../messages/vi.json', () => ({
|
||||
default: {
|
||||
common: { goodgo: 'GoodGo', loading: 'Đang tải...' },
|
||||
metadata: { title: 'GoodGo — Nền tảng Bất động sản Việt Nam' },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../messages/en.json', () => ({
|
||||
default: {
|
||||
common: { goodgo: 'GoodGo', loading: 'Loading...' },
|
||||
metadata: { title: 'GoodGo — Vietnam Real Estate Platform' },
|
||||
},
|
||||
}));
|
||||
|
||||
// Import triggers getRequestConfig, which captures the config function
|
||||
await import('../request');
|
||||
|
||||
describe('i18n/request', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('captures a config function via getRequestConfig', () => {
|
||||
expect(capturedConfigFn).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
describe('locale resolution', () => {
|
||||
it('uses the requested locale when it is valid (vi)', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('vi'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('uses the requested locale when it is valid (en)', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('en'),
|
||||
});
|
||||
expect(result.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) when requestLocale is undefined', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve(undefined),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) when requestLocale is empty string', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve(''),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) for an invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('fr'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) for another invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('ja'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message loading', () => {
|
||||
it('loads Vietnamese messages for vi locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('vi'),
|
||||
});
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
|
||||
});
|
||||
|
||||
it('loads English messages for en locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('en'),
|
||||
});
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Loading...');
|
||||
});
|
||||
|
||||
it('loads Vietnamese messages when falling back from invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('fr'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/web/i18n/__tests__/routing.spec.ts
Normal file
53
apps/web/i18n/__tests__/routing.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockDefineRouting } = vi.hoisted(() => ({
|
||||
mockDefineRouting: vi.fn((config: unknown) => config),
|
||||
}));
|
||||
|
||||
// Mock the config module before importing routing
|
||||
vi.mock('../config', () => ({
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi' as const,
|
||||
}));
|
||||
|
||||
// Mock next-intl/routing
|
||||
vi.mock('next-intl/routing', () => ({
|
||||
defineRouting: mockDefineRouting,
|
||||
}));
|
||||
|
||||
import { routing } from '../routing';
|
||||
|
||||
describe('i18n/routing', () => {
|
||||
it('calls defineRouting with the correct configuration', () => {
|
||||
expect(mockDefineRouting).toHaveBeenCalledWith({
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
});
|
||||
});
|
||||
|
||||
it('exports a routing object', () => {
|
||||
expect(routing).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes all supported locales', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
locales: readonly string[];
|
||||
};
|
||||
expect(routingConfig.locales).toEqual(['vi', 'en']);
|
||||
});
|
||||
|
||||
it('sets defaultLocale to vi', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
defaultLocale: string;
|
||||
};
|
||||
expect(routingConfig.defaultLocale).toBe('vi');
|
||||
});
|
||||
|
||||
it('uses as-needed locale prefix strategy', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
localePrefix: string;
|
||||
};
|
||||
expect(routingConfig.localePrefix).toBe('as-needed');
|
||||
});
|
||||
});
|
||||
222
apps/web/messages/__tests__/messages.spec.ts
Normal file
222
apps/web/messages/__tests__/messages.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import viMessages from '../../messages/vi.json';
|
||||
import enMessages from '../../messages/en.json';
|
||||
|
||||
describe('locale messages', () => {
|
||||
describe('structural parity between vi.json and en.json', () => {
|
||||
function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
keys.push(
|
||||
...getKeys(obj[key] as Record<string, unknown>, fullKey),
|
||||
);
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
it('vi.json and en.json have the same top-level namespaces', () => {
|
||||
const viNamespaces = Object.keys(viMessages).sort();
|
||||
const enNamespaces = Object.keys(enMessages).sort();
|
||||
expect(viNamespaces).toEqual(enNamespaces);
|
||||
});
|
||||
|
||||
it('vi.json and en.json have the exact same key structure', () => {
|
||||
const viKeys = getKeys(viMessages as unknown as Record<string, unknown>);
|
||||
const enKeys = getKeys(enMessages as unknown as Record<string, unknown>);
|
||||
expect(viKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it('no keys are missing from en.json', () => {
|
||||
const viKeys = new Set(
|
||||
getKeys(viMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const enKeys = new Set(
|
||||
getKeys(enMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const missingInEn = [...viKeys].filter((k) => !enKeys.has(k));
|
||||
expect(missingInEn).toEqual([]);
|
||||
});
|
||||
|
||||
it('no keys are missing from vi.json', () => {
|
||||
const viKeys = new Set(
|
||||
getKeys(viMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const enKeys = new Set(
|
||||
getKeys(enMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const missingInVi = [...enKeys].filter((k) => !viKeys.has(k));
|
||||
expect(missingInVi).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vietnamese (vi.json) content', () => {
|
||||
it('has a metadata namespace with title', () => {
|
||||
expect(viMessages.metadata).toBeDefined();
|
||||
expect(viMessages.metadata.title).toBe(
|
||||
'GoodGo — Nền tảng Bất động sản Việt Nam',
|
||||
);
|
||||
});
|
||||
|
||||
it('has common namespace with Vietnamese translations', () => {
|
||||
expect(viMessages.common.loading).toBe('Đang tải...');
|
||||
expect(viMessages.common.login).toBe('Đăng nhập');
|
||||
expect(viMessages.common.register).toBe('Đăng ký');
|
||||
expect(viMessages.common.logout).toBe('Đăng xuất');
|
||||
});
|
||||
|
||||
it('has nav namespace with Vietnamese navigation labels', () => {
|
||||
expect(viMessages.nav.home).toBe('Trang chủ');
|
||||
expect(viMessages.nav.search).toBe('Tìm kiếm');
|
||||
expect(viMessages.nav.pricing).toBe('Bảng giá');
|
||||
});
|
||||
|
||||
it('has propertyTypes namespace with all 9 property types in Vietnamese', () => {
|
||||
const types = viMessages.propertyTypes;
|
||||
expect(Object.keys(types)).toHaveLength(9);
|
||||
expect(types.APARTMENT).toBe('Căn hộ');
|
||||
expect(types.HOUSE).toBe('Nhà riêng');
|
||||
expect(types.VILLA).toBe('Biệt thự');
|
||||
expect(types.LAND).toBe('Đất nền');
|
||||
expect(types.OFFICE).toBe('Văn phòng');
|
||||
expect(types.SHOPHOUSE).toBe('Shophouse');
|
||||
expect(types.ROOM_RENTAL).toBe('Phòng trọ');
|
||||
expect(types.CONDOTEL).toBe('Condotel');
|
||||
expect(types.SERVICED_APARTMENT).toBe('Căn hộ dịch vụ');
|
||||
});
|
||||
|
||||
it('has transactionTypes in Vietnamese', () => {
|
||||
expect(viMessages.transactionTypes.SALE).toBe('Bán');
|
||||
expect(viMessages.transactionTypes.RENT).toBe('Cho thuê');
|
||||
});
|
||||
|
||||
it('has language labels including self-referencing Vietnamese label', () => {
|
||||
expect(viMessages.language.label).toBe('Ngôn ngữ');
|
||||
expect(viMessages.language.vi).toBe('Tiếng Việt');
|
||||
expect(viMessages.language.en).toBe('English');
|
||||
});
|
||||
|
||||
it('has auth namespace with login form labels', () => {
|
||||
expect(viMessages.auth.loginTitle).toBe('Đăng nhập');
|
||||
expect(viMessages.auth.phone).toBe('Số điện thoại');
|
||||
expect(viMessages.auth.password).toBe('Mật khẩu');
|
||||
});
|
||||
|
||||
it('has pricing namespace with plan tier labels', () => {
|
||||
expect(viMessages.pricing.tiers.FREE).toBe('Miễn phí');
|
||||
expect(viMessages.pricing.tiers.AGENT_PRO).toBe('Môi giới Pro');
|
||||
expect(viMessages.pricing.tiers.INVESTOR).toBe('Nhà đầu tư');
|
||||
expect(viMessages.pricing.tiers.ENTERPRISE).toBe('Doanh nghiệp');
|
||||
});
|
||||
|
||||
it('has search namespace with filter labels', () => {
|
||||
expect(viMessages.search.filters).toBe('Bộ lọc');
|
||||
expect(viMessages.search.district).toBe('Quận/huyện');
|
||||
expect(viMessages.search.ward).toBe('Phường/xã');
|
||||
});
|
||||
});
|
||||
|
||||
describe('English (en.json) content', () => {
|
||||
it('has a metadata namespace with English title', () => {
|
||||
expect(enMessages.metadata.title).toBe(
|
||||
'GoodGo — Vietnam Real Estate Platform',
|
||||
);
|
||||
});
|
||||
|
||||
it('has common namespace with English translations', () => {
|
||||
expect(enMessages.common.loading).toBe('Loading...');
|
||||
expect(enMessages.common.login).toBe('Login');
|
||||
expect(enMessages.common.register).toBe('Register');
|
||||
expect(enMessages.common.logout).toBe('Logout');
|
||||
});
|
||||
|
||||
it('has propertyTypes in English', () => {
|
||||
expect(enMessages.propertyTypes.APARTMENT).toBe('Apartment');
|
||||
expect(enMessages.propertyTypes.HOUSE).toBe('House');
|
||||
expect(enMessages.propertyTypes.VILLA).toBe('Villa');
|
||||
});
|
||||
|
||||
it('has transactionTypes in English', () => {
|
||||
expect(enMessages.transactionTypes.SALE).toBe('Sale');
|
||||
expect(enMessages.transactionTypes.RENT).toBe('Rent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolation placeholders', () => {
|
||||
it('vi.json common.errorCode contains {code} placeholder', () => {
|
||||
expect(viMessages.common.errorCode).toContain('{code}');
|
||||
});
|
||||
|
||||
it('en.json common.errorCode contains {code} placeholder', () => {
|
||||
expect(enMessages.common.errorCode).toContain('{code}');
|
||||
});
|
||||
|
||||
it('vi.json common.retriedCount contains {count} placeholder', () => {
|
||||
expect(viMessages.common.retriedCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('en.json common.retriedCount contains {count} placeholder', () => {
|
||||
expect(enMessages.common.retriedCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('vi.json search.bedroomsCount contains {count} placeholder', () => {
|
||||
expect(viMessages.search.bedroomsCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('en.json search.bedroomsCount contains {count} placeholder', () => {
|
||||
expect(enMessages.search.bedroomsCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('vi.json and en.json pricing.currentPlanBadge both contain {plan}', () => {
|
||||
expect(viMessages.pricing.currentPlanBadge).toContain('{plan}');
|
||||
expect(enMessages.pricing.currentPlanBadge).toContain('{plan}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no empty values', () => {
|
||||
function findEmptyValues(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): string[] {
|
||||
const empty: string[] = [];
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
empty.push(
|
||||
...findEmptyValues(value as Record<string, unknown>, fullKey),
|
||||
);
|
||||
} else if (typeof value === 'string' && value.trim() === '') {
|
||||
empty.push(fullKey);
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
it('vi.json has no empty string values', () => {
|
||||
const emptyKeys = findEmptyValues(
|
||||
viMessages as unknown as Record<string, unknown>,
|
||||
);
|
||||
expect(emptyKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('en.json has no empty string values', () => {
|
||||
const emptyKeys = findEmptyValues(
|
||||
enMessages as unknown as Record<string, unknown>,
|
||||
);
|
||||
expect(emptyKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint src/ app/ components/ lib/ hooks/ i18n/ --no-error-on-unmatched-pattern",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -44,6 +45,7 @@
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/coverage-v8": "^4.1.3",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"msw": "^2.13.2",
|
||||
|
||||
@@ -10,6 +10,38 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
|
||||
reportsDirectory: './coverage',
|
||||
include: [
|
||||
'app/**/*.ts',
|
||||
'app/**/*.tsx',
|
||||
'components/**/*.ts',
|
||||
'components/**/*.tsx',
|
||||
'lib/**/*.ts',
|
||||
'hooks/**/*.ts',
|
||||
],
|
||||
exclude: [
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'**/node_modules/**',
|
||||
'app/**/layout.tsx',
|
||||
'app/**/loading.tsx',
|
||||
'app/**/error.tsx',
|
||||
'app/**/not-found.tsx',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 60,
|
||||
branches: 50,
|
||||
functions: 60,
|
||||
lines: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user