Compare commits
5 Commits
7a854373b3
...
9e70074403
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e70074403 | ||
|
|
54c97daa5b | ||
|
|
cc851d7e5f | ||
|
|
e706f4ddf2 | ||
|
|
0924f0cb9b |
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('PrismaAVMService', () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
|
||||
'Property not found: non-existent',
|
||||
"Property with id 'non-existent' not found",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,194 +1,67 @@
|
||||
import {
|
||||
AiRoutes,
|
||||
type AVMPredictRequest,
|
||||
type AVMPredictResponse,
|
||||
type AVMv2PredictRequest,
|
||||
type AVMv2PredictResponse,
|
||||
type AVMv2Comparable,
|
||||
type AVMv2FeatureImportance,
|
||||
type IndustrialAVMRequest,
|
||||
type IndustrialAVMResponse,
|
||||
type IndustrialComparable,
|
||||
type IndustrialFeatureImportance,
|
||||
type ModerationRequest,
|
||||
type ModerationResponse,
|
||||
type ModerationFlag,
|
||||
type NeighborhoodPOICounts,
|
||||
type NeighborhoodScoreRequest,
|
||||
type NeighborhoodScoreResponse,
|
||||
} from '@goodgo/ai-contract';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires the runtime import so emitDecoratorMetadata can see the class (see project MEMORY.md)
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
|
||||
export interface AiPredictRequest {
|
||||
area: number;
|
||||
district: string;
|
||||
city: string;
|
||||
property_type: string;
|
||||
bedrooms?: number;
|
||||
bathrooms?: number;
|
||||
floors?: number;
|
||||
frontage?: number;
|
||||
road_width?: number;
|
||||
year_built?: number | null;
|
||||
has_legal_paper?: boolean;
|
||||
}
|
||||
|
||||
export interface AiPredictResponse {
|
||||
estimated_price_vnd: number;
|
||||
confidence: number;
|
||||
price_per_m2: number;
|
||||
price_range_low: number;
|
||||
price_range_high: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AVM v2 request — extended feature set for residential ensemble.
|
||||
* Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py.
|
||||
/*
|
||||
* The DTO names below are aliases over `@goodgo/ai-contract` (auto-generated
|
||||
* from the FastAPI OpenAPI schema at libs/ai-services). They are kept to
|
||||
* preserve the existing public surface for callers under apps/api; the
|
||||
* underlying shapes come from the contract package so a schema change in
|
||||
* Python surfaces as a TypeScript compile error here.
|
||||
*
|
||||
* Do not hand-edit these aliases to diverge from the generated types. If
|
||||
* the FastAPI schema changes, refresh the contract:
|
||||
*
|
||||
* pnpm --filter @goodgo/ai-contract export:openapi
|
||||
* pnpm --filter @goodgo/ai-contract generate
|
||||
*/
|
||||
export interface AiPredictV2Request {
|
||||
district: string;
|
||||
city: string;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
distance_to_cbd_km?: number;
|
||||
distance_to_metro_km?: number;
|
||||
distance_to_school_km?: number;
|
||||
distance_to_hospital_km?: number;
|
||||
distance_to_park_km?: number;
|
||||
distance_to_mall_km?: number;
|
||||
flood_zone_risk?: number;
|
||||
neighborhood_score?: number;
|
||||
rooms?: number;
|
||||
floor_level?: number;
|
||||
total_floors?: number;
|
||||
direction?: string;
|
||||
floor_ratio?: number;
|
||||
building_age_years?: number;
|
||||
has_elevator?: boolean;
|
||||
has_parking?: boolean;
|
||||
has_pool?: boolean;
|
||||
has_legal_paper?: boolean;
|
||||
developer_reputation?: number;
|
||||
avg_price_district_3m_vnd_m2?: number;
|
||||
listing_density?: number;
|
||||
absorption_rate?: number;
|
||||
dom_avg?: number;
|
||||
price_momentum_30d?: number;
|
||||
yoy_change?: number;
|
||||
renovation_score?: number;
|
||||
view_quality?: number;
|
||||
interior_quality?: number;
|
||||
noise_level?: number;
|
||||
natural_light?: number;
|
||||
month?: number;
|
||||
quarter?: number;
|
||||
is_year_end?: boolean;
|
||||
}
|
||||
|
||||
export interface AiPredictV2FeatureImportance {
|
||||
feature: string;
|
||||
importance: number;
|
||||
}
|
||||
// --- AVM v1 (legacy) ------------------------------------------------------
|
||||
export type AiPredictRequest = AVMPredictRequest;
|
||||
export type AiPredictResponse = AVMPredictResponse;
|
||||
|
||||
export interface AiPredictV2Comparable {
|
||||
district: string;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
price_vnd: number;
|
||||
price_per_m2_vnd: number;
|
||||
similarity_score: number;
|
||||
}
|
||||
// --- AVM v2 (residential ensemble) ---------------------------------------
|
||||
export type AiPredictV2Request = AVMv2PredictRequest;
|
||||
export type AiPredictV2Response = AVMv2PredictResponse;
|
||||
export type AiPredictV2FeatureImportance = AVMv2FeatureImportance;
|
||||
export type AiPredictV2Comparable = AVMv2Comparable;
|
||||
|
||||
export interface AiPredictV2Response {
|
||||
estimated_price_vnd: number;
|
||||
confidence: number;
|
||||
price_per_m2_vnd: number;
|
||||
price_range_low_vnd: number;
|
||||
price_range_high_vnd: number;
|
||||
drivers?: AiPredictV2FeatureImportance[];
|
||||
comparables?: AiPredictV2Comparable[];
|
||||
model_version?: string;
|
||||
ensemble_method?: string;
|
||||
}
|
||||
// --- Industrial AVM -------------------------------------------------------
|
||||
export type AiIndustrialPredictRequest = IndustrialAVMRequest;
|
||||
export type AiIndustrialPredictResponse = IndustrialAVMResponse;
|
||||
export type AiIndustrialComparable = IndustrialComparable;
|
||||
export type AiIndustrialFeatureImportance = IndustrialFeatureImportance;
|
||||
|
||||
export interface AiIndustrialPredictRequest {
|
||||
province: string;
|
||||
region: string;
|
||||
park_occupancy_rate: number;
|
||||
park_area_ha: number;
|
||||
park_age_years: number;
|
||||
distance_to_port_km: number;
|
||||
distance_to_airport_km: number;
|
||||
distance_to_highway_km: number;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
ceiling_height_m?: number;
|
||||
floor_load_ton_m2?: number;
|
||||
power_capacity_kva?: number;
|
||||
building_coverage?: number;
|
||||
loading_docks?: number;
|
||||
zoning?: string;
|
||||
industry_demand_index?: number;
|
||||
fdi_province_musd?: number;
|
||||
labor_cost_province_vnd?: number;
|
||||
logistics_connectivity_score?: number;
|
||||
}
|
||||
// --- Moderation -----------------------------------------------------------
|
||||
export type AiModerationRequest = ModerationRequest;
|
||||
export type AiModerationResponse = ModerationResponse;
|
||||
export type AiModerationFlag = ModerationFlag;
|
||||
|
||||
export interface AiIndustrialComparable {
|
||||
park_name: string;
|
||||
province: string;
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
rent_usd_m2: number;
|
||||
similarity_score: number;
|
||||
}
|
||||
|
||||
export interface AiIndustrialFeatureImportance {
|
||||
feature: string;
|
||||
importance: number;
|
||||
}
|
||||
|
||||
export interface AiIndustrialPredictResponse {
|
||||
estimated_rent_usd_m2: number;
|
||||
confidence: number;
|
||||
rent_range_low_usd_m2: number;
|
||||
rent_range_high_usd_m2: number;
|
||||
annual_rent_usd_m2: number;
|
||||
total_monthly_rent_usd: number;
|
||||
comparables: AiIndustrialComparable[];
|
||||
drivers: AiIndustrialFeatureImportance[];
|
||||
model_version: string;
|
||||
}
|
||||
|
||||
export interface AiModerationRequest {
|
||||
text: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export interface AiModerationFlag {
|
||||
category: string;
|
||||
severity: string;
|
||||
matched_text: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AiModerationResponse {
|
||||
is_flagged: boolean;
|
||||
score: number;
|
||||
flags: AiModerationFlag[];
|
||||
cleaned_text: string | null;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodPOICounts {
|
||||
education: number;
|
||||
healthcare: number;
|
||||
transport: number;
|
||||
shopping: number;
|
||||
greenery: number;
|
||||
safety: number;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreRequest {
|
||||
district: string;
|
||||
city: string;
|
||||
poi_counts: AiNeighborhoodPOICounts;
|
||||
}
|
||||
|
||||
export interface AiNeighborhoodScoreResponse {
|
||||
district: string;
|
||||
city: string;
|
||||
education_score: number;
|
||||
healthcare_score: number;
|
||||
transport_score: number;
|
||||
shopping_score: number;
|
||||
greenery_score: number;
|
||||
safety_score: number;
|
||||
total_score: number;
|
||||
poi_counts: Record<string, number>;
|
||||
algorithm_version: string;
|
||||
}
|
||||
// --- Neighborhood scoring -------------------------------------------------
|
||||
export type AiNeighborhoodPOICounts = NeighborhoodPOICounts;
|
||||
export type AiNeighborhoodScoreRequest = NeighborhoodScoreRequest;
|
||||
export type AiNeighborhoodScoreResponse = NeighborhoodScoreResponse;
|
||||
|
||||
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
|
||||
|
||||
@@ -214,30 +87,30 @@ export class AiServiceClient implements IAiServiceClient {
|
||||
}
|
||||
|
||||
async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
|
||||
return this.post<AiPredictResponse>('/avm/predict', req);
|
||||
return this.post<AiPredictResponse>(AiRoutes.avmPredict, req);
|
||||
}
|
||||
|
||||
async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> {
|
||||
return this.post<AiPredictV2Response>('/avm/v2/predict', req);
|
||||
return this.post<AiPredictV2Response>(AiRoutes.avmV2Predict, req);
|
||||
}
|
||||
|
||||
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
|
||||
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
|
||||
return this.post<AiIndustrialPredictResponse>(AiRoutes.avmIndustrialPredict, req);
|
||||
}
|
||||
|
||||
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
|
||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||
return this.post<AiModerationResponse>(AiRoutes.moderationCheck, req);
|
||||
}
|
||||
|
||||
async scoreNeighborhood(
|
||||
req: AiNeighborhoodScoreRequest,
|
||||
): Promise<AiNeighborhoodScoreResponse> {
|
||||
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
|
||||
return this.post<AiNeighborhoodScoreResponse>(AiRoutes.neighborhoodScore, req);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
const response = await fetch(`${this.baseUrl}${AiRoutes.health}`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
@@ -265,7 +138,10 @@ export class AiServiceClient implements IAiServiceClient {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`AI service ${path} returned ${response.status}: ${text}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_PROVIDER_ERROR,
|
||||
`AI service ${path} returned ${response.status}: ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
|
||||
@Injectable()
|
||||
export class AvmRetrainCronService {
|
||||
@@ -101,7 +103,7 @@ export class AvmRetrainCronService {
|
||||
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
|
||||
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
|
||||
CASE
|
||||
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
|
||||
WHEN p."legalStatus"::text IN ('SO_DO', 'SO_HONG') THEN 1.0
|
||||
ELSE 0.0
|
||||
END AS has_legal_paper,
|
||||
0.5 AS developer_reputation,
|
||||
@@ -206,7 +208,10 @@ export class AvmRetrainCronService {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Training data upload failed (${response.status}): ${text}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_PROVIDER_ERROR,
|
||||
`Training data upload failed (${response.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
@@ -235,7 +240,10 @@ export class AvmRetrainCronService {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Retrain request failed (${response.status}): ${text}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_PROVIDER_ERROR,
|
||||
`Retrain request failed (${response.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<RetrainResult>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
import {
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
@@ -113,7 +115,10 @@ export class PrismaAVMService implements IAVMService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided');
|
||||
throw new DomainException(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'Either propertyId or (latitude, longitude, areaM2) must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
|
||||
@@ -127,7 +132,7 @@ export class PrismaAVMService implements IAVMService {
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error(`Property not found: ${propertyId}`);
|
||||
if (!row) throw new NotFoundException('Property', propertyId);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { DomainException, ErrorCode } from '@modules/shared';
|
||||
import {
|
||||
MEDIA_STORAGE_SERVICE,
|
||||
MinioMediaStorageService,
|
||||
@@ -18,6 +19,7 @@ import { ResetPasswordHandler } from './application/commands/reset-password/rese
|
||||
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
||||
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||
import { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.handler';
|
||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||
@@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h
|
||||
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
||||
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
|
||||
import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler';
|
||||
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
||||
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
||||
@@ -54,6 +57,8 @@ const CommandHandlers = [
|
||||
RegisterUserHandler,
|
||||
LoginUserHandler,
|
||||
RefreshTokenHandler,
|
||||
RequestPhoneOtpHandler,
|
||||
VerifyPhoneOtpHandler,
|
||||
VerifyKycHandler,
|
||||
SubmitKycHandler,
|
||||
GenerateKycUploadUrlsHandler,
|
||||
@@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH
|
||||
useFactory: () => {
|
||||
const secret = process.env['JWT_SECRET'];
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
|
||||
}
|
||||
return {
|
||||
secret,
|
||||
|
||||
@@ -23,10 +23,22 @@ vi.mock('@nestjs/passport', () => {
|
||||
});
|
||||
|
||||
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
||||
vi.mock('@modules/shared', () => ({
|
||||
vi.mock('@modules/shared', () => {
|
||||
const { HttpException } = require('@nestjs/common');
|
||||
class DomainException extends HttpException {
|
||||
constructor(public readonly errorCode: string, message: string, statusCode = 500) {
|
||||
super(message, statusCode);
|
||||
}
|
||||
}
|
||||
return {
|
||||
PrismaService: class {},
|
||||
RedisService: class {},
|
||||
}));
|
||||
DomainException,
|
||||
ErrorCode: {
|
||||
AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
type RedisStub = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
|
||||
import { EventBus } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type OAuthProvider, type Prisma } from '@prisma/client';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared';
|
||||
import { UserEntity } from '../../domain/entities/user.entity';
|
||||
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
@@ -62,7 +62,7 @@ export class OAuthService {
|
||||
});
|
||||
|
||||
if (!existingOAuth.user.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
|
||||
@@ -74,7 +74,7 @@ export class OAuthService {
|
||||
const existingUser = await this.userRepo.findByEmail(profile.email);
|
||||
if (existingUser) {
|
||||
if (!existingUser.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
await this.createOAuthAccount(existingUser.id, profile);
|
||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
|
||||
@@ -93,7 +93,7 @@ export class OAuthService {
|
||||
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
|
||||
if (existingUser) {
|
||||
if (!existingUser.isActive) {
|
||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
||||
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
await this.createOAuthAccount(existingUser.id, profile);
|
||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
|
||||
@@ -121,6 +121,7 @@ export class OAuthService {
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { type Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { PrismaService, RedisService } from '@modules/shared';
|
||||
import { PrismaService, RedisService, DomainException, ErrorCode } from '@modules/shared';
|
||||
import { type JwtPayload } from '../services/token.service';
|
||||
|
||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||
@@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
) {
|
||||
const jwtSecret = process.env['JWT_SECRET'];
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
|
||||
}
|
||||
|
||||
super({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
|
||||
import { type TokenPair } from '../services/token.service';
|
||||
|
||||
@@ -126,11 +126,19 @@ export class ZaloOAuthStrategy {
|
||||
|
||||
if (data.error) {
|
||||
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
||||
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||
`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error('Zalo OAuth error: No access token in response');
|
||||
throw new DomainException(
|
||||
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||
'Zalo OAuth error: No access token in response',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -150,11 +158,19 @@ export class ZaloOAuthStrategy {
|
||||
|
||||
if (data.error) {
|
||||
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
||||
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||
`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.id) {
|
||||
throw new Error('Zalo OAuth error: No user ID in response');
|
||||
throw new DomainException(
|
||||
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||
'Zalo OAuth error: No user ID in response',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
|
||||
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
|
||||
|
||||
@@ -36,7 +38,10 @@ export interface IMediaStorageService {
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Missing required environment variable: ${key}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
import {
|
||||
type IPaymentGateway,
|
||||
type CreatePaymentUrlParams,
|
||||
@@ -89,7 +91,10 @@ export class MomoService implements IPaymentGateway {
|
||||
const result = await response.json() as { resultCode: number; payUrl: string };
|
||||
|
||||
if (result.resultCode !== 0) {
|
||||
throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.PAYMENT_FAILED,
|
||||
`MoMo create payment failed: resultCode=${result.resultCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService');
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { DomainException } from '@modules/shared/domain/domain-exception';
|
||||
import { ErrorCode } from '@modules/shared/domain/error-codes';
|
||||
import {
|
||||
type IPaymentGateway,
|
||||
type CreatePaymentUrlParams,
|
||||
@@ -85,7 +87,10 @@ export class ZalopayService implements IPaymentGateway {
|
||||
};
|
||||
|
||||
if (result.return_code !== 1) {
|
||||
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`);
|
||||
throw new DomainException(
|
||||
ErrorCode.PAYMENT_FAILED,
|
||||
`ZaloPay create payment failed: return_code=${result.return_code}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { VnAdminService } from '../vn-admin.service';
|
||||
|
||||
describe('VnAdminService', () => {
|
||||
let service: VnAdminService;
|
||||
let mockPrisma: {
|
||||
vnProvince: { findMany: ReturnType<typeof vi.fn> };
|
||||
vnDistrict: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> };
|
||||
vnWard: { findMany: ReturnType<typeof vi.fn> };
|
||||
vnAdministrativeAlias: { findFirst: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
vnProvince: { findMany: vi.fn() },
|
||||
vnDistrict: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
vnWard: { findMany: vi.fn() },
|
||||
vnAdministrativeAlias: { findFirst: vi.fn() },
|
||||
};
|
||||
service = new VnAdminService(mockPrisma as any);
|
||||
});
|
||||
|
||||
// ── listProvinces ──────────────────────────────────────────────────
|
||||
|
||||
describe('listProvinces', () => {
|
||||
it('returns all provinces ordered by name ascending', async () => {
|
||||
const provinces = [
|
||||
{ code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' },
|
||||
{ code: '79', name: 'TP Hồ Chí Minh', type: 'thanh_pho', codename: 'thanh_pho_ho_chi_minh' },
|
||||
];
|
||||
mockPrisma.vnProvince.findMany.mockResolvedValue(provinces);
|
||||
|
||||
const result = await service.listProvinces();
|
||||
|
||||
expect(result).toEqual(provinces);
|
||||
expect(mockPrisma.vnProvince.findMany).toHaveBeenCalledWith({
|
||||
orderBy: [{ name: 'asc' }],
|
||||
select: { code: true, name: true, type: true, codename: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array when no provinces exist', async () => {
|
||||
mockPrisma.vnProvince.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.listProvinces();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listDistricts ──────────────────────────────────────────────────
|
||||
|
||||
describe('listDistricts', () => {
|
||||
it('returns districts filtered by provinceCode', async () => {
|
||||
const districts = [
|
||||
{ code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' },
|
||||
{ code: '769', provinceCode: '79', name: 'Thành phố Thủ Đức', type: 'thanh_pho', codename: 'thanh_pho_thu_duc' },
|
||||
];
|
||||
mockPrisma.vnDistrict.findMany.mockResolvedValue(districts);
|
||||
|
||||
const result = await service.listDistricts('79');
|
||||
|
||||
expect(result).toEqual(districts);
|
||||
expect(mockPrisma.vnDistrict.findMany).toHaveBeenCalledWith({
|
||||
where: { provinceCode: '79' },
|
||||
orderBy: [{ name: 'asc' }],
|
||||
select: { code: true, provinceCode: true, name: true, type: true, codename: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array when province has no districts', async () => {
|
||||
mockPrisma.vnDistrict.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.listDistricts('99');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listWards ──────────────────────────────────────────────────────
|
||||
|
||||
describe('listWards', () => {
|
||||
it('returns wards filtered by districtCode', async () => {
|
||||
const wards = [
|
||||
{ code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' },
|
||||
{ code: '26737', districtCode: '760', name: 'Phường Bến Thành', type: 'phuong', codename: 'phuong_ben_thanh' },
|
||||
];
|
||||
mockPrisma.vnWard.findMany.mockResolvedValue(wards);
|
||||
|
||||
const result = await service.listWards('760');
|
||||
|
||||
expect(result).toEqual(wards);
|
||||
expect(mockPrisma.vnWard.findMany).toHaveBeenCalledWith({
|
||||
where: { districtCode: '760' },
|
||||
orderBy: [{ name: 'asc' }],
|
||||
select: { code: true, districtCode: true, name: true, type: true, codename: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array when district has no wards', async () => {
|
||||
mockPrisma.vnWard.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.listWards('999');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDistrict ────────────────────────────────────────────────
|
||||
|
||||
describe('resolveDistrict', () => {
|
||||
it('returns null resolution for empty string input', async () => {
|
||||
const result = await service.resolveDistrict('');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: null,
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
});
|
||||
expect(mockPrisma.vnDistrict.findFirst).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null resolution for whitespace-only input', async () => {
|
||||
const result = await service.resolveDistrict(' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: null,
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a current district name directly', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
|
||||
|
||||
const result = await service.resolveDistrict('Quận 1');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: '760',
|
||||
isCurrent: true,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
});
|
||||
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
|
||||
where: { name: 'Quận 1' },
|
||||
select: { code: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by provinceCode when provided', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
|
||||
|
||||
await service.resolveDistrict('Quận 1', '79');
|
||||
|
||||
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
|
||||
where: { name: 'Quận 1', provinceCode: '79' },
|
||||
select: { code: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a historical alias when no direct match is found', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
|
||||
newDistrictCode: '769',
|
||||
reason: 'Sáp nhập vào Thủ Đức',
|
||||
});
|
||||
|
||||
const result = await service.resolveDistrict('Quận 2');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: '769',
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: true,
|
||||
aliasReason: 'Sáp nhập vào Thủ Đức',
|
||||
});
|
||||
expect(mockPrisma.vnAdministrativeAlias.findFirst).toHaveBeenCalledWith({
|
||||
where: { oldName: 'Quận 2', level: 'district' },
|
||||
select: { newDistrictCode: true, reason: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null resolution when neither direct nor alias match', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.resolveDistrict('Nonexistent District');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: null,
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null resolution when alias exists but has no newDistrictCode', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
|
||||
newDistrictCode: null,
|
||||
reason: 'Removed',
|
||||
});
|
||||
|
||||
const result = await service.resolveDistrict('Old Name');
|
||||
|
||||
expect(result).toEqual({
|
||||
districtCode: null,
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from input before matching', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
|
||||
|
||||
await service.resolveDistrict(' Quận 1 ');
|
||||
|
||||
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
|
||||
where: { name: 'Quận 1' },
|
||||
select: { code: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips alias lookup when direct match succeeds', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
|
||||
|
||||
await service.resolveDistrict('Quận 1');
|
||||
|
||||
expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── isKnownDistrictName ────────────────────────────────────────────
|
||||
|
||||
describe('isKnownDistrictName', () => {
|
||||
it('returns true when resolveDistrict finds a districtCode', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
|
||||
|
||||
const result = await service.isKnownDistrictName('Quận 1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when resolveDistrict finds no districtCode', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.isKnownDistrictName('Unknown District');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when district is resolved via alias', async () => {
|
||||
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
|
||||
newDistrictCode: '769',
|
||||
reason: 'Merged',
|
||||
});
|
||||
|
||||
const result = await service.isKnownDistrictName('Quận 9');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { VnAdminController } from '../vn-admin.controller';
|
||||
|
||||
describe('VnAdminController', () => {
|
||||
let controller: VnAdminController;
|
||||
let mockVnAdmin: {
|
||||
listProvinces: ReturnType<typeof vi.fn>;
|
||||
listDistricts: ReturnType<typeof vi.fn>;
|
||||
listWards: ReturnType<typeof vi.fn>;
|
||||
resolveDistrict: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockVnAdmin = {
|
||||
listProvinces: vi.fn(),
|
||||
listDistricts: vi.fn(),
|
||||
listWards: vi.fn(),
|
||||
resolveDistrict: vi.fn(),
|
||||
};
|
||||
controller = new VnAdminController(mockVnAdmin as any);
|
||||
});
|
||||
|
||||
// ── GET /reference/vn-admin/provinces ──────────────────────────────
|
||||
|
||||
describe('listProvinces', () => {
|
||||
it('delegates to VnAdminService.listProvinces and returns result', async () => {
|
||||
const provinces = [
|
||||
{ code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' },
|
||||
];
|
||||
mockVnAdmin.listProvinces.mockResolvedValue(provinces);
|
||||
|
||||
const result = await controller.listProvinces();
|
||||
|
||||
expect(result).toEqual(provinces);
|
||||
expect(mockVnAdmin.listProvinces).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /reference/vn-admin/provinces/:code/districts ──────────────
|
||||
|
||||
describe('listDistricts', () => {
|
||||
it('delegates to VnAdminService.listDistricts with the province code', async () => {
|
||||
const districts = [
|
||||
{ code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' },
|
||||
];
|
||||
mockVnAdmin.listDistricts.mockResolvedValue(districts);
|
||||
|
||||
const result = await controller.listDistricts('79');
|
||||
|
||||
expect(result).toEqual(districts);
|
||||
expect(mockVnAdmin.listDistricts).toHaveBeenCalledWith('79');
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /reference/vn-admin/districts/:code/wards ──────────────────
|
||||
|
||||
describe('listWards', () => {
|
||||
it('delegates to VnAdminService.listWards with the district code', async () => {
|
||||
const wards = [
|
||||
{ code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' },
|
||||
];
|
||||
mockVnAdmin.listWards.mockResolvedValue(wards);
|
||||
|
||||
const result = await controller.listWards('760');
|
||||
|
||||
expect(result).toEqual(wards);
|
||||
expect(mockVnAdmin.listWards).toHaveBeenCalledWith('760');
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /reference/vn-admin/resolve-district ───────────────────────
|
||||
|
||||
describe('resolveDistrict', () => {
|
||||
it('delegates to VnAdminService.resolveDistrict with name and provinceCode', async () => {
|
||||
const resolution = {
|
||||
districtCode: '760',
|
||||
isCurrent: true,
|
||||
resolvedViaAlias: false,
|
||||
aliasReason: null,
|
||||
};
|
||||
mockVnAdmin.resolveDistrict.mockResolvedValue(resolution);
|
||||
|
||||
const result = await controller.resolveDistrict('Quận 1', '79');
|
||||
|
||||
expect(result).toEqual(resolution);
|
||||
expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 1', '79');
|
||||
});
|
||||
|
||||
it('delegates without provinceCode when not provided', async () => {
|
||||
const resolution = {
|
||||
districtCode: '769',
|
||||
isCurrent: false,
|
||||
resolvedViaAlias: true,
|
||||
aliasReason: 'Sáp nhập',
|
||||
};
|
||||
mockVnAdmin.resolveDistrict.mockResolvedValue(resolution);
|
||||
|
||||
const result = await controller.resolveDistrict('Quận 2', undefined);
|
||||
|
||||
expect(result).toEqual(resolution);
|
||||
expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 2', undefined);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when name is undefined', async () => {
|
||||
await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when name is empty string', async () => {
|
||||
await expect(controller.resolveDistrict('', undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when name is whitespace only', async () => {
|
||||
await expect(controller.resolveDistrict(' ', undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException with correct message', async () => {
|
||||
await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow(
|
||||
'Query parameter "name" is required.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No auth guard (intentionally public) ───────────────────────────
|
||||
|
||||
describe('public access (no auth guard)', () => {
|
||||
it('controller has no UseGuards decorator — all endpoints are public', () => {
|
||||
// VnAdminController intentionally has no @UseGuards decorator.
|
||||
// Verify by checking that the controller class metadata does not include
|
||||
// the NestJS guards metadata key.
|
||||
const guards = Reflect.getMetadata('__guards__', VnAdminController);
|
||||
expect(guards).toBeUndefined();
|
||||
});
|
||||
|
||||
it('individual route handlers have no method-level guards', () => {
|
||||
const methods = ['listProvinces', 'listDistricts', 'listWards', 'resolveDistrict'] as const;
|
||||
for (const method of methods) {
|
||||
const guards = Reflect.getMetadata('__guards__', controller[method]);
|
||||
expect(guards).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ export enum ErrorCode {
|
||||
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
|
||||
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
|
||||
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
|
||||
AUTH_OAUTH_PROVIDER_ERROR = 'AUTH_OAUTH_PROVIDER_ERROR',
|
||||
AUTH_CONFIG_MISSING = 'AUTH_CONFIG_MISSING',
|
||||
AUTH_ACCOUNT_DISABLED = 'AUTH_ACCOUNT_DISABLED',
|
||||
|
||||
// User
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
@@ -69,4 +72,13 @@ export enum ErrorCode {
|
||||
// AI / Claude integration
|
||||
AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED',
|
||||
AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR',
|
||||
|
||||
// Notifications
|
||||
NOTIFICATION_TEMPLATE_NOT_FOUND = 'NOTIFICATION_TEMPLATE_NOT_FOUND',
|
||||
NOTIFICATION_PROVIDER_NOT_CONFIGURED = 'NOTIFICATION_PROVIDER_NOT_CONFIGURED',
|
||||
NOTIFICATION_PROVIDER_ERROR = 'NOTIFICATION_PROVIDER_ERROR',
|
||||
NOTIFICATION_INVALID_TOKEN = 'NOTIFICATION_INVALID_TOKEN',
|
||||
NOTIFICATION_INTERACTION_WINDOW_EXPIRED = 'NOTIFICATION_INTERACTION_WINDOW_EXPIRED',
|
||||
NOTIFICATION_LINK_NOT_FOUND = 'NOTIFICATION_LINK_NOT_FOUND',
|
||||
NOTIFICATION_OAUTH_INVALID_STATE = 'NOTIFICATION_OAUTH_INVALID_STATE',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { DomainException } from './domain-exception';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export class Result<T, E = Error> {
|
||||
private constructor(
|
||||
private readonly _isOk: boolean,
|
||||
@@ -28,7 +31,7 @@ export class Result<T, E = Error> {
|
||||
|
||||
unwrapErr(): E {
|
||||
if (!this._isOk) return this._error as E;
|
||||
throw new Error('Called unwrapErr on an Ok result');
|
||||
throw new DomainException(ErrorCode.INTERNAL_ERROR, 'Called unwrapErr on an Ok result');
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* are required only in production.
|
||||
*/
|
||||
|
||||
import { DomainException } from '../domain/domain-exception';
|
||||
import { ErrorCode } from '../domain/error-codes';
|
||||
|
||||
const ALWAYS_REQUIRED: readonly string[] = [
|
||||
'JWT_SECRET',
|
||||
'JWT_REFRESH_SECRET',
|
||||
@@ -104,9 +107,9 @@ export function validateEnv(): void {
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables:\n ${missing.join('\n ')}\n` +
|
||||
'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.',
|
||||
throw new DomainException(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Missing required environment variables:\n ${missing.join('\n ')}\nJWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,9 +124,9 @@ export function validateEnv(): void {
|
||||
}
|
||||
|
||||
if (secretErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
||||
'Generate secure secrets with: openssl rand -base64 48',
|
||||
throw new DomainException(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\nGenerate secure secrets with: openssl rand -base64 48`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,9 +143,9 @@ export function validateEnv(): void {
|
||||
}
|
||||
|
||||
if (missingProd.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` +
|
||||
'See .env.example for the full list of variables.',
|
||||
throw new DomainException(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\nSee .env.example for the full list of variables.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { DomainException } from '../domain/domain-exception';
|
||||
import { ErrorCode } from '../domain/error-codes';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
||||
@@ -23,7 +25,8 @@ export interface FieldEncryptionConfig {
|
||||
function deriveKeyBuffer(hexKey: string): Buffer {
|
||||
const buf = Buffer.from(hexKey, 'hex');
|
||||
if (buf.length !== 32) {
|
||||
throw new Error(
|
||||
throw new DomainException(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
`KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`,
|
||||
);
|
||||
}
|
||||
@@ -63,7 +66,10 @@ export function decryptField(stored: unknown, config: FieldEncryptionConfig): un
|
||||
// Format: enc:v{version}:{iv}:{authTag}:{ciphertext}
|
||||
const parts = stored.slice(PREFIX.length).split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Malformed encrypted field: expected 4 segments after prefix');
|
||||
throw new DomainException(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'Malformed encrypted field: expected 4 segments after prefix',
|
||||
);
|
||||
}
|
||||
|
||||
const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
359
apps/web/__tests__/middleware.spec.ts
Normal file
359
apps/web/__tests__/middleware.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Hoist mock so it's available in the vi.mock factory
|
||||
const { mockIntlMiddleware } = vi.hoisted(() => ({
|
||||
mockIntlMiddleware: vi.fn(() => NextResponse.next()),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl/middleware', () => ({
|
||||
default: vi.fn(() => mockIntlMiddleware),
|
||||
}));
|
||||
|
||||
// Mock routing config
|
||||
vi.mock('@/i18n/routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
import { middleware, config } from '../middleware';
|
||||
|
||||
function createRequest(
|
||||
pathname: string,
|
||||
options?: { cookies?: Record<string, string>; origin?: string },
|
||||
): NextRequest {
|
||||
const origin = options?.origin ?? 'http://localhost:3200';
|
||||
const url = new URL(pathname, origin);
|
||||
const request = new NextRequest(url);
|
||||
if (options?.cookies) {
|
||||
for (const [name, value] of Object.entries(options.cookies)) {
|
||||
request.cookies.set(name, value);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
describe('middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIntlMiddleware.mockReturnValue(NextResponse.next());
|
||||
});
|
||||
|
||||
describe('matcher config', () => {
|
||||
it('exports a valid matcher that excludes api and static routes', () => {
|
||||
expect(config).toBeDefined();
|
||||
expect(config.matcher).toEqual([
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matcher pattern matches normal routes', () => {
|
||||
const pattern = new RegExp(config.matcher[0]);
|
||||
expect(pattern.test('/dashboard')).toBe(true);
|
||||
expect(pattern.test('/login')).toBe(true);
|
||||
expect(pattern.test('/vi/search')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public paths — unauthenticated access', () => {
|
||||
it('allows unauthenticated access to the root path /', () => {
|
||||
const request = createRequest('/');
|
||||
const response = middleware(request);
|
||||
// Should not redirect to login — should pass through to intlMiddleware
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /login', () => {
|
||||
const request = createRequest('/login');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /register', () => {
|
||||
const request = createRequest('/register');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /search', () => {
|
||||
const request = createRequest('/search');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /search/anything', () => {
|
||||
const request = createRequest('/search/filters?type=APARTMENT');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /listings', () => {
|
||||
const request = createRequest('/listings');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /listings/:id', () => {
|
||||
const request = createRequest('/listings/abc-123');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /auth/callback', () => {
|
||||
const request = createRequest('/auth/callback');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to /auth/callback/google', () => {
|
||||
const request = createRequest('/auth/callback/google');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth redirect — protected routes require authentication', () => {
|
||||
it('redirects unauthenticated users from /dashboard to /login', () => {
|
||||
const request = createRequest('/dashboard');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users from /profile to /login', () => {
|
||||
const request = createRequest('/profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users from /my-listings to /login with redirect param', () => {
|
||||
const request = createRequest('/my-listings');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/my-listings');
|
||||
});
|
||||
|
||||
it('allows authenticated users to access /dashboard', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access /profile', () => {
|
||||
const request = createRequest('/profile', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-only redirect — authenticated users bounce from login/register', () => {
|
||||
it('redirects authenticated users from /login to /dashboard', () => {
|
||||
const request = createRequest('/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('redirects authenticated users from /register to /dashboard', () => {
|
||||
const request = createRequest('/register', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('does NOT redirect authenticated users from other public paths', () => {
|
||||
const request = createRequest('/search', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale stripping — paths with locale prefix', () => {
|
||||
it('strips /vi prefix and allows public path', () => {
|
||||
const request = createRequest('/vi/login');
|
||||
const response = middleware(request);
|
||||
// /login is public, no redirect to /login (would be self-redirect)
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips /en prefix and allows public path', () => {
|
||||
const request = createRequest('/en/search');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips /vi prefix and redirects protected path to login', () => {
|
||||
const request = createRequest('/vi/dashboard');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('strips /en prefix and redirects protected path to login', () => {
|
||||
const request = createRequest('/en/profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
|
||||
});
|
||||
|
||||
it('strips /vi prefix for auth-only redirect for authenticated users', () => {
|
||||
const request = createRequest('/vi/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('strips /en prefix for auth-only redirect for authenticated users', () => {
|
||||
const request = createRequest('/en/register', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('intl middleware delegation', () => {
|
||||
it('delegates to intlMiddleware for authenticated public paths', () => {
|
||||
const request = createRequest('/search', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('delegates to intlMiddleware for unauthenticated public paths', () => {
|
||||
const request = createRequest('/');
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('delegates to intlMiddleware for authenticated protected paths', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('does NOT delegate to intlMiddleware when redirecting to login', () => {
|
||||
const request = createRequest('/dashboard');
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delegate to intlMiddleware when redirecting to dashboard', () => {
|
||||
const request = createRequest('/login', {
|
||||
cookies: { goodgo_authenticated: 'true' },
|
||||
});
|
||||
middleware(request);
|
||||
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles path with trailing slash', () => {
|
||||
const request = createRequest('/dashboard/');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('handles deeply nested protected path', () => {
|
||||
const request = createRequest('/dashboard/analytics/reports');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe(
|
||||
'/dashboard/analytics/reports',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles root path with locale prefix /vi', () => {
|
||||
const request = createRequest('/vi');
|
||||
const response = middleware(request);
|
||||
// /vi -> stripped to / -> public, no redirect
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles root path with locale prefix /en', () => {
|
||||
const request = createRequest('/en');
|
||||
const response = middleware(request);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat partial locale match as locale prefix', () => {
|
||||
// /video should NOT strip "vi" prefix — "vi" must be followed by / or end
|
||||
const request = createRequest('/video');
|
||||
const response = middleware(request);
|
||||
// /video is not public, so should redirect to login
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('preserves redirect path through query string in login redirect', () => {
|
||||
const request = createRequest('/settings?tab=profile');
|
||||
const response = middleware(request);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
expect(redirectUrl.pathname).toBe('/login');
|
||||
// The redirect param should contain the stripped path
|
||||
expect(redirectUrl.searchParams.get('redirect')).toBe('/settings');
|
||||
});
|
||||
|
||||
it('handles cookie with empty string value as having the cookie', () => {
|
||||
const request = createRequest('/dashboard', {
|
||||
cookies: { goodgo_authenticated: '' },
|
||||
});
|
||||
const response = middleware(request);
|
||||
// cookies.has() returns true even with empty value
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
apps/web/i18n/__tests__/config.spec.ts
Normal file
49
apps/web/i18n/__tests__/config.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { locales, defaultLocale } from '../config';
|
||||
import type { Locale } from '../config';
|
||||
|
||||
describe('i18n/config', () => {
|
||||
describe('locales', () => {
|
||||
it('exports an array of supported locales', () => {
|
||||
expect(Array.isArray(locales)).toBe(true);
|
||||
expect(locales.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('includes Vietnamese (vi) as a supported locale', () => {
|
||||
expect(locales).toContain('vi');
|
||||
});
|
||||
|
||||
it('includes English (en) as a supported locale', () => {
|
||||
expect(locales).toContain('en');
|
||||
});
|
||||
|
||||
it('only contains vi and en', () => {
|
||||
expect([...locales]).toEqual(['vi', 'en']);
|
||||
});
|
||||
|
||||
it('is a readonly tuple', () => {
|
||||
// TypeScript const assertion — runtime check that it behaves as expected
|
||||
const copy = [...locales];
|
||||
expect(copy).toEqual(['vi', 'en']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultLocale', () => {
|
||||
it('is set to Vietnamese (vi)', () => {
|
||||
expect(defaultLocale).toBe('vi');
|
||||
});
|
||||
|
||||
it('is one of the supported locales', () => {
|
||||
expect(locales).toContain(defaultLocale);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locale type', () => {
|
||||
it('accepts valid locale values', () => {
|
||||
const vi: Locale = 'vi';
|
||||
const en: Locale = 'en';
|
||||
expect(vi).toBe('vi');
|
||||
expect(en).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/web/i18n/__tests__/navigation.spec.ts
Normal file
51
apps/web/i18n/__tests__/navigation.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockCreateNavigation } = vi.hoisted(() => ({
|
||||
mockCreateNavigation: vi.fn(() => ({
|
||||
Link: 'MockLink',
|
||||
redirect: 'mockRedirect',
|
||||
usePathname: 'mockUsePathname',
|
||||
useRouter: 'mockUseRouter',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock routing config
|
||||
vi.mock('../routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next-intl/navigation', () => ({
|
||||
createNavigation: mockCreateNavigation,
|
||||
}));
|
||||
|
||||
import { Link, redirect, usePathname, useRouter } from '../navigation';
|
||||
|
||||
describe('i18n/navigation', () => {
|
||||
it('calls createNavigation with the routing config', () => {
|
||||
expect(mockCreateNavigation).toHaveBeenCalledWith({
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
});
|
||||
});
|
||||
|
||||
it('exports Link from createNavigation', () => {
|
||||
expect(Link).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports redirect from createNavigation', () => {
|
||||
expect(redirect).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports usePathname from createNavigation', () => {
|
||||
expect(usePathname).toBeDefined();
|
||||
});
|
||||
|
||||
it('exports useRouter from createNavigation', () => {
|
||||
expect(useRouter).toBeDefined();
|
||||
});
|
||||
});
|
||||
121
apps/web/i18n/__tests__/request.spec.ts
Normal file
121
apps/web/i18n/__tests__/request.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the routing module
|
||||
vi.mock('../routing', () => ({
|
||||
routing: {
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
},
|
||||
}));
|
||||
|
||||
// Track getRequestConfig calls
|
||||
let capturedConfigFn: ((params: { requestLocale: Promise<string | undefined> }) => Promise<{
|
||||
locale: string;
|
||||
messages: Record<string, unknown>;
|
||||
}>) | null = null;
|
||||
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getRequestConfig: (fn: typeof capturedConfigFn) => {
|
||||
capturedConfigFn = fn;
|
||||
return fn;
|
||||
},
|
||||
}));
|
||||
|
||||
// Provide message mocks
|
||||
vi.mock('../../messages/vi.json', () => ({
|
||||
default: {
|
||||
common: { goodgo: 'GoodGo', loading: 'Đang tải...' },
|
||||
metadata: { title: 'GoodGo — Nền tảng Bất động sản Việt Nam' },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../messages/en.json', () => ({
|
||||
default: {
|
||||
common: { goodgo: 'GoodGo', loading: 'Loading...' },
|
||||
metadata: { title: 'GoodGo — Vietnam Real Estate Platform' },
|
||||
},
|
||||
}));
|
||||
|
||||
// Import triggers getRequestConfig, which captures the config function
|
||||
await import('../request');
|
||||
|
||||
describe('i18n/request', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('captures a config function via getRequestConfig', () => {
|
||||
expect(capturedConfigFn).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
describe('locale resolution', () => {
|
||||
it('uses the requested locale when it is valid (vi)', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('vi'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('uses the requested locale when it is valid (en)', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('en'),
|
||||
});
|
||||
expect(result.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) when requestLocale is undefined', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve(undefined),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) when requestLocale is empty string', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve(''),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) for an invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('fr'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
|
||||
it('falls back to default locale (vi) for another invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('ja'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message loading', () => {
|
||||
it('loads Vietnamese messages for vi locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('vi'),
|
||||
});
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
|
||||
});
|
||||
|
||||
it('loads English messages for en locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('en'),
|
||||
});
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Loading...');
|
||||
});
|
||||
|
||||
it('loads Vietnamese messages when falling back from invalid locale', async () => {
|
||||
const result = await capturedConfigFn!({
|
||||
requestLocale: Promise.resolve('fr'),
|
||||
});
|
||||
expect(result.locale).toBe('vi');
|
||||
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/web/i18n/__tests__/routing.spec.ts
Normal file
53
apps/web/i18n/__tests__/routing.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockDefineRouting } = vi.hoisted(() => ({
|
||||
mockDefineRouting: vi.fn((config: unknown) => config),
|
||||
}));
|
||||
|
||||
// Mock the config module before importing routing
|
||||
vi.mock('../config', () => ({
|
||||
locales: ['vi', 'en'] as const,
|
||||
defaultLocale: 'vi' as const,
|
||||
}));
|
||||
|
||||
// Mock next-intl/routing
|
||||
vi.mock('next-intl/routing', () => ({
|
||||
defineRouting: mockDefineRouting,
|
||||
}));
|
||||
|
||||
import { routing } from '../routing';
|
||||
|
||||
describe('i18n/routing', () => {
|
||||
it('calls defineRouting with the correct configuration', () => {
|
||||
expect(mockDefineRouting).toHaveBeenCalledWith({
|
||||
locales: ['vi', 'en'],
|
||||
defaultLocale: 'vi',
|
||||
localePrefix: 'as-needed',
|
||||
});
|
||||
});
|
||||
|
||||
it('exports a routing object', () => {
|
||||
expect(routing).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes all supported locales', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
locales: readonly string[];
|
||||
};
|
||||
expect(routingConfig.locales).toEqual(['vi', 'en']);
|
||||
});
|
||||
|
||||
it('sets defaultLocale to vi', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
defaultLocale: string;
|
||||
};
|
||||
expect(routingConfig.defaultLocale).toBe('vi');
|
||||
});
|
||||
|
||||
it('uses as-needed locale prefix strategy', () => {
|
||||
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
|
||||
localePrefix: string;
|
||||
};
|
||||
expect(routingConfig.localePrefix).toBe('as-needed');
|
||||
});
|
||||
});
|
||||
222
apps/web/messages/__tests__/messages.spec.ts
Normal file
222
apps/web/messages/__tests__/messages.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import viMessages from '../../messages/vi.json';
|
||||
import enMessages from '../../messages/en.json';
|
||||
|
||||
describe('locale messages', () => {
|
||||
describe('structural parity between vi.json and en.json', () => {
|
||||
function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
keys.push(
|
||||
...getKeys(obj[key] as Record<string, unknown>, fullKey),
|
||||
);
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
it('vi.json and en.json have the same top-level namespaces', () => {
|
||||
const viNamespaces = Object.keys(viMessages).sort();
|
||||
const enNamespaces = Object.keys(enMessages).sort();
|
||||
expect(viNamespaces).toEqual(enNamespaces);
|
||||
});
|
||||
|
||||
it('vi.json and en.json have the exact same key structure', () => {
|
||||
const viKeys = getKeys(viMessages as unknown as Record<string, unknown>);
|
||||
const enKeys = getKeys(enMessages as unknown as Record<string, unknown>);
|
||||
expect(viKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it('no keys are missing from en.json', () => {
|
||||
const viKeys = new Set(
|
||||
getKeys(viMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const enKeys = new Set(
|
||||
getKeys(enMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const missingInEn = [...viKeys].filter((k) => !enKeys.has(k));
|
||||
expect(missingInEn).toEqual([]);
|
||||
});
|
||||
|
||||
it('no keys are missing from vi.json', () => {
|
||||
const viKeys = new Set(
|
||||
getKeys(viMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const enKeys = new Set(
|
||||
getKeys(enMessages as unknown as Record<string, unknown>),
|
||||
);
|
||||
const missingInVi = [...enKeys].filter((k) => !viKeys.has(k));
|
||||
expect(missingInVi).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vietnamese (vi.json) content', () => {
|
||||
it('has a metadata namespace with title', () => {
|
||||
expect(viMessages.metadata).toBeDefined();
|
||||
expect(viMessages.metadata.title).toBe(
|
||||
'GoodGo — Nền tảng Bất động sản Việt Nam',
|
||||
);
|
||||
});
|
||||
|
||||
it('has common namespace with Vietnamese translations', () => {
|
||||
expect(viMessages.common.loading).toBe('Đang tải...');
|
||||
expect(viMessages.common.login).toBe('Đăng nhập');
|
||||
expect(viMessages.common.register).toBe('Đăng ký');
|
||||
expect(viMessages.common.logout).toBe('Đăng xuất');
|
||||
});
|
||||
|
||||
it('has nav namespace with Vietnamese navigation labels', () => {
|
||||
expect(viMessages.nav.home).toBe('Trang chủ');
|
||||
expect(viMessages.nav.search).toBe('Tìm kiếm');
|
||||
expect(viMessages.nav.pricing).toBe('Bảng giá');
|
||||
});
|
||||
|
||||
it('has propertyTypes namespace with all 9 property types in Vietnamese', () => {
|
||||
const types = viMessages.propertyTypes;
|
||||
expect(Object.keys(types)).toHaveLength(9);
|
||||
expect(types.APARTMENT).toBe('Căn hộ');
|
||||
expect(types.HOUSE).toBe('Nhà riêng');
|
||||
expect(types.VILLA).toBe('Biệt thự');
|
||||
expect(types.LAND).toBe('Đất nền');
|
||||
expect(types.OFFICE).toBe('Văn phòng');
|
||||
expect(types.SHOPHOUSE).toBe('Shophouse');
|
||||
expect(types.ROOM_RENTAL).toBe('Phòng trọ');
|
||||
expect(types.CONDOTEL).toBe('Condotel');
|
||||
expect(types.SERVICED_APARTMENT).toBe('Căn hộ dịch vụ');
|
||||
});
|
||||
|
||||
it('has transactionTypes in Vietnamese', () => {
|
||||
expect(viMessages.transactionTypes.SALE).toBe('Bán');
|
||||
expect(viMessages.transactionTypes.RENT).toBe('Cho thuê');
|
||||
});
|
||||
|
||||
it('has language labels including self-referencing Vietnamese label', () => {
|
||||
expect(viMessages.language.label).toBe('Ngôn ngữ');
|
||||
expect(viMessages.language.vi).toBe('Tiếng Việt');
|
||||
expect(viMessages.language.en).toBe('English');
|
||||
});
|
||||
|
||||
it('has auth namespace with login form labels', () => {
|
||||
expect(viMessages.auth.loginTitle).toBe('Đăng nhập');
|
||||
expect(viMessages.auth.phone).toBe('Số điện thoại');
|
||||
expect(viMessages.auth.password).toBe('Mật khẩu');
|
||||
});
|
||||
|
||||
it('has pricing namespace with plan tier labels', () => {
|
||||
expect(viMessages.pricing.tiers.FREE).toBe('Miễn phí');
|
||||
expect(viMessages.pricing.tiers.AGENT_PRO).toBe('Môi giới Pro');
|
||||
expect(viMessages.pricing.tiers.INVESTOR).toBe('Nhà đầu tư');
|
||||
expect(viMessages.pricing.tiers.ENTERPRISE).toBe('Doanh nghiệp');
|
||||
});
|
||||
|
||||
it('has search namespace with filter labels', () => {
|
||||
expect(viMessages.search.filters).toBe('Bộ lọc');
|
||||
expect(viMessages.search.district).toBe('Quận/huyện');
|
||||
expect(viMessages.search.ward).toBe('Phường/xã');
|
||||
});
|
||||
});
|
||||
|
||||
describe('English (en.json) content', () => {
|
||||
it('has a metadata namespace with English title', () => {
|
||||
expect(enMessages.metadata.title).toBe(
|
||||
'GoodGo — Vietnam Real Estate Platform',
|
||||
);
|
||||
});
|
||||
|
||||
it('has common namespace with English translations', () => {
|
||||
expect(enMessages.common.loading).toBe('Loading...');
|
||||
expect(enMessages.common.login).toBe('Login');
|
||||
expect(enMessages.common.register).toBe('Register');
|
||||
expect(enMessages.common.logout).toBe('Logout');
|
||||
});
|
||||
|
||||
it('has propertyTypes in English', () => {
|
||||
expect(enMessages.propertyTypes.APARTMENT).toBe('Apartment');
|
||||
expect(enMessages.propertyTypes.HOUSE).toBe('House');
|
||||
expect(enMessages.propertyTypes.VILLA).toBe('Villa');
|
||||
});
|
||||
|
||||
it('has transactionTypes in English', () => {
|
||||
expect(enMessages.transactionTypes.SALE).toBe('Sale');
|
||||
expect(enMessages.transactionTypes.RENT).toBe('Rent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolation placeholders', () => {
|
||||
it('vi.json common.errorCode contains {code} placeholder', () => {
|
||||
expect(viMessages.common.errorCode).toContain('{code}');
|
||||
});
|
||||
|
||||
it('en.json common.errorCode contains {code} placeholder', () => {
|
||||
expect(enMessages.common.errorCode).toContain('{code}');
|
||||
});
|
||||
|
||||
it('vi.json common.retriedCount contains {count} placeholder', () => {
|
||||
expect(viMessages.common.retriedCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('en.json common.retriedCount contains {count} placeholder', () => {
|
||||
expect(enMessages.common.retriedCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('vi.json search.bedroomsCount contains {count} placeholder', () => {
|
||||
expect(viMessages.search.bedroomsCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('en.json search.bedroomsCount contains {count} placeholder', () => {
|
||||
expect(enMessages.search.bedroomsCount).toContain('{count}');
|
||||
});
|
||||
|
||||
it('vi.json and en.json pricing.currentPlanBadge both contain {plan}', () => {
|
||||
expect(viMessages.pricing.currentPlanBadge).toContain('{plan}');
|
||||
expect(enMessages.pricing.currentPlanBadge).toContain('{plan}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no empty values', () => {
|
||||
function findEmptyValues(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): string[] {
|
||||
const empty: string[] = [];
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
empty.push(
|
||||
...findEmptyValues(value as Record<string, unknown>, fullKey),
|
||||
);
|
||||
} else if (typeof value === 'string' && value.trim() === '') {
|
||||
empty.push(fullKey);
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
it('vi.json has no empty string values', () => {
|
||||
const emptyKeys = findEmptyValues(
|
||||
viMessages as unknown as Record<string, unknown>,
|
||||
);
|
||||
expect(emptyKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('en.json has no empty string values', () => {
|
||||
const emptyKeys = findEmptyValues(
|
||||
enMessages as unknown as Record<string, unknown>,
|
||||
);
|
||||
expect(emptyKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint src/ app/ components/ lib/ hooks/ i18n/ --no-error-on-unmatched-pattern",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -44,6 +45,7 @@
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/coverage-v8": "^4.1.3",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"msw": "^2.13.2",
|
||||
|
||||
@@ -10,6 +10,38 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
|
||||
reportsDirectory: './coverage',
|
||||
include: [
|
||||
'app/**/*.ts',
|
||||
'app/**/*.tsx',
|
||||
'components/**/*.ts',
|
||||
'components/**/*.tsx',
|
||||
'lib/**/*.ts',
|
||||
'hooks/**/*.ts',
|
||||
],
|
||||
exclude: [
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'**/node_modules/**',
|
||||
'app/**/layout.tsx',
|
||||
'app/**/loading.tsx',
|
||||
'app/**/error.tsx',
|
||||
'app/**/not-found.tsx',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 60,
|
||||
branches: 50,
|
||||
functions: 60,
|
||||
lines: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -1,80 +1,144 @@
|
||||
# GoodGo Platform AI — Theo Dõi Dự Án
|
||||
|
||||
**Cập Nhật Lần Cuối:** 2026-04-22
|
||||
**Cập Nhật Lần Cuối:** 2026-04-23
|
||||
**Dự Án:** Goodgo Platform AI
|
||||
**Trạng Thái:** GOO-2 Audit & Execution — Sprint 1 đang triển khai
|
||||
**Trạng Thái:** GOO-2 Audit & Execution — Audit phase đang triển khai
|
||||
|
||||
---
|
||||
|
||||
## GOO-2 Lead Orchestrator Audit — Task Tracker (2026-04-22)
|
||||
## GOO-2 Lead Orchestrator — Task Tracker (2026-04-23)
|
||||
|
||||
### Sprint 1 — Blockers + P0 Security
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-3 | Fix double CSRF middleware | Critical | Backend TechLead | ✅ done |
|
||||
| GOO-4 | UsageRecord atomic metering | Critical | Senior Backend Engineer | 🔄 in_progress |
|
||||
| GOO-5 | Rate-limit exchange-token | Critical | Junior Backend Engineer | 🔄 in_progress |
|
||||
| GOO-6 | Fix MoMo IPN URL | Critical | Middle Backend Engineer | 🔄 in_progress |
|
||||
| GOO-7 | JWT validate user status | Critical | Security Engineer | 🔄 in_progress |
|
||||
| GOO-8 | Encrypt SystemSetting secrets | Critical | Security Engineer | ⏳ todo |
|
||||
| GOO-9 | Fix MCP status filter | Critical | Junior Backend Engineer | ⏳ todo |
|
||||
| GOO-10 | Update PRODUCTION_READINESS.md | High | Doc Bot | 🔄 in_progress |
|
||||
| GOO-4 | UsageRecord atomic metering | Critical | Senior Backend Engineer | ✅ done |
|
||||
| GOO-5 | Rate-limit exchange-token | Critical | Junior Backend Engineer | ✅ done |
|
||||
| GOO-6 | Fix MoMo IPN URL | Critical | Middle Backend Engineer | ✅ done |
|
||||
| GOO-7 | JWT validate user status | Critical | Security Engineer | ✅ done |
|
||||
| GOO-8 | Encrypt SystemSetting secrets | Critical | CEO | ✅ done |
|
||||
| GOO-9 | Fix MCP status filter | Critical | Junior Backend Engineer | ✅ done |
|
||||
| GOO-10 | Update PRODUCTION_READINESS.md | High | Product Manager | ✅ done |
|
||||
|
||||
### Sprint 2 — P0 Features + Trust
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-11 | Phone-OTP login | Critical | Backend TechLead | 🔄 in_progress |
|
||||
| GOO-12 | legalStatus enum + badge | Critical | Senior Backend Engineer | ⏳ todo |
|
||||
| GOO-13 | Vietnamese diacritic search | Critical | Backend TechLead | ⏳ todo |
|
||||
| GOO-14 | Remove $queryRawUnsafe | High | Middle Backend Engineer | ⏳ todo |
|
||||
| GOO-15 | Fix soft-deleted user login | High | Junior Backend Engineer | ⏳ todo (blocked by GOO-7) |
|
||||
| GOO-16 | Fix obsolete districts | High | Middle Frontend Engineer | 🔄 in_progress |
|
||||
| GOO-17 | ZNS template registration | High | CMO | 🚫 blocked |
|
||||
| GOO-11 | Phone-OTP login | Critical | Backend TechLead | ✅ done |
|
||||
| GOO-12 | legalStatus enum + badge | Critical | Senior Backend Engineer | ✅ done |
|
||||
| GOO-13 | Vietnamese diacritic search | Critical | Senior Backend Engineer | ✅ done |
|
||||
| GOO-14 | Remove $queryRawUnsafe | High | Middle Backend Engineer | ✅ done |
|
||||
| GOO-15 | Fix soft-deleted user login | High | Junior Backend Engineer | ✅ done |
|
||||
| GOO-16 | Fix obsolete districts | High | Middle Frontend Engineer | ✅ done |
|
||||
| GOO-17 | ZNS template registration | High | CEO | 🚫 blocked |
|
||||
|
||||
### Sprint 3 — MVP Feature Completeness
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-18 | Ward-level search | High | Middle Backend Engineer | ⏳ todo |
|
||||
| GOO-19 | Scam/abuse report flow | High | Senior Backend Engineer | ⏳ todo |
|
||||
| GOO-20 | ROOM_RENTAL property type | High | Junior Backend Engineer #2 | 🔄 in_progress |
|
||||
| GOO-21 | Vietnam admin data (ĐVHCVN) | High | Database Architect | 🔄 in_progress |
|
||||
| GOO-22 | Subscription plan seeding | Medium | Junior Backend Engineer #2 | ⏳ todo |
|
||||
| GOO-23 | Module boundary fixes | Medium | Backend TechLead | ⏳ todo |
|
||||
| GOO-18 | Ward-level search | High | Middle Backend Engineer | ✅ done |
|
||||
| GOO-19 | Scam/abuse report flow | High | Senior Backend Engineer | ✅ done |
|
||||
| GOO-20 | ROOM_RENTAL property type | High | Junior Backend Engineer #2 | ✅ done |
|
||||
| GOO-21 | Vietnam admin data (ĐVHCVN) | High | Founding Engineer | ✅ done |
|
||||
| GOO-22 | Subscription plan seeding | Medium | Junior Backend Engineer #2 | ✅ done |
|
||||
| GOO-23 | Module boundary fixes | Medium | Backend TechLead | ✅ done |
|
||||
|
||||
### Sprint 4 — Trust + Monetization
|
||||
|
||||
| Task | Tiêu đề | Owner | Trạng thái |
|
||||
|------|---------|-------|------------|
|
||||
| GOO-24 | Certificate verification | Senior Backend Engineer | ⏳ todo (blocked by GOO-12) |
|
||||
| GOO-25 | Payment go-live checklist | DevOps Engineer | 🔄 in_progress |
|
||||
| GOO-26 | Revenue stats fix | Middle Backend Engineer | ⏳ todo |
|
||||
| GOO-27 | Float→Decimal migration | Database Architect | 🔄 in_progress |
|
||||
| GOO-28 | Typesense+MinIO health | Infrastructure Engineer | 🔄 in_progress |
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-24 | Certificate verification | High | Senior Backend Engineer | ✅ done |
|
||||
| GOO-25 | Payment go-live checklist | High | DevOps Engineer | ✅ done |
|
||||
| GOO-26 | Revenue stats fix | High | Middle Backend Engineer | ✅ done |
|
||||
| GOO-27 | Float→Decimal migration | High | Middle Backend Engineer | ✅ done |
|
||||
| GOO-28 | Typesense+MinIO health | Medium | Infrastructure Engineer | ✅ done |
|
||||
|
||||
### Sprint 5 — UX + Performance
|
||||
|
||||
| Task | Tiêu đề | Owner | Trạng thái |
|
||||
|------|---------|-------|------------|
|
||||
| GOO-29 | Mobile swipe gallery | Middle Frontend Engineer | ⏳ todo |
|
||||
| GOO-30 | Listing expiry notification | Middle Backend Engineer | ⏳ todo |
|
||||
| GOO-31 | AVM circuit breaker | Infrastructure Engineer | ⏳ todo |
|
||||
| GOO-34 | P2 batch fixes | Founding Engineer | 🔄 in_progress |
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-29 | Mobile swipe gallery | Medium | Middle Frontend Engineer | ✅ done |
|
||||
| GOO-30 | Listing expiry notification | Medium | Middle Backend Engineer | ✅ done |
|
||||
| GOO-31 | AVM circuit breaker | Medium | Infrastructure Engineer | ✅ done |
|
||||
| GOO-34 | P2 batch fixes | Medium | Founding Engineer | ✅ done |
|
||||
| GOO-44 | Unit tests cho ListingExpiryCronService | Low | Junior Backend Engineer | 🔄 in_progress |
|
||||
|
||||
### Sprint 6 — Architecture + Docs
|
||||
|
||||
| Task | Tiêu đề | Owner | Trạng thái |
|
||||
|------|---------|-------|------------|
|
||||
| GOO-32 | Architecture hygiene batch | Backend TechLead | ⏳ todo |
|
||||
| GOO-33 | Documentation updates | Doc Bot | 🔄 in_progress |
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-32 | Architecture hygiene batch | Medium | CEO | ✅ done |
|
||||
| GOO-33 | Documentation updates | Medium | Doc Bot | ✅ done |
|
||||
| GOO-35 | Index ProjectDevelopment in Typesense | Medium | Backend TechLead | ✅ done |
|
||||
| GOO-36 | AI sidecar OpenAPI contract generation | Medium | Backend TechLead | ✅ done |
|
||||
|
||||
### Tổng kết tiến độ
|
||||
- **Done:** 1/32 (3%)
|
||||
- **In progress:** 13/32 (41%)
|
||||
- **Todo:** 16/32 (50%)
|
||||
- **Blocked:** 2/32 (6%)
|
||||
### Coordination & Ops Tasks
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-1 | Approve toàn bộ Agents | Medium | — | ✅ done |
|
||||
| GOO-2 | Lead Orchestrator — Audit, Plan & Execute | Medium | CEO | 🔄 in_progress |
|
||||
| GOO-37 | Kiểm tra đường dẫn workspace | Medium | — | 🚫 blocked |
|
||||
| GOO-38 | Kiểm tra files đã cập nhật | Medium | CEO | ✅ done |
|
||||
| GOO-39 | CTO — Điều phối kỹ thuật unblock GOO-21/24 | High | CTO | ✅ done |
|
||||
| GOO-40 | Backend TechLead — Unblock GOO-21 + Review GOO-24 | High | Backend TechLead | ✅ done |
|
||||
| GOO-41 | Frontend TechLead — Hỗ trợ unblock GOO-21 | High | Frontend TechLead | ✅ done |
|
||||
| GOO-42 | QA — Verify GOO-24 + GOO-30 | High | QA Engineer | ✅ done |
|
||||
| GOO-43 | Unblock GOO-21 AC3 — Fix filter-bar TS error | High | Frontend TechLead | ✅ done |
|
||||
| GOO-45 | Cập nhật Agents Config → GLM 5.1 | Medium | CEO | ✅ done |
|
||||
| GOO-46 | Cập nhật Agents Config & Permission | Medium | CEO | ✅ done |
|
||||
|
||||
### Audit Phase — Unit Tests (A1–A5)
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-47 | [A1] Unit tests cho industrial/ (50 files) | Critical | Backend TechLead | 🔄 in_progress |
|
||||
| GOO-48 | [A2] Unit tests cho projects/ (25 files) | High | Senior Backend Engineer | 🔄 in_progress |
|
||||
| GOO-51 | [A3] Unit tests cho documents/ (20 files) | High | Middle Backend Engineer | 🔄 in_progress |
|
||||
| GOO-58 | [A4] Unit tests cho favorites/ (14 files) | Medium | Junior Backend Engineer | ⏳ todo |
|
||||
| GOO-59 | [A5] Unit tests cho reference/ (4 files) | Low | Junior Backend Engineer #2 | 🔄 in_progress |
|
||||
|
||||
### Audit Phase — Error Handling Migration (A6–A8)
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-49 | [A6] Migrate Error → DomainException trong notifications/ (21) | High | Backend TechLead | ⏳ todo |
|
||||
| GOO-52 | [A7] Migrate Error → DomainException trong auth/ (9) | Medium | Senior Backend Engineer | ⏳ todo |
|
||||
| GOO-53 | [A8] Migrate Error → DomainException trong shared/analytics/payments/ (14) | Medium | Middle Backend Engineer | ⏳ todo |
|
||||
|
||||
### Audit Phase — Frontend & Misc (A9–A11)
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-54 | [A9] Frontend component tests — 108 components | High | Frontend TechLead | 🔄 in_progress |
|
||||
| GOO-60 | [A10] Frontend middleware + i18n tests | Medium | Senior Frontend Engineer | 🔄 in_progress |
|
||||
| GOO-61 | [A11] Sync PROJECT_TRACKER.md với Paperclip | Medium | Doc Bot | 🔄 in_progress |
|
||||
|
||||
### Audit Phase — Infrastructure & Quality (B1–B8)
|
||||
|
||||
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|
||||
|------|---------|---------|-------|------------|
|
||||
| GOO-62 | [B1] E2E test suite expansion | High | QA Engineer | 🔄 in_progress |
|
||||
| GOO-55 | [B2] API rate limiting fine-tuning | Medium | Security Engineer | 🔄 in_progress |
|
||||
| GOO-63 | [B3] Frontend error boundary components | Medium | Frontend TechLead | ⏳ todo |
|
||||
| GOO-56 | [B4] API request validation audit | Medium | API Architect | 🔄 in_progress |
|
||||
| GOO-57 | [B5] Database query optimization audit | Medium | Database Architect | 🔄 in_progress |
|
||||
| GOO-50 | [B6] CI pipeline: test coverage thresholds | High | DevOps Engineer | 🔄 in_progress |
|
||||
| GOO-64 | [B7] Sentry error tracking verification | Medium | SRE Engineer | 🔄 in_progress |
|
||||
| GOO-65 | [B8] Frontend accessibility audit (WCAG 2.1) | Medium | UX/UI Designer | 🔄 in_progress |
|
||||
|
||||
### Tổng kết tiến độ GOO-2
|
||||
|
||||
| Nhóm | Tổng | Done | In Progress | Todo | Blocked |
|
||||
|------|------|------|-------------|------|---------|
|
||||
| Sprint 1–6 (GOO-3 → GOO-36) | 32 | 30 | 1 | 0 | 1 |
|
||||
| Coordination & Ops | 11 | 9 | 1 | 0 | 1 |
|
||||
| Audit A (A1–A11) | 11 | 0 | 7 | 4 | 0 |
|
||||
| Audit B (B1–B8) | 8 | 0 | 6 | 1 | 0 |
|
||||
| **Tổng GOO issues** | **65** | **42** | **16** | **5** | **2** |
|
||||
|
||||
**Tiến độ tổng: 42/65 (65%) done — 16 in_progress — 5 todo — 2 blocked**
|
||||
|
||||
---
|
||||
|
||||
@@ -455,7 +519,7 @@ Task cha: [TEC-1915](/TEC/issues/TEC-1915) — Goodgo Platform AI
|
||||
| [TEC-1922](/TEC/issues/TEC-1922) | Tạo checklist sẵn sàng sản xuất chính thức và ký duyệt | Trung bình | cần làm | Kỹ Sư SRE |
|
||||
| [TEC-1923](/TEC/issues/TEC-1923) | Cập nhật PROJECT_TRACKER.md với kết quả kiểm toán Wave 13 | Trung bình | hoàn thành | Kỹ Sư Viết Tài Liệu |
|
||||
|
||||
### Wave 14 — Kiểm Toán CEO (2026-04-12) — ✅ Build Xanh
|
||||
### Wave 14 — Kiểm Toán CEO (2026-04-12) — Build Xanh
|
||||
|
||||
Task cha: [TEC-1970](/TEC/issues/TEC-1970) — Goodgo Platform AI
|
||||
|
||||
@@ -517,10 +581,11 @@ Tất cả đều không chặn sẵn sàng sản xuất.
|
||||
| Giai Đoạn 5 | 4 | 4 | 0 | 0 | 0 | 0 |
|
||||
| Giai Đoạn 6 | 16 | 16 | 0 | 0 | 0 | 0 |
|
||||
| Giai Đoạn 7 | 108 | 97 | 1 | 0 | 5 | 5 |
|
||||
| **Tổng** | **159** | **148** | **1** | **0** | **5** | **5** |
|
||||
| **Tổng (Legacy)** | **159** | **148** | **1** | **0** | **5** | **5** |
|
||||
| **GOO-2 (Active)** | **65** | **42** | **16** | **2** | **5** | **0** |
|
||||
|
||||
*Lưu ý: 5 vấn đề đã huỷ (TEC-1547, TEC-1876, TEC-1877 + 2 vấn đề khác). Số liệu được lấy từ bộ theo dõi vấn đề Paperclip vào ngày 2026-04-12.*
|
||||
*Lưu ý: 5 vấn đề legacy đã huỷ (TEC-1547, TEC-1876, TEC-1877 + 2 vấn đề khác). Số liệu GOO-2 được đồng bộ từ Paperclip API vào ngày 2026-04-23.*
|
||||
|
||||
---
|
||||
|
||||
*Cập nhật lần cuối bởi Kỹ Sư Viết Tài Liệu — 2026-04-12 (Kiểm toán CEO Wave 14: build xanh, dọn dẹp backlog bởi Kỹ Sư QA)*
|
||||
*Cập nhật lần cuối bởi Doc Bot — 2026-04-23 (GOO-61: Sync PROJECT_TRACKER.md với Paperclip issue statuses)*
|
||||
|
||||
@@ -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
339
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"test": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user