mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user