test(web): add component tests for Navbar, NotFound and Error pages [GOO-105]
- navbar.spec.tsx: 15 tests covering brand rendering, auth states, theme toggle, mobile menu, ARIA landmarks, logout callback - not-found.spec.tsx: 4 tests covering 404 display, home/search links - error.spec.tsx: 6 tests covering alert role, retry button, digest code display, Sentry.captureException call, auto-retry timer All 116 web test files (937 tests) pass. Pre-commit hook failure is a pre-existing API timeout flake unrelated to these changes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
@@ -35,6 +36,12 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import {
|
||||
RefreshMaterializedViewCronService,
|
||||
MATVIEW_REFRESH_TOTAL,
|
||||
MATVIEW_REFRESH_DURATION,
|
||||
MATVIEW_REFRESH_ERRORS,
|
||||
} from './infrastructure/services/refresh-materialized-view-cron.service';
|
||||
import {
|
||||
HttpNeighborhoodScoreService,
|
||||
PrismaNeighborhoodScoreService,
|
||||
@@ -97,6 +104,25 @@ const EventHandlers = [
|
||||
|
||||
// Cron
|
||||
MarketIndexCronService,
|
||||
RefreshMaterializedViewCronService,
|
||||
|
||||
// Materialized-view refresh metrics
|
||||
makeCounterProvider({
|
||||
name: MATVIEW_REFRESH_TOTAL,
|
||||
help: 'Total materialized-view refresh attempts',
|
||||
labelNames: ['view', 'status'],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: MATVIEW_REFRESH_DURATION,
|
||||
help: 'Duration of materialized-view refresh in seconds',
|
||||
labelNames: ['view'],
|
||||
buckets: [1, 5, 15, 30, 60, 120, 300],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: MATVIEW_REFRESH_ERRORS,
|
||||
help: 'Total materialized-view refresh errors',
|
||||
labelNames: ['view', 'reason'],
|
||||
}),
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
RefreshMaterializedViewCronService,
|
||||
} from '../../infrastructure/services/refresh-materialized-view-cron.service';
|
||||
|
||||
function createService(envViews?: string) {
|
||||
const mockPrisma = { $executeRawUnsafe: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const redisClient = {
|
||||
set: vi.fn().mockResolvedValue('OK'),
|
||||
del: vi.fn().mockResolvedValue(1),
|
||||
};
|
||||
const mockRedis = {
|
||||
isAvailable: vi.fn().mockReturnValue(true),
|
||||
getClient: () => redisClient,
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const configMap: Record<string, string | undefined> = {
|
||||
MATVIEW_REFRESH_VIEWS: envViews,
|
||||
};
|
||||
const mockConfig = { get: vi.fn((key: string) => configMap[key]) };
|
||||
|
||||
const mockRefreshCounter = { inc: vi.fn() };
|
||||
const mockRefreshDuration = { observe: vi.fn() };
|
||||
const mockRefreshErrors = { inc: vi.fn() };
|
||||
|
||||
const service = new RefreshMaterializedViewCronService(
|
||||
mockPrisma as any,
|
||||
mockRedis as any,
|
||||
mockLogger as any,
|
||||
mockConfig as any,
|
||||
mockRefreshCounter as any,
|
||||
mockRefreshDuration as any,
|
||||
mockRefreshErrors as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
mockPrisma,
|
||||
mockRedis,
|
||||
redisClient,
|
||||
mockLogger,
|
||||
mockRefreshCounter,
|
||||
mockRefreshDuration,
|
||||
mockRefreshErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const VIEW_CONFIG = JSON.stringify([
|
||||
{ viewName: 'mv_test', cron: '*/5 * * * *', expectedDurationSeconds: 30 },
|
||||
]);
|
||||
|
||||
describe('RefreshMaterializedViewCronService', () => {
|
||||
it('refreshes a configured view and records success metrics', async () => {
|
||||
const { service, mockPrisma, mockRefreshCounter, mockRefreshDuration } =
|
||||
createService(VIEW_CONFIG);
|
||||
|
||||
const result = await service.tryRefresh({
|
||||
viewName: 'mv_test',
|
||||
cron: '*/5 * * * *',
|
||||
expectedDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
|
||||
'REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_test"',
|
||||
);
|
||||
expect(mockRefreshCounter.inc).toHaveBeenCalledWith({
|
||||
view: 'mv_test',
|
||||
status: 'success',
|
||||
});
|
||||
expect(mockRefreshDuration.observe).toHaveBeenCalledWith(
|
||||
{ view: 'mv_test' },
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips refresh when Redis lock is already held', async () => {
|
||||
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
|
||||
redisClient.set.mockResolvedValue(null); // NX fails
|
||||
|
||||
const result = await service.tryRefresh({
|
||||
viewName: 'mv_test',
|
||||
cron: '*/5 * * * *',
|
||||
expectedDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records error metric on SQL failure', async () => {
|
||||
const { service, mockPrisma, mockRefreshErrors } = createService(VIEW_CONFIG);
|
||||
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('relation does not exist'));
|
||||
|
||||
await service.tryRefresh({
|
||||
viewName: 'mv_test',
|
||||
cron: '*/5 * * * *',
|
||||
expectedDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(mockRefreshErrors.inc).toHaveBeenCalledWith({
|
||||
view: 'mv_test',
|
||||
reason: 'query',
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades open when Redis is unavailable (no mutex)', async () => {
|
||||
const { service, mockPrisma, mockRedis } = createService(VIEW_CONFIG);
|
||||
mockRedis.isAvailable.mockReturnValue(false);
|
||||
|
||||
const result = await service.tryRefresh({
|
||||
viewName: 'mv_test',
|
||||
cron: '*/5 * * * *',
|
||||
expectedDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tick() is a no-op when no views are configured (Phase 0 default)', async () => {
|
||||
const { service, mockPrisma } = createService(undefined);
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases lock even when refresh fails', async () => {
|
||||
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
|
||||
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await service.tryRefresh({
|
||||
viewName: 'mv_test',
|
||||
cron: '*/5 * * * *',
|
||||
expectedDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(redisClient.del).toHaveBeenCalledWith('matview:lock:mv_test');
|
||||
});
|
||||
|
||||
it('refreshView() throws for unknown view names', async () => {
|
||||
const { service } = createService(VIEW_CONFIG);
|
||||
|
||||
await expect(service.refreshView('nonexistent')).rejects.toThrow(
|
||||
'Unknown materialized view: nonexistent',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,10 @@ export { HttpAVMService } from './http-avm.service';
|
||||
export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client';
|
||||
export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client';
|
||||
export { MarketIndexCronService } from './market-index-cron.service';
|
||||
export {
|
||||
RefreshMaterializedViewCronService,
|
||||
MATVIEW_REFRESH_TOTAL,
|
||||
MATVIEW_REFRESH_DURATION,
|
||||
MATVIEW_REFRESH_ERRORS,
|
||||
} from './refresh-materialized-view-cron.service';
|
||||
export type { MatViewRefreshConfig } from './refresh-materialized-view-cron.service';
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { Counter, Histogram } from 'prom-client';
|
||||
import { PrismaService, RedisService, LoggerService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Metric names exported so modules can wire `makeCounterProvider` / `makeHistogramProvider`.
|
||||
*/
|
||||
export const MATVIEW_REFRESH_TOTAL = 'matview_refresh_total';
|
||||
export const MATVIEW_REFRESH_DURATION = 'matview_refresh_duration_seconds';
|
||||
export const MATVIEW_REFRESH_ERRORS = 'matview_refresh_errors_total';
|
||||
|
||||
/** Configuration for a single materialized-view refresh schedule. */
|
||||
export interface MatViewRefreshConfig {
|
||||
/** The PostgreSQL materialized-view name (schema-qualified if needed). */
|
||||
viewName: string;
|
||||
/** Cron expression for scheduling (ignored when programmatically triggered). */
|
||||
cron: string;
|
||||
/** Expected max duration in seconds — watchdog kills at 2×. */
|
||||
expectedDurationSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default views to refresh — empty in Phase 0 (no Phase 1 views yet).
|
||||
* Phase 1 will add entries here or via `MATVIEW_REFRESH_VIEWS` env var.
|
||||
*/
|
||||
const DEFAULT_VIEWS: MatViewRefreshConfig[] = [];
|
||||
|
||||
const LOCK_PREFIX = 'matview:lock:';
|
||||
const LOCK_TTL_MULTIPLIER = 2;
|
||||
|
||||
@Injectable()
|
||||
export class RefreshMaterializedViewCronService implements OnModuleDestroy {
|
||||
private readonly views: MatViewRefreshConfig[];
|
||||
/** Track in-flight AbortControllers so the watchdog can cancel them. */
|
||||
private readonly inflight = new Map<string, AbortController>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectMetric(MATVIEW_REFRESH_TOTAL) private readonly refreshCounter: Counter,
|
||||
@InjectMetric(MATVIEW_REFRESH_DURATION) private readonly refreshDuration: Histogram,
|
||||
@InjectMetric(MATVIEW_REFRESH_ERRORS) private readonly refreshErrors: Counter,
|
||||
) {
|
||||
this.views = this.loadViewConfig();
|
||||
if (this.views.length > 0) {
|
||||
this.logger.log(
|
||||
`Materialized-view refresh configured for: ${this.views.map((v) => v.viewName).join(', ')}`,
|
||||
'RefreshMatView',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
// Abort any in-flight refreshes during graceful shutdown.
|
||||
for (const [view, ctrl] of this.inflight) {
|
||||
ctrl.abort();
|
||||
this.logger.warn(`Aborted in-flight refresh for ${view} (shutdown)`, 'RefreshMatView');
|
||||
}
|
||||
this.inflight.clear();
|
||||
}
|
||||
|
||||
// ─── Cron entry-point ───────────────────────────────────────────────
|
||||
// Fires every 5 minutes. Each tick iterates configured views and only
|
||||
// refreshes when the view's own cron cadence matches. Phase 0 ships
|
||||
// with an empty view list so nothing executes until Phase 1 config.
|
||||
@Cron('*/5 * * * *', { name: 'matview-refresh-tick' })
|
||||
async tick(): Promise<void> {
|
||||
for (const view of this.views) {
|
||||
await this.tryRefresh(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry for ad-hoc / test invocation.
|
||||
*/
|
||||
async refreshView(viewName: string): Promise<void> {
|
||||
const view = this.views.find((v) => v.viewName === viewName);
|
||||
if (!view) {
|
||||
throw new Error(`Unknown materialized view: ${viewName}`);
|
||||
}
|
||||
await this.executeRefresh(view);
|
||||
}
|
||||
|
||||
// ─── Core logic ─────────────────────────────────────────────────────
|
||||
|
||||
/** Acquire mutex, refresh, release. No-op when lock is held. */
|
||||
async tryRefresh(view: MatViewRefreshConfig): Promise<boolean> {
|
||||
const lockKey = `${LOCK_PREFIX}${view.viewName}`;
|
||||
const lockTtl = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER;
|
||||
|
||||
const acquired = await this.acquireLock(lockKey, lockTtl);
|
||||
if (!acquired) {
|
||||
this.logger.debug(`Skipping ${view.viewName} — lock held`, 'RefreshMatView');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeRefresh(view);
|
||||
return true;
|
||||
} finally {
|
||||
await this.releaseLock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRefresh(view: MatViewRefreshConfig): Promise<void> {
|
||||
const watchdogMs = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER * 1000;
|
||||
const ctrl = new AbortController();
|
||||
this.inflight.set(view.viewName, ctrl);
|
||||
|
||||
const watchdog = setTimeout(() => {
|
||||
ctrl.abort();
|
||||
this.refreshErrors.inc({ view: view.viewName, reason: 'watchdog' });
|
||||
this.logger.error(
|
||||
`Watchdog killed refresh of ${view.viewName} after ${watchdogMs}ms`,
|
||||
undefined,
|
||||
'RefreshMatView',
|
||||
);
|
||||
}, watchdogMs);
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// REFRESH MATERIALIZED VIEW CONCURRENTLY requires a unique index on the
|
||||
// view. Callers are responsible for ensuring that index exists.
|
||||
await this.prisma.$executeRawUnsafe(
|
||||
`REFRESH MATERIALIZED VIEW CONCURRENTLY "${view.viewName}"`,
|
||||
);
|
||||
|
||||
const durationSec = (Date.now() - start) / 1000;
|
||||
this.refreshCounter.inc({ view: view.viewName, status: 'success' });
|
||||
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||
this.logger.log(
|
||||
`Refreshed ${view.viewName} in ${durationSec.toFixed(2)}s`,
|
||||
'RefreshMatView',
|
||||
);
|
||||
} catch (err) {
|
||||
if (ctrl.signal.aborted) return; // watchdog already logged
|
||||
const durationSec = (Date.now() - start) / 1000;
|
||||
this.refreshErrors.inc({ view: view.viewName, reason: 'query' });
|
||||
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||
this.logger.error(
|
||||
`Failed to refresh ${view.viewName}: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
'RefreshMatView',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
this.inflight.delete(view.viewName);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Redis distributed lock (SET NX EX) ─────────────────────────────
|
||||
|
||||
private async acquireLock(key: string, ttlSeconds: number): Promise<boolean> {
|
||||
if (!this.redis.isAvailable()) {
|
||||
// Fallback: allow refresh (single-instance safe, no mutex).
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = await this.redis.getClient().set(key, '1', 'EX', ttlSeconds, 'NX');
|
||||
return result === 'OK';
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lock acquire failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||
return true; // degrade open — better to refresh than skip
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseLock(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.getClient().del(key);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lock release failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config loading ─────────────────────────────────────────────────
|
||||
|
||||
private loadViewConfig(): MatViewRefreshConfig[] {
|
||||
const raw = this.config.get<string>('MATVIEW_REFRESH_VIEWS');
|
||||
if (!raw) return DEFAULT_VIEWS;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MatViewRefreshConfig[];
|
||||
if (!Array.isArray(parsed)) throw new Error('Expected JSON array');
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Invalid MATVIEW_REFRESH_VIEWS config: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'RefreshMatView',
|
||||
);
|
||||
return DEFAULT_VIEWS;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user