import { CallHandler, ConflictException, ExecutionContext, ForbiddenException, HttpException, Injectable, InternalServerErrorException, NestInterceptor, UnauthorizedException, } from "@nestjs/common"; import type { Request, Response } from "express"; import { Observable } from "rxjs"; import { delay, map } from "rxjs/operators"; import { DEMO_SCENARIOS, type DemoScenario } from "./api.dto"; function scenarioFromRequest(request: Request): DemoScenario { const value = String(request.headers["x-demo-scenario"] ?? "normal"); return DEMO_SCENARIOS.includes(value as DemoScenario) ? (value as DemoScenario) : "normal"; } export function assertNoForcedAuthFailure(request: Request): void { const scenario = scenarioFromRequest(request); if (scenario === "expired-auth") { throw new UnauthorizedException({ code: "DEMO_AUTH_EXPIRED", message: "Authentication was expired by the demo scenario.", }); } if (scenario === "forbidden") { throw new ForbiddenException({ code: "DEMO_FORBIDDEN", message: "Access was denied by the demo scenario.", }); } } @Injectable() export class ScenarioInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const http = context.switchToHttp(); const request = http.getRequest(); const response = http.getResponse(); const scenario = scenarioFromRequest(request); response.setHeader("X-Demo-Scenario", scenario); if (scenario === "server-error") { throw new InternalServerErrorException({ code: "DEMO_SERVER_ERROR", message: "Server error forced by X-Demo-Scenario.", }); } if (scenario === "rate-limited") { response.setHeader("Retry-After", "3"); throw new HttpException( { code: "DEMO_RATE_LIMITED", message: "Rate limit forced by X-Demo-Scenario.", }, 429, ); } if ( scenario === "conflict" && !["GET", "HEAD", "OPTIONS"].includes(request.method) ) { throw new ConflictException({ code: "DEMO_CONFLICT", message: "Conflict forced by X-Demo-Scenario.", }); } const configuredDelay = scenario === "slow" ? Number(process.env.MOCK_SLOW_DELAY_MS ?? 1500) : scenario === "timeout" ? Number(process.env.MOCK_TIMEOUT_DELAY_MS ?? 30000) : 0; return next.handle().pipe( configuredDelay > 0 ? delay(configuredDelay) : (source) => source, map((payload) => this.transformPayload(payload, scenario)), ); } private transformPayload(payload: unknown, scenario: DemoScenario): unknown { if (!payload || typeof payload !== "object" || !("data" in payload)) { return payload; } const response = payload as { data: unknown; meta?: Record; }; if (!Array.isArray(response.data)) { return payload; } if (scenario === "empty") { return { ...response, data: [], meta: response.meta ? { ...response.meta, total: 0, totalPages: 0, nextCursor: null, hasMore: false, } : response.meta, }; } if (scenario === "large-dataset" && response.data.length > 0) { const source = response.data as Array>; const data = Array.from({ length: 250 }, (_, index) => { const original = source[index % source.length]; return { ...original, id: `${String(original.id ?? "item")}-scenario-${String(index + 1).padStart(3, "0")}`, }; }); return { ...response, data, meta: response.meta ? { ...response.meta, total: data.length, totalPages: 1, nextCursor: null, hasMore: false, } : response.meta, }; } return payload; } }