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:
Ho Ngoc Hai
2026-04-23 10:47:30 +07:00
parent 7a854373b3
commit 0924f0cb9b
15 changed files with 1356 additions and 61 deletions

View File

@@ -9,6 +9,7 @@
"start:prod": "node dist/main",
"lint": "eslint src/",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit"
},
@@ -16,6 +17,7 @@
"@anthropic-ai/sdk": "^0.89.0",
"@aws-sdk/client-s3": "^3.1026.0",
"@aws-sdk/s3-request-presigner": "^3.1026.0",
"@goodgo/ai-contract": "workspace:*",
"@goodgo/mcp-servers": "workspace:*",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/bullmq": "^11.0.4",
@@ -84,6 +86,7 @@
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.3",
"prisma": "^7.7.0",
"supertest": "^7.2.2",
"typescript": "^6.0.2",

View File

@@ -0,0 +1,115 @@
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
import { ListingExpiryCronService } from '../../infrastructure/cron/listing-expiry-cron.service';
describe('ListingExpiryCronService', () => {
let service: ListingExpiryCronService;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
mockEventBus = { publish: vi.fn() };
mockLogger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() };
service = new ListingExpiryCronService(
mockPrisma as any,
mockEventBus as any,
mockLogger as any,
);
});
it('publishes ListingExpiringEvent for each expiring listing and logs the count', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-a', expiresAt },
{ id: 'listing-b', sellerId: 'seller-b', expiresAt },
]);
await service.notifyExpiringListings();
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledTimes(2);
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0]).toBeInstanceOf(ListingExpiringEvent);
expect(events[1]).toBeInstanceOf(ListingExpiringEvent);
expect(events.map((e) => e.aggregateId).sort()).toEqual(['listing-a', 'listing-b']);
expect(events[0].eventName).toBe('listing.expiring');
expect(events[0].sellerId).toBe('seller-a');
expect(events[0].expiresAt).toBe(expiresAt);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('2 listing(s)'),
'ListingExpiryCronService',
);
});
it('is a no-op when no listings are expiring (idempotent across runs)', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await service.notifyExpiringListings();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.log).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
'No listings expiring in the next 3 days — nothing to notify',
'ListingExpiryCronService',
);
});
it('catches DB errors and logs them without throwing', async () => {
const boom = new Error('connection lost');
mockPrisma.$queryRaw.mockRejectedValue(boom);
await expect(service.notifyExpiringListings()).resolves.toBeUndefined();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('connection lost'),
expect.any(String),
'ListingExpiryCronService',
);
});
it('uses a single atomic UPDATE ... RETURNING so concurrent runs do not double-notify', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
// Simulate two parallel runs against the same DB; only the first sees the row.
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ id: 'listing-1', sellerId: 'seller-1', expiresAt }])
.mockResolvedValueOnce([]);
await Promise.all([
service.notifyExpiringListings(),
service.notifyExpiringListings(),
]);
// Only one event published across both runs.
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('publishes events with correct sellerId and expiresAt per row', async () => {
const expiresAtA = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000);
const expiresAtB = new Date(Date.now() + 2.5 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-x', expiresAt: expiresAtA },
{ id: 'listing-b', sellerId: 'seller-y', expiresAt: expiresAtB },
]);
await service.notifyExpiringListings();
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0].aggregateId).toBe('listing-a');
expect(events[0].sellerId).toBe('seller-x');
expect(events[0].expiresAt).toBe(expiresAtA);
expect(events[1].aggregateId).toBe('listing-b');
expect(events[1].sellerId).toBe('seller-y');
expect(events[1].expiresAt).toBe(expiresAtB);
});
});

View File

@@ -10,6 +10,29 @@ export default defineConfig({
env: {
BCRYPT_ROUNDS: '4',
},
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
reportsDirectory: './coverage',
include: ['src/modules/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.integration.spec.ts',
'src/**/*.mock.ts',
'src/**/*.module.ts',
'src/**/index.ts',
'src/**/__tests__/**',
'src/**/__mocks__/**',
'src/**/dto/**',
'node_modules',
],
thresholds: {
statements: 60,
branches: 50,
functions: 60,
lines: 60,
},
},
},
resolve: {
alias: {

View 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();
});
});
});

View 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');
});
});
});

View 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();
});
});

View 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...');
});
});
});

View 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');
});
});

View 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([]);
});
});
});

View File

@@ -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",

View File

@@ -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: {