Merge: suíte de testes de integração (agente eden-qa)

This commit is contained in:
2026-09-03 11:16:13 -03:00
12 changed files with 1767 additions and 3 deletions

22
apps/api/.swcrc Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"keepClassNames": true,
"target": "es2022",
"loose": false,
"externalHelpers": false
},
"module": {
"type": "es6"
}
}

View File

@@ -6,9 +6,10 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/main.js", "start": "node dist/main.js",
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"lint": "echo 'no linter configured yet'", "lint": "echo 'no linter configured yet'",
"test": "echo 'no tests yet'" "test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@eden/config": "workspace:*", "@eden/config": "workspace:*",
@@ -28,10 +29,16 @@
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^10.4.15",
"@swc/core": "^1.10.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3" "typescript": "^5.7.3",
"unplugin-swc": "^1.5.1",
"vitest": "^2.1.8"
} }
} }

143
apps/api/test/auth.spec.ts Normal file
View File

@@ -0,0 +1,143 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agent,
bearer,
closeTestApp,
createTestApp,
loginSuperAdmin,
qaEmail,
type AuthedTokens,
} from "./setup.js";
/**
* Protects the session/refresh-token invariants of ADR-0003
* (docs/adr/0003-auth-sessions.md) and apps/api/src/auth/auth.service.ts.
*
* Fixture: one throwaway user (role_key='user', a pre-existing system
* role — no new role is created here), invited via the real
* POST /v1/users/invite flow as super_admin. Every session created for it
* is a byproduct of real login/refresh/logout calls, never a hand-inserted
* row — replay detection specifically must be proven end-to-end through
* the actual rotation chain, not simulated.
*
* Cleanup: the fixture user is deactivated (PATCH isActive:false) in
* afterAll, which also revokes any sessions still active — there is no
* DELETE /v1/users endpoint. Session rows themselves are left in place
* (there's no session-deletion endpoint either, and they're an expected
* append-only-ish audit trail of real auth activity, not "dirty" state);
* they all point at a deactivated, obviously-tagged (qa-test-) fixture
* user.
*/
describe("auth — session lifecycle invariants", () => {
let app: INestApplication;
let admin: AuthedTokens;
let fixtureEmail: string;
let fixtureUserId: string;
let fixturePassword: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
fixtureEmail = qaEmail("auth");
const invite = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email: fixtureEmail, roleKey: "user", applications: ["core"] })
.expect(201);
fixtureUserId = invite.body.user.id;
fixturePassword = invite.body.tempPassword;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/users/${fixtureUserId}`)
.set(...bearer(admin.accessToken))
.send({ isActive: false })
.expect(200);
await closeTestApp(app);
});
describe("login — anti-enumeration (eden.md §4.1)", () => {
it("wrong password for an existing account returns a generic 401", async () => {
const res = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: "definitely-the-wrong-password", applicationKey: "core" })
.expect(401);
expect(res.body.message).toBe("Credenciais inválidas");
});
it("a non-existent account returns the exact same 401 message as a wrong password", async () => {
const res = await agent(app)
.post("/api/v1/auth/login")
.send({ email: qaEmail("nobody"), password: "whatever-12345", applicationKey: "core" })
.expect(401);
// Invariant: the response must not let a caller distinguish
// "account doesn't exist" from "account exists, wrong password" —
// if this message ever diverges from the wrong-password case above,
// the API becomes an account-enumeration oracle.
expect(res.body.message).toBe("Credenciais inválidas");
});
});
describe("refresh — rotation (ADR-0003)", () => {
it("a successful refresh returns a different refresh token than the one submitted", async () => {
const session = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
const originalRefreshToken = session.body.refreshToken as string;
const refreshed = await agent(app)
.post("/api/v1/auth/refresh")
.send({ refreshToken: originalRefreshToken })
.expect(200);
expect(refreshed.body.refreshToken).toBeTypeOf("string");
expect(refreshed.body.refreshToken).not.toBe(originalRefreshToken);
});
});
describe("refresh — replay detection revokes the whole session chain (ADR-0003)", () => {
it("reusing an already-rotated refresh token is rejected AND invalidates the token that replaced it", async () => {
const session = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
const firstToken = session.body.refreshToken as string;
const rotated = await agent(app)
.post("/api/v1/auth/refresh")
.send({ refreshToken: firstToken })
.expect(200);
const secondToken = rotated.body.refreshToken as string;
expect(secondToken).not.toBe(firstToken);
// Replay: reuse the now-rotated-away first token.
await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: firstToken }).expect(401);
// The specific invariant under test: replay of a revoked token must
// revoke the ENTIRE session chain defensively (auth.service.ts
// refresh() -> revokeAllUserSessions), not just reject the replayed
// token. So the token that legitimately replaced it must now also
// be dead, even though it was never itself reused or leaked.
await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: secondToken }).expect(401);
});
});
describe("logout — idempotent", () => {
it("calling logout twice with the same refresh token succeeds both times", async () => {
const session = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
const refreshToken = session.body.refreshToken as string;
await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204);
// Second call: session is already revoked/gone — auth.service.ts's
// logout() treats "no active session found" as success, not 404/500.
await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204);
});
});
});

View File

@@ -0,0 +1,65 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
/**
* Protects the CNPJ digit-check invariant in
* apps/api/src/companies/companies.service.ts (assertValidCnpjIfPresent ->
* @eden/domain-shared's isValidCNPJ) — format validation only, per that
* function's own doc comment ("never confirms a document actually exists
* or belongs to whoever claims it").
*
* The valid-CNPJ fixture uses 11.444.777/0001-61 — verified against the
* same digit-check algorithm (packages/domain-shared/src/validators/
* br-documents.ts) before writing this test, and confirmed (by querying
* the shared dev Postgres directly) NOT already used by any company row,
* unlike 11.222.333/0001-81 which the seeded "Handix Telecom LTDA" company
* already holds.
*
* Cleanup: there is no DELETE /v1/companies endpoint, so the company
* created by the "valid CNPJ succeeds" test is deactivated
* (PATCH isActive:false) in afterAll rather than deleted.
*/
describe("companies — CNPJ validation invariant", () => {
let app: INestApplication;
let admin: AuthedTokens;
let createdCompanyId: string | undefined;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
});
afterAll(async () => {
if (createdCompanyId) {
await agent(app)
.patch(`/api/v1/companies/${createdCompanyId}`)
.set(...bearer(admin.accessToken))
.send({ isActive: false })
.expect(200);
}
await closeTestApp(app);
});
it("rejects a CNPJ that fails the check-digit algorithm with 400", async () => {
const res = await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- Empresa CNPJ Inválido", cnpj: "11.222.333/0001-00" })
.expect(400);
expect(res.body.message).toContain("CNPJ inválido");
});
it("accepts a company with a valid, not-already-used CNPJ", async () => {
const res = await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- Empresa CNPJ Válido", cnpj: "11.444.777/0001-61" })
.expect(201);
expect(res.body.id).toBeTypeOf("string");
// Persisted digits-only, per companies.service.ts's onlyDigits(dto.cnpj).
expect(res.body.cnpj).toBe("11444777000161");
createdCompanyId = res.body.id;
});
});

View File

@@ -0,0 +1,58 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
/**
* Protects the global ValidationPipe wiring itself
* (whitelist: true, forbidNonWhitelisted: true — apps/api/src/main.ts,
* mirrored in test/setup.ts's createTestApp). Master Prompt §12: the
* server never trusts a client-supplied field it didn't ask for. This
* doesn't test any one DTO's specific fields so much as prove the pipe
* is actually reaching these routes and actually configured to reject
* (not silently strip) unknown fields.
*
* No fixtures are created — every request here is expected to be
* rejected by the pipe *before* the controller/service runs, so nothing
* ever reaches the database. No cleanup needed.
*/
describe("mass-assignment — unexpected fields are rejected, not silently dropped", () => {
let app: INestApplication;
let admin: AuthedTokens;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
});
afterAll(async () => {
await closeTestApp(app);
});
it("POST /v1/companies rejects an unexpected 'id' field", async () => {
await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- mass assignment id", id: "11111111-1111-1111-1111-111111111111" })
.expect(400);
});
it("POST /v1/companies rejects an unexpected 'isActive' field (not part of CreateCompanyDto)", async () => {
await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- mass assignment isActive", isActive: false })
.expect(400);
});
it("POST /v1/products rejects an unexpected 'createdBy' field", async () => {
await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: "qa-test- mass assignment product",
productType: "service",
createdBy: "11111111-1111-1111-1111-111111111111",
})
.expect(400);
});
});

View File

@@ -0,0 +1,120 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { query } from "@eden/database";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens } from "./setup.js";
/**
* Protects the fix in commit 8d108e0 ("fix: assign-user de reseller usava
* resellerId do body, não o :id da URL"): the reseller a user is assigned
* to must always be the `:id` in the URL, never a value read from the
* request body. AssignUserToResellerDto (apps/api/src/resellers/dto/
* assign-user-to-reseller.dto.ts) now only declares `userId` — it doesn't
* accept `resellerId` at all — so the strongest proof available at the
* HTTP layer is that a body containing `resellerId` is rejected outright
* by the mass-assignment whitelist (a body that could "win" over the URL
* literally cannot reach the service layer any more). This is backed up
* with a direct read of `users.reseller_id` after a legitimate call,
* confirming the URL's reseller is the one actually persisted.
*
* Fixtures: two throwaway resellers + one throwaway user (all
* `qa-test-`/`qa_` tagged). Cleanup: unassign the user (reseller_id back
* to null, exercised by test (b) itself), then deactivate the user and
* both resellers — no DELETE endpoint exists for either resource.
*/
describe("resellers — assign-user URL-wins-over-body invariant", () => {
let app: INestApplication;
let admin: AuthedTokens;
let resellerAId: string;
let resellerBId: string;
let userId: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
const resellerA = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Reseller A (target)" })
.expect(201);
resellerAId = resellerA.body.id;
const resellerB = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Reseller B (decoy in body)" })
.expect(201);
resellerBId = resellerB.body.id;
const invite = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email: qaEmail("reseller-assignee"), roleKey: "user", applications: ["core"] })
.expect(201);
userId = invite.body.user.id;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/users/${userId}`)
.set(...bearer(admin.accessToken))
.send({ isActive: false })
.expect(200);
await agent(app)
.patch(`/api/v1/resellers/${resellerAId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await agent(app)
.patch(`/api/v1/resellers/${resellerBId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("a 'resellerId' in the body is rejected by the whitelist — there is no way to redirect the assignment via the body", async () => {
await agent(app)
.patch(`/api/v1/resellers/${resellerAId}/assign-user`)
.set(...bearer(admin.accessToken))
.send({ userId, resellerId: resellerBId })
.expect(400);
// Confirm the rejected request didn't partially apply anything.
const { rows } = await query<{ reseller_id: string | null }>(
"SELECT reseller_id FROM users WHERE id = $1",
[userId],
);
expect(rows[0]?.reseller_id).toBeNull();
});
it("assigning via the URL's :id actually persists that reseller (not the decoy)", async () => {
const res = await agent(app)
.patch(`/api/v1/resellers/${resellerAId}/assign-user`)
.set(...bearer(admin.accessToken))
.send({ userId })
.expect(200);
expect(res.body.resellerId).toBe(resellerAId);
const { rows } = await query<{ reseller_id: string | null }>(
"SELECT reseller_id FROM users WHERE id = $1",
[userId],
);
expect(rows[0]?.reseller_id).toBe(resellerAId);
});
it("POST /v1/resellers/unassign-user clears the assignment", async () => {
const res = await agent(app)
.post("/api/v1/resellers/unassign-user")
.set(...bearer(admin.accessToken))
.send({ userId })
.expect(201);
expect(res.body.resellerId).toBeNull();
const { rows } = await query<{ reseller_id: string | null }>(
"SELECT reseller_id FROM users WHERE id = $1",
[userId],
);
expect(rows[0]?.reseller_id).toBeNull();
});
});

View File

@@ -0,0 +1,124 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agent,
bearer,
closeTestApp,
createTestApp,
login,
loginSuperAdmin,
qaEmail,
qaKey,
type AuthedTokens,
} from "./setup.js";
/**
* Protects the role-weight invariant of apps/api/src/auth/role-weight.ts
* (docs/adr/0004-permission-model.md, eden.md §1.6): an actor can never
* assign/act on a role heavier than their own. Two distinct regressions
* are guarded here:
*
* (a) an unknown role_key must fail as a clean 400, not an unhandled
* DB FK-constraint 500 — see role-weight.ts's own comment on this
* exact bug ("this function must be the single place that turns
* 'role doesn't exist' into a clean 400, not the DB constraint").
* (b) the weight comparison itself: a low-weight actor must be
* forbidden (403) from assigning a heavier role than their own,
* even when they otherwise hold the users:edit permission needed to
* reach the check at all.
*/
describe("role-weight — assignment invariant", () => {
let app: INestApplication;
let admin: AuthedTokens;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
});
afterAll(async () => {
await closeTestApp(app);
});
it("inviting a user with a role_key that doesn't exist returns 400, not 500 (regression guard)", async () => {
const email = qaEmail("role-badkey");
const res = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email, roleKey: "does_not_exist_role_xyz" })
.expect(400);
expect(res.body.message).toContain("does_not_exist_role_xyz");
// The invite must have been rejected before any row was written —
// confirm no user with this email exists.
const list = await agent(app)
.get("/api/v1/users")
.set(...bearer(admin.accessToken))
.expect(200);
expect(list.body.some((u: { email: string }) => u.email === email.toLowerCase())).toBe(false);
});
describe("a lower-weight actor cannot assign a heavier role", () => {
let lowRoleKey: string;
let lowActorUserId: string;
let lowActorEmail: string;
let lowActorPassword: string;
let lowActorTokens: AuthedTokens;
beforeAll(async () => {
// 1. A fresh, low-weight role (weight 5 — lighter than every
// system role except none; lighter than 'admin' at 80), granted
// users:edit/all so the actor reaches the role-weight check itself
// rather than being stopped earlier by PermissionGuard — isolating
// the specific invariant under test.
lowRoleKey = qaKey("lowrole");
await agent(app)
.post("/api/v1/roles")
.set(...bearer(admin.accessToken))
.send({ key: lowRoleKey, label: "QA test low-weight role", weight: 5 })
.expect(201);
await agent(app)
.patch(`/api/v1/roles/${lowRoleKey}/permissions`)
.set(...bearer(admin.accessToken))
.send({ permissions: [{ resource: "users", action: "edit", scope: "all" }] })
.expect(200);
// 2. A throwaway user in that role, logged in for real.
lowActorEmail = qaEmail("role-lowactor");
const invite = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email: lowActorEmail, roleKey: lowRoleKey, applications: ["core"] })
.expect(201);
lowActorUserId = invite.body.user.id;
lowActorPassword = invite.body.tempPassword;
lowActorTokens = await login(app, lowActorEmail, lowActorPassword);
});
afterAll(async () => {
// Reassign the fixture user back to a system role and deactivate it
// so the throwaway role has zero referencing users left, then
// delete the throwaway role (RolesService.delete refuses to delete
// a role that still has users on it) — full cleanup, not just a
// tag, since both a DELETE-role and a PATCH-user endpoint exist.
await agent(app)
.patch(`/api/v1/users/${lowActorUserId}`)
.set(...bearer(admin.accessToken))
.send({ roleKey: "user", isActive: false })
.expect(200);
await agent(app)
.delete(`/api/v1/roles/${lowRoleKey}`)
.set(...bearer(admin.accessToken))
.expect(204);
});
it("holding users:edit is not enough — assigning role 'admin' (weight 80 > actor's 5) is forbidden", async () => {
const res = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(lowActorTokens.accessToken))
.send({ email: qaEmail("role-target"), roleKey: "admin" })
.expect(403);
expect(res.body.message).toMatch(/peso maior/i);
});
});
});

120
apps/api/test/setup.ts Normal file
View 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);
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

60
apps/api/vitest.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
import swc from "unplugin-swc";
/**
* These are integration tests, not unit tests — they need DATABASE_URL,
* JWT_SIGNING_SECRET, EDEN_SUPERADMIN_EMAIL/PASSWORD, etc. from the repo
* root `.env` (gitignored, never read by vitest automatically the way
* Next/Vite's client env loading does). Parsed by hand here (no `dotenv`
* dependency needed) and passed through `test.env` so it reaches the
* worker process(es) vitest actually runs specs in.
*/
function loadRootEnv(): Record<string, string> {
const envPath = resolve(__dirname, "../../.env");
if (!existsSync(envPath)) return {};
const out: Record<string, string> = {};
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
/**
* Integration test config (eden-qa). Every spec under test/ boots a real,
* fully-wired Nest app (Test.createTestingModule + AppModule) against a
* real Postgres — no mocked DB layer, per the eden-qa persona's rule.
* Timeout is generous (15s default) because some tests hit the DB several
* times in sequence and hash passwords with Argon2id (intentionally slow).
*/
export default defineConfig({
// NestJS's DI resolves constructor parameter types from
// `design:paramtypes` metadata emitted by the TS decorator-metadata
// transform. Vite's default esbuild transform does NOT implement
// `emitDecoratorMetadata` (esbuild is a single-file transformer with no
// type checker to resolve types from) — every @Injectable()/@Controller
// class would silently receive `undefined` for its dependencies instead
// of a real instance. Using SWC (which does implement it, matching
// `tsc`'s behavior) here is the standard fix for testing Nest apps with
// Vitest — see https://docs.nestjs.com/recipes/swc#vitest.
plugins: [swc.vite()],
test: {
environment: "node",
include: ["test/**/*.spec.ts"],
testTimeout: 15000,
hookTimeout: 15000,
// Integration tests share one real Postgres connection pool + one
// seeded super_admin account; running spec files in parallel worker
// processes risks session/role-cleanup races between files (e.g. two
// files both minting/deleting similarly-named throwaway roles). Runs
// sequentially, file by file — still fully real, just not concurrent.
fileParallelism: false,
globals: false,
env: { ...loadRootEnv(), ...process.env },
},
});

1029
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,5 +4,6 @@ packages:
allowBuilds: allowBuilds:
'@nestjs/core': true '@nestjs/core': true
'@scarf/scarf': false '@scarf/scarf': false
'@swc/core': true
argon2: true argon2: true
esbuild: true esbuild: true