From 0924f0cb9bd0486d8c9ae294d4801e8e9b9a77fb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 10:47:30 +0700 Subject: [PATCH] test(web): add middleware + i18n + messages test suites (GOO-60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 43 ++- apps/api/package.json | 3 + .../listing-expiry-cron.service.spec.ts | 115 ++++++ apps/api/vitest.config.ts | 23 ++ apps/web/__tests__/middleware.spec.ts | 359 ++++++++++++++++++ apps/web/i18n/__tests__/config.spec.ts | 49 +++ apps/web/i18n/__tests__/navigation.spec.ts | 51 +++ apps/web/i18n/__tests__/request.spec.ts | 121 ++++++ apps/web/i18n/__tests__/routing.spec.ts | 53 +++ apps/web/messages/__tests__/messages.spec.ts | 222 +++++++++++ apps/web/package.json | 2 + apps/web/vitest.config.ts | 32 ++ package.json | 1 + pnpm-lock.yaml | 339 ++++++++++++++--- turbo.json | 4 + 15 files changed, 1356 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts create mode 100644 apps/web/__tests__/middleware.spec.ts create mode 100644 apps/web/i18n/__tests__/config.spec.ts create mode 100644 apps/web/i18n/__tests__/navigation.spec.ts create mode 100644 apps/web/i18n/__tests__/request.spec.ts create mode 100644 apps/web/i18n/__tests__/routing.spec.ts create mode 100644 apps/web/messages/__tests__/messages.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43a8ebb..f3f4872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index b644ef4..39bd3b8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts b/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts new file mode 100644 index 0000000..3e53858 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts @@ -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 }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: { + log: ReturnType; + debug: ReturnType; + error: ReturnType; + }; + + 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); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 84143bd..1d64735 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -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: { diff --git a/apps/web/__tests__/middleware.spec.ts b/apps/web/__tests__/middleware.spec.ts new file mode 100644 index 0000000..ce141ea --- /dev/null +++ b/apps/web/__tests__/middleware.spec.ts @@ -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; 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(); + }); + }); +}); diff --git a/apps/web/i18n/__tests__/config.spec.ts b/apps/web/i18n/__tests__/config.spec.ts new file mode 100644 index 0000000..ec6c24e --- /dev/null +++ b/apps/web/i18n/__tests__/config.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/web/i18n/__tests__/navigation.spec.ts b/apps/web/i18n/__tests__/navigation.spec.ts new file mode 100644 index 0000000..c9389c4 --- /dev/null +++ b/apps/web/i18n/__tests__/navigation.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/i18n/__tests__/request.spec.ts b/apps/web/i18n/__tests__/request.spec.ts new file mode 100644 index 0000000..d44b005 --- /dev/null +++ b/apps/web/i18n/__tests__/request.spec.ts @@ -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 }) => Promise<{ + locale: string; + messages: Record; +}>) | 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>).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>).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>).common.loading).toBe('Đang tải...'); + }); + }); +}); diff --git a/apps/web/i18n/__tests__/routing.spec.ts b/apps/web/i18n/__tests__/routing.spec.ts new file mode 100644 index 0000000..6ceb61a --- /dev/null +++ b/apps/web/i18n/__tests__/routing.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/messages/__tests__/messages.spec.ts b/apps/web/messages/__tests__/messages.spec.ts new file mode 100644 index 0000000..64ad673 --- /dev/null +++ b/apps/web/messages/__tests__/messages.spec.ts @@ -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, 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, 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); + const enKeys = getKeys(enMessages as unknown as Record); + expect(viKeys).toEqual(enKeys); + }); + + it('no keys are missing from en.json', () => { + const viKeys = new Set( + getKeys(viMessages as unknown as Record), + ); + const enKeys = new Set( + getKeys(enMessages as unknown as Record), + ); + 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), + ); + const enKeys = new Set( + getKeys(enMessages as unknown as Record), + ); + 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, + 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, 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, + ); + expect(emptyKeys).toEqual([]); + }); + + it('en.json has no empty string values', () => { + const emptyKeys = findEmptyValues( + enMessages as unknown as Record, + ); + expect(emptyKeys).toEqual([]); + }); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 7bbaa8a..2159bf5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 0d1d000..007a8e1 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -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: { diff --git a/package.json b/package.json index e327c10..fdd9efb 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc66fff..f195f34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/turbo.json b/turbo.json index f3f6c29..5ea23f6 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,10 @@ "test": { "dependsOn": ["^build"] }, + "test:coverage": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, "typecheck": { "dependsOn": ["^build"] }