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>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|