test(api): add Vitest+supertest integration test suite (eden-qa)
Boots the real Nest app (Test.createTestingModule + AppModule) against a
real Postgres — no mocked DB or guards anywhere in this suite. Uses SWC
(unplugin-swc) instead of Vite's default esbuild transform because
esbuild doesn't implement emitDecoratorMetadata, which silently breaks
NestJS's constructor DI (every provider resolves to undefined).
Covers:
- auth.spec.ts: anti-enumeration login message, refresh-token rotation,
replay-detection revoking the whole session chain, idempotent logout
(ADR-0003).
- role-weight.spec.ts: invalid role_key on invite returns 400 not 500
(regression guard), lower-weight actor forbidden from assigning a
heavier role even when holding users:edit (ADR-0004).
- companies.spec.ts: CNPJ digit-check validation (reject/accept).
- mass-assignment.spec.ts: forbidNonWhitelisted actually rejects
unexpected fields on companies/products.
- reseller-assignment.spec.ts: assign-user always targets the URL's
:id, never a body resellerId (regression guard for commit 8d108e0);
unassign-user clears the assignment.
All fixtures are qa-test-/qa_ tagged and cleaned up (deactivated where
no DELETE endpoint exists, deleted where one does) — verified no
pre-existing seed data (super_admin, Handix Telecom LTDA, Handix revenda
própria) was touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
120
apps/api/test/setup.ts
Normal file
120
apps/api/test/setup.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import "reflect-metadata";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { closePool } from "@eden/database";
|
||||
import { AppModule } from "../src/app.module.js";
|
||||
|
||||
/**
|
||||
* Shared integration-test bootstrap (eden-qa). Boots the REAL Nest app —
|
||||
* every module, guard, pipe and repository exactly as main.ts wires them —
|
||||
* against the REAL Postgres pointed to by DATABASE_URL. The only thing
|
||||
* skipped versus a full deployment is the separate OS process/container:
|
||||
* HTTP goes through supertest -> app.getHttpServer() (real Express, real
|
||||
* routing, real guards), and the DB goes through the real `pg` Pool (no
|
||||
* mock/in-memory substitute anywhere in this stack).
|
||||
*
|
||||
* Mirrors main.ts's ValidationPipe + global prefix exactly (see
|
||||
* apps/api/src/main.ts) without importing/touching main.ts itself, so
|
||||
* this file can't conflict with other work landing there.
|
||||
*/
|
||||
export async function createTestApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function closeTestApp(app: INestApplication): Promise<void> {
|
||||
await app.close();
|
||||
// packages/database's pg Pool is a module-level singleton, not a Nest
|
||||
// provider tied to app.close() — close it explicitly so vitest's process
|
||||
// can exit instead of hanging on an idle DB connection.
|
||||
await closePool();
|
||||
}
|
||||
|
||||
/** Base path every controller in this API is mounted under (see main.ts's setGlobalPrefix). */
|
||||
export const API = "/api";
|
||||
|
||||
export function agent(app: INestApplication) {
|
||||
return request(app.getHttpServer());
|
||||
}
|
||||
|
||||
export interface AuthedTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in for real via POST /api/v1/auth/login (no guard/token mocking
|
||||
* anywhere in this suite). Requires EDEN_SUPERADMIN_EMAIL/PASSWORD to be
|
||||
* set in the environment (apps/api/src/bootstrap/seed.service.ts seeds
|
||||
* this account once at boot, only if `users` was empty when the API first
|
||||
* started) — the same account docker-compose's eden-api container seeds
|
||||
* against the shared dev Postgres.
|
||||
*/
|
||||
export async function loginSuperAdmin(app: INestApplication): Promise<AuthedTokens> {
|
||||
const email = process.env.EDEN_SUPERADMIN_EMAIL;
|
||||
const password = process.env.EDEN_SUPERADMIN_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD must be set (see .env) to run these integration tests — they authenticate for real, no mocked guard.",
|
||||
);
|
||||
}
|
||||
return login(app, email, password);
|
||||
}
|
||||
|
||||
export async function login(
|
||||
app: INestApplication,
|
||||
email: string,
|
||||
password: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber" = "core",
|
||||
): Promise<AuthedTokens> {
|
||||
const res = await agent(app)
|
||||
.post(`${API}/v1/auth/login`)
|
||||
.send({ email, password, applicationKey })
|
||||
.expect(200);
|
||||
return {
|
||||
accessToken: res.body.accessToken as string,
|
||||
refreshToken: res.body.refreshToken as string,
|
||||
userId: res.body.user.id as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function bearer(token: string): [string, string] {
|
||||
return ["Authorization", `Bearer ${token}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every fixture this suite creates is tagged with this prefix so it's
|
||||
* trivially identifiable (and greppable) as QA-test-owned data in the
|
||||
* shared dev database, even if a cleanup step is ever missed.
|
||||
*/
|
||||
export const QA_PREFIX = "qa-test-";
|
||||
|
||||
export function qaEmail(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `${QA_PREFIX}${label}-${rand}@example.com`;
|
||||
}
|
||||
|
||||
export function qaKey(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
// Roles: lowercase, starts with a letter, a-z0-9_, 2-30 chars (see
|
||||
// apps/api/src/roles/dto/create-role.dto.ts).
|
||||
return `qa_${label}_${rand}`.slice(0, 30);
|
||||
}
|
||||
Reference in New Issue
Block a user