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

@@ -64,8 +64,24 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Test with coverage
run: pnpm test:coverage
- name: Upload API coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-api
path: apps/api/coverage/
retention-days: 14
- name: Upload Web coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-web
path: apps/web/coverage/
retention-days: 14
- name: Build
run: pnpm build
@@ -145,6 +161,29 @@ jobs:
print("OpenAPI paths OK:", sorted(paths.keys()))
PY
- name: Setup Node.js for contract drift check
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm for contract drift check
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install JS deps for contract drift check
working-directory: .
run: pnpm install --frozen-lockfile
- name: AI sidecar OpenAPI contract drift check
env:
AI_CORS_ORIGINS: http://localhost:3000
# Tell the drift script which Python interpreter to use; libs/ai-services
# has no .venv in CI, so we rely on the system python where deps are installed.
PYTHON: python
working-directory: .
run: node libs/ai-contract/scripts/check-drift.mjs
e2e:
name: E2E Tests
needs: ci

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

View File

@@ -26,6 +26,7 @@
"build": "turbo run build",
"lint": "eslint .",
"test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"typecheck": "turbo run typecheck",
"format": "prettier --write .",
"format:check": "prettier --check .",

339
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.1026.0
version: 3.1026.0
'@goodgo/ai-contract':
specifier: workspace:*
version: link:../../libs/ai-contract
'@goodgo/mcp-servers':
specifier: workspace:*
version: link:../../libs/mcp-servers
@@ -168,9 +171,6 @@ importers:
class-validator:
specifier: ^0.15.1
version: 0.15.1
cockatiel:
specifier: ^3.2.1
version: 3.2.1
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
@@ -286,6 +286,9 @@ importers:
'@types/supertest':
specifier: ^7.2.0
version: 7.2.0
'@vitest/coverage-v8':
specifier: ^4.1.3
version: 4.1.3(vitest@4.1.3)
prisma:
specifier: ^7.7.0
version: 7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2)
@@ -297,7 +300,7 @@ importers:
version: 6.0.2
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
apps/web:
dependencies:
@@ -395,6 +398,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/coverage-v8':
specifier: ^4.1.3
version: 4.1.3(vitest@4.1.3)
autoprefixer:
specifier: ^10.4.0
version: 10.4.27(postcss@8.5.8)
@@ -418,7 +424,16 @@ importers:
version: 6.0.2
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
libs/ai-contract:
devDependencies:
openapi-typescript:
specifier: ^7.4.4
version: 7.13.0(typescript@6.0.2)
typescript:
specifier: ^6.0.2
version: 6.0.2
libs/mcp-servers:
dependencies:
@@ -446,7 +461,7 @@ importers:
version: 6.0.2
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -749,6 +764,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
@@ -2343,6 +2362,16 @@ packages:
'@types/react':
optional: true
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.12':
resolution: {integrity: sha512-b32XWsz6enN6K4bx8xWsqUaXTJR/DnYT3lL1CzDYzIYKw243NNlz6fexmr71q/U4HrEcMoJGBvwAfcxOb8ymQw==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -3477,6 +3506,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/coverage-v8@4.1.3':
resolution: {integrity: sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==}
peerDependencies:
'@vitest/browser': 4.1.3
vitest: 4.1.3
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.3':
resolution: {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==}
@@ -3723,6 +3761,9 @@ packages:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
@@ -3864,6 +3905,9 @@ packages:
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
@@ -3947,6 +3991,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
@@ -4046,10 +4093,6 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
cockatiel@3.2.1:
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
engines: {node: '>=16'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -4057,6 +4100,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -5023,6 +5069,9 @@ packages:
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
@@ -5104,6 +5153,10 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -5213,6 +5266,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
@@ -5239,6 +5304,13 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -5464,6 +5536,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mapbox-gl@3.21.0:
resolution: {integrity: sha512-0mv/LHDoW6QmxLEoNqCPuby428WTekfs38TtNXd/cxDOLdY6kFd/ztaSkcBeqNksbYSz2lXnPfBm8nN5+hxA0w==}
@@ -5553,6 +5632,10 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -5785,6 +5868,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -5838,6 +5927,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
@@ -6703,6 +6796,10 @@ packages:
resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
engines: {node: '>=14.18.0'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -6909,6 +7006,10 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-fest@5.5.0:
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
engines: {node: '>=20'}
@@ -6997,6 +7098,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -7293,6 +7397,9 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
@@ -7901,7 +8008,7 @@ snapshots:
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -7985,7 +8092,7 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -7994,6 +8101,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.2.2': {}
'@bramus/specificity@2.4.2':
@@ -8141,7 +8250,7 @@ snapshots:
'@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -8157,7 +8266,7 @@ snapshots:
'@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.14.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -9490,7 +9599,7 @@ snapshots:
'@puppeteer/browsers@2.13.0':
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -9559,6 +9668,29 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.28
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.12(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -10364,7 +10496,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -10706,7 +10838,7 @@ snapshots:
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.2
transitivePeerDependencies:
@@ -10716,7 +10848,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
@@ -10735,7 +10867,7 @@ snapshots:
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2
@@ -10750,7 +10882,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.16
@@ -10846,6 +10978,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.3(vitest@4.1.3)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.3
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.3':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -11023,7 +11169,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -11116,6 +11262,12 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-retry@1.3.3:
dependencies:
retry: 0.13.1
@@ -11221,7 +11373,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -11249,6 +11401,10 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -11348,6 +11504,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
change-case@5.4.4: {}
chardet@2.1.1: {}
chart.js@4.5.1:
@@ -11447,14 +11605,14 @@ snapshots:
cluster-key-slot@1.1.2: {}
cockatiel@3.2.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
colorette@1.4.0: {}
colorette@2.0.20: {}
combined-stream@1.0.8:
@@ -11625,9 +11783,11 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.3:
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {}
@@ -11778,7 +11938,7 @@ snapshots:
engine.io-client@6.6.4:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
engine.io-parser: 5.2.3
ws: 8.18.3
xmlhttprequest-ssl: 2.1.2
@@ -11798,7 +11958,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
engine.io-parser: 5.2.3
ws: 8.18.3
transitivePeerDependencies:
@@ -11904,7 +12064,7 @@ snapshots:
eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
get-tsconfig: 4.13.7
@@ -11922,7 +12082,7 @@ snapshots:
'@package-json/types': 0.0.12
'@typescript-eslint/types': 8.58.0
comment-parser: 1.4.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
@@ -11968,7 +12128,7 @@ snapshots:
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -12058,7 +12218,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -12089,7 +12249,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -12190,7 +12350,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -12317,7 +12477,7 @@ snapshots:
gaxios@6.7.1:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-stream: 2.0.1
node-fetch: 2.7.0
uuid: 9.0.1
@@ -12329,7 +12489,7 @@ snapshots:
gaxios@7.1.4:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@10.2.2)
node-fetch: 3.3.2
transitivePeerDependencies:
- supports-color
@@ -12396,7 +12556,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.0
data-uri-to-buffer: 6.0.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -12542,6 +12702,8 @@ snapshots:
html-entities@2.6.0:
optional: true
html-escaper@2.0.2: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
@@ -12568,7 +12730,7 @@ snapshots:
dependencies:
'@tootallnate/once': 3.0.1
agent-base: 6.0.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
optional: true
@@ -12576,7 +12738,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -12585,14 +12747,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -12639,6 +12801,8 @@ snapshots:
indent-string@4.0.0: {}
index-to-position@1.2.0: {}
inherits@2.0.4: {}
ini@4.1.1: {}
@@ -12659,7 +12823,7 @@ snapshots:
dependencies:
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
@@ -12731,6 +12895,19 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterare@1.2.1: {}
jest-worker@27.5.1:
@@ -12749,6 +12926,10 @@ snapshots:
joycon@3.1.1: {}
js-levenshtein@1.1.6: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -12847,7 +13028,7 @@ snapshots:
jwks-rsa@3.2.2:
dependencies:
'@types/jsonwebtoken': 9.0.10
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
jose: 4.15.9
limiter: 1.1.5
lru-memoizer: 2.3.0
@@ -12993,6 +13174,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
mapbox-gl@3.21.0:
dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.2
@@ -13082,6 +13273,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.13
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.0
minimist@1.2.8: {}
minipass@7.1.3: {}
@@ -13310,6 +13505,16 @@ snapshots:
dependencies:
mimic-function: 5.0.1
openapi-typescript@7.13.0(typescript@6.0.2):
dependencies:
'@redocly/openapi-core': 1.34.12(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 6.0.2
yargs-parser: 21.1.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -13364,10 +13569,10 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
get-uri: 6.0.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@10.2.2)
pac-resolver: 7.0.1
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
@@ -13391,6 +13596,12 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.0
index-to-position: 1.2.0
type-fest: 4.41.0
parse-srcset@1.0.2: {}
parse5@8.0.0:
@@ -13710,9 +13921,9 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@10.2.2)
lru-cache: 7.18.3
pac-proxy-agent: 7.2.0
proxy-from-env: 1.1.0
@@ -13735,7 +13946,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.0
chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872)
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
devtools-protocol: 0.0.1595872
typed-query-selector: 2.12.1
webdriver-bidi-protocol: 0.4.1
@@ -13905,7 +14116,7 @@ snapshots:
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
@@ -13997,7 +14208,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -14065,7 +14276,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -14182,7 +14393,7 @@ snapshots:
socket.io-adapter@2.5.6:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
@@ -14192,7 +14403,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
engine.io-client: 6.6.4
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -14203,7 +14414,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -14212,7 +14423,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
engine.io: 6.6.6
socket.io-adapter: 2.5.6
socket.io-parser: 4.2.6
@@ -14224,7 +14435,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
socks: 2.8.7
transitivePeerDependencies:
- supports-color
@@ -14371,7 +14582,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@10.2.2)
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 3.5.4
@@ -14393,6 +14604,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -14645,6 +14858,8 @@ snapshots:
type-fest@0.7.1: {}
type-fest@4.41.0: {}
type-fest@5.5.0:
dependencies:
tagged-tag: 1.0.0
@@ -14741,6 +14956,8 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uri-js-replace@1.0.1: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -14829,7 +15046,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.3
'@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -14854,11 +15071,12 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.5.2
'@vitest/coverage-v8': 4.1.3(vitest@4.1.3)
jsdom: 29.0.2(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.3
'@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -14883,6 +15101,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.5.2
'@vitest/coverage-v8': 4.1.3(vitest@4.1.3)
jsdom: 29.0.2(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
@@ -15062,6 +15281,8 @@ snapshots:
yallist@4.0.0: {}
yaml-ast-parser@0.0.43: {}
yaml@2.8.3: {}
yargs-parser@18.1.3:

View File

@@ -15,6 +15,10 @@
"test": {
"dependsOn": ["^build"]
},
"test:coverage": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"typecheck": {
"dependsOn": ["^build"]
}