fix(tests): create missing infrastructure stubs and fix AVM spec (GOO-131)
Several committed modules imported files that were never created, causing
every spec that imports SharedModule/NotificationsModule to fail with
"Cannot find module" errors. This commit provides the missing pieces:
API infrastructure stubs (RFC-001/GOO-170 in-flight feature deps):
- shared/infrastructure/versioning.ts: API_VERSION_REGISTRY, resolveMajorSpec
and related types for RFC-001 Phase 1 versioning
- shared/infrastructure/interceptors/index.ts: VersionInterceptor +
DeprecationInterceptor NestJS interceptors
- metrics/metrics.constants.ts: add READ_MODEL_PROJECTOR_LAG_SECONDS,
READ_MODEL_REFRESH_DURATION_SECONDS, READ_MODEL_RECONCILIATION_DRIFT_TOTAL
Phone-login OTP flow (GOO-182 in-flight deps):
- auth/domain/events/phone-login-otp-requested.event.ts: DomainEvent stub
- notifications/.../phone-login-otp-requested.listener.ts: event listener
AVM spec fix:
- analytics/.../prisma-avm.service.spec.ts: switch mock from $queryRawUnsafe
to $queryRaw (findComparables was parameterized in 6774914) and use
mockResolvedValueOnce for correct call-order semantics
After these changes all 333 API + 148 web + 59 mcp-servers tests pass.
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
@@ -29,12 +29,15 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
// First $queryRaw call: property location lookup
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
// Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
|
||||||
]);
|
mockPrisma.$queryRaw
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
]);
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -44,14 +47,15 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
])
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
]);
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses coordinates directly when no propertyId', async () => {
|
it('uses coordinates directly when no propertyId', async () => {
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
// coords-only path: no property lookup, $queryRaw used for comparables directly
|
||||||
|
mockPrisma.$queryRaw.mockResolvedValue([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
@@ -78,18 +83,20 @@ describe('PrismaAVMService', () => {
|
|||||||
|
|
||||||
expect(result.confidence).toBeGreaterThan(0);
|
expect(result.confidence).toBeGreaterThan(0);
|
||||||
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
||||||
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
|
// coords-only path: $queryRaw is used for comparables; $queryRawUnsafe not called
|
||||||
|
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getComparables', () => {
|
describe('getComparables', () => {
|
||||||
it('returns comparables for a property', async () => {
|
it('returns comparables for a property', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
])
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.getComparables('prop-1', 3000);
|
const result = await service.getComparables('prop-1', 3000);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
export class PhoneLoginOtpRequestedEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'user.phone_login_otp_requested';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly phone: string,
|
||||||
|
public readonly otpCode: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
|
|||||||
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
|
export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients';
|
||||||
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
|
export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total';
|
||||||
|
|
||||||
|
// ── Read-Model / Projection Metrics ──
|
||||||
|
export const READ_MODEL_PROJECTOR_LAG_SECONDS = 'goodgo_read_model_projector_lag_seconds';
|
||||||
|
export const READ_MODEL_REFRESH_DURATION_SECONDS = 'goodgo_read_model_refresh_duration_seconds';
|
||||||
|
export const READ_MODEL_RECONCILIATION_DRIFT_TOTAL = 'goodgo_read_model_reconciliation_drift_total';
|
||||||
|
|
||||||
// ── Web Vitals / RUM Metrics ──
|
// ── Web Vitals / RUM Metrics ──
|
||||||
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
|
||||||
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CommandBus } from '@nestjs/cqrs';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||||
|
import type { PhoneLoginOtpRequestedEvent } from '../../../auth/domain/events/phone-login-otp-requested.event';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PhoneLoginOtpRequestedListener {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('user.phone_login_otp_requested', { async: true })
|
||||||
|
async handle(event: PhoneLoginOtpRequestedEvent): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`Sending OTP SMS to ${event.phone} for user ${event.aggregateId}`,
|
||||||
|
'PhoneLoginOtpRequestedListener',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.commandBus.execute(
|
||||||
|
new SendNotificationCommand({
|
||||||
|
userId: event.aggregateId,
|
||||||
|
channel: 'sms',
|
||||||
|
template: 'phone_login_otp',
|
||||||
|
context: {
|
||||||
|
phone: event.phone,
|
||||||
|
otpCode: event.otpCode,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* RFC-001 Phase 1 — API versioning interceptors.
|
||||||
|
*
|
||||||
|
* Placeholder implementations so the module compiles while the full
|
||||||
|
* versioning feature (GOO-170) is being developed on its own branch.
|
||||||
|
*/
|
||||||
|
import { Injectable, type NestInterceptor, type ExecutionContext, type CallHandler } from '@nestjs/common';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export const API_MINOR_HEADER = 'X-Api-Minor-Version';
|
||||||
|
export const API_MINOR_RESOLVED_HEADER = 'X-Api-Minor-Resolved';
|
||||||
|
|
||||||
|
export interface ResolvedApiVersion {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the Accept-Version request header and attaches a parsed
|
||||||
|
* ResolvedApiVersion object to `req.apiVersion` for downstream use.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class VersionInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const req = context.switchToHttp().getRequest<{ apiVersion?: ResolvedApiVersion; headers: Record<string, string> }>();
|
||||||
|
const raw = req.headers['accept-version'] ?? 'v1.0';
|
||||||
|
const [majorStr, minorStr] = raw.replace(/^v/, '').split('.');
|
||||||
|
req.apiVersion = {
|
||||||
|
major: parseInt(majorStr ?? '1', 10),
|
||||||
|
minor: parseInt(minorStr ?? '0', 10),
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes deprecation headers when the resolved spec carries a sunset date.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DeprecationInterceptor implements NestInterceptor {
|
||||||
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(() => {
|
||||||
|
// Deprecation warnings are a no-op in the stub.
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/modules/shared/infrastructure/versioning.ts
Normal file
44
apps/api/src/modules/shared/infrastructure/versioning.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* RFC-001 Phase 1 — API versioning registry.
|
||||||
|
*
|
||||||
|
* Placeholder stubs so the module compiles while the full versioning
|
||||||
|
* feature (GOO-170) is being developed on its own branch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiVersionDeprecation {
|
||||||
|
sunset: string;
|
||||||
|
replacement?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiMajorSpec {
|
||||||
|
major: number;
|
||||||
|
minMinor: number;
|
||||||
|
maxMinor: number;
|
||||||
|
deprecation?: ApiVersionDeprecation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiVersionRegistry {
|
||||||
|
current: string;
|
||||||
|
specs: ApiMajorSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_VERSION_REGISTRY: ApiVersionRegistry = {
|
||||||
|
current: 'v1.0',
|
||||||
|
specs: [
|
||||||
|
{
|
||||||
|
major: 1,
|
||||||
|
minMinor: 0,
|
||||||
|
maxMinor: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the major-version spec for a given Accept-Version header value.
|
||||||
|
* Returns undefined when no matching spec is found.
|
||||||
|
*/
|
||||||
|
export function resolveMajorSpec(version: string): ApiMajorSpec | undefined {
|
||||||
|
const major = parseInt(version.replace(/^v/, '').split('.')[0] ?? '1', 10);
|
||||||
|
return API_VERSION_REGISTRY.specs.find((s) => s.major === major);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user