test: add EDR passenger pricing/config E2E bug-hunt harness

Hermetic E2E harness targeting pricing integrity and backoffice config:
- e2e/ docker Postgres (5544) + prepare.sh/run.sh one-command runner + HTML report
- 6 suites / 23 tests reproducing pricing, FX, wallet, refund, config and auth
  defects (see docs/ISSUES.md); docs/e2e-test-matrix.md documents the matrix
- two-tier harness (slim module boot + direct service instantiation) to work
  around the IAM/RabbitMQ/file-type boot wall
- .env.test.example tracked; loader falls back to it for fresh checkouts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Muluhabt
2026-07-20 16:22:40 +03:00
parent 5d94e8acf2
commit c4f54a666b
24 changed files with 1938 additions and 2 deletions

View File

@@ -0,0 +1,39 @@
/**
* Loads apps/edr-passenger-api/.env.test into process.env BEFORE the Nest AppModule boots.
* Registered as a jest `setupFile` (runs per test file, before the framework and before any
* `Test.createTestingModule`). Zero-dependency KEY=VALUE parser — dotenv is not a direct dep here.
* Existing process.env values win (so CI can override the DB URL without editing the file).
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Prefer a local (gitignored) .env.test; fall back to the tracked .env.test.example so a fresh
// checkout of the branch runs the suites without a manual copy step.
const localPath = join(__dirname, "..", "..", ".env.test");
const examplePath = join(__dirname, "..", "..", ".env.test.example");
const envPath = existsSync(localPath) ? localPath : examplePath;
try {
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
// strip surrounding quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
} catch (err) {
// Surface loudly — a missing .env.test means every suite would boot against the wrong DB.
throw new Error(
`[load-env] could not read ${envPath}: ${(err as Error).message}`,
);
}

View File

@@ -0,0 +1,26 @@
/**
* Singleton PrismaClient against the hermetic test DB (DATABASE_URL from .env.test, loaded by
* setup/load-env.ts). Used by:
* - the fixture seeder (fixtures/seed-core.ts), and
* - "direct-instantiation" specs for services behind the IAM/RabbitMQ wall (BookingsService,
* PaymentsService, WalletService, …) which cannot be booted through their Nest modules because
* those transitively import the @tria-plc IAM stack (ESM-only `file-type`) / golevelup RabbitMQ.
* Those specs `new TheService(prisma, ...mockedCollaborators)` and assert the money logic.
*/
import { PrismaClient } from "@prisma/client";
let client: PrismaClient | undefined;
export function getTestPrisma(): PrismaClient {
if (!client) {
client = new PrismaClient();
}
return client;
}
export async function disconnectTestPrisma(): Promise<void> {
if (client) {
await client.$disconnect();
client = undefined;
}
}

View File

@@ -0,0 +1,145 @@
/**
* Slim Nest test harness — boots ONLY the passenger domain modules needed for pricing/booking
* tests, deliberately excluding the IAM (TriaIamModule), SharedAuth, and MinIO stack from
* app.module.ts. Those drag in `@tria-plc/api-common`'s file-crud/minio chain which requires the
* ESM-only `file-type` package that jest's CommonJS resolver cannot load.
*
* Two entry points:
* - createServiceHarness(): resolve services directly (FareEngineService, etc.) for unit/DB-level
* assertions on the money math.
* - createHttpHarness(): a full Nest HTTP app with the SAME global ValidationPipe as main.ts, so
* controller/DTO/pipe behavior (client-trust, DTO validation) is exercised end-to-end over HTTP.
*
* The IAM JwtGuard is overridden with an always-allow stub so protected routes are reachable; auth
* *enforcement* findings (which guards are missing) are asserted separately via route metadata, not
* by booting the real guard.
*/
import { Global, INestApplication, Module, ValidationPipe } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigModule } from "@nestjs/config";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { ScheduleModule } from "@nestjs/schedule";
import { getDataSourceToken } from "@nestjs/typeorm";
import { PrismaClient } from "@prisma/client";
import { PrismaModule } from "../../src/common/prisma.module";
import { PrismaService } from "../../src/common/prisma.service";
import { SessionActivityInterceptor } from "../../src/common/interceptors/session-activity.interceptor";
import { FareEngineModule } from "../../src/modules/fare-engine/fare-engine.module";
import { CurrencyModule } from "../../src/modules/currency/currency.module";
import { CurrenciesModule } from "../../src/modules/currencies/currencies.module";
import { PromosModule } from "../../src/modules/promos/promos.module";
import { SeatClassesModule } from "../../src/modules/seat-classes/seat-classes.module";
import { StationsModule } from "../../src/modules/stations/stations.module";
import { SchedulesModule } from "../../src/modules/schedules/schedules.module";
import { SegmentsModule } from "../../src/modules/segments/segments.module";
import { SystemConfigModule } from "../../src/modules/system-config/system-config.module";
/**
* A stub TypeORM DataSource, provided globally so IAM-derived providers that reach the slim
* harness transitively (e.g. NotificationsService via ExcessBaggageModule) can instantiate.
* Pricing tests never trigger the code paths that actually use it.
*/
const fakeDataSource = {
query: async () => [],
transaction: async (cb: (m: unknown) => unknown) => cb({}),
getRepository: () => ({}),
createQueryRunner: () => ({
connect: async () => undefined,
startTransaction: async () => undefined,
commitTransaction: async () => undefined,
rollbackTransaction: async () => undefined,
release: async () => undefined,
manager: {},
}),
};
@Global()
@Module({
providers: [{ provide: getDataSourceToken(), useValue: fakeDataSource }],
exports: [getDataSourceToken()],
})
class TestGlobalsModule {}
/** Modules that are safe to import in isolation (verified free of the IAM/MinIO chain). */
const DOMAIN_MODULES = [
FareEngineModule,
CurrencyModule,
CurrenciesModule,
PromosModule,
SeatClassesModule,
StationsModule,
SchedulesModule,
SegmentsModule,
SystemConfigModule,
];
// NOTE: ExcessBaggageModule/PaymentsModule/BookingsModule are intentionally excluded — they pull in
// NotificationsModule → @golevelup RabbitMQ which connects at boot. Their suites instantiate the
// service directly with mocked collaborators (see excess-baggage / booking-trust specs).
async function buildModule(): Promise<TestingModule> {
return Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
TestGlobalsModule,
PrismaModule,
...DOMAIN_MODULES,
],
})
// SessionActivityInterceptor needs the IAM TypeORM DataSource, which the slim harness
// deliberately omits. Replace it with a pass-through — it does not affect pricing logic.
.overrideProvider(SessionActivityInterceptor)
.useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() })
.compile();
}
export interface ServiceHarness {
moduleRef: TestingModule;
prisma: PrismaClient;
close: () => Promise<void>;
}
/** Resolve services for direct method-level assertions. */
export async function createServiceHarness(): Promise<ServiceHarness> {
const moduleRef = await buildModule();
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
return {
moduleRef,
prisma,
close: async () => {
await moduleRef.close();
},
};
}
export interface HttpHarness {
app: INestApplication;
moduleRef: TestingModule;
prisma: PrismaClient;
close: () => Promise<void>;
}
/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */
export async function createHttpHarness(): Promise<HttpHarness> {
const moduleRef = await buildModule();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidUnknownValues: false,
}),
);
await app.init();
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
return {
app,
moduleRef,
prisma,
close: async () => {
await app.close();
},
};
}

View File

@@ -0,0 +1,10 @@
/**
* CommonJS stub for the ESM-only `file-type` package (v21). jest's CommonJS resolver cannot load
* the real one, and `@tria-plc/api-common`'s minio.service `require("file-type")` at import time,
* dragging the whole IAM stack down with it. minio.service only calls fileTypeFromBuffer when
* actually processing an upload — never during pricing/booking tests — so a stub is sufficient to
* let the full AppModule boot. Mapped via jest `moduleNameMapper` (^file-type$).
*/
export async function fileTypeFromBuffer(): Promise<undefined> {
return undefined;
}