import { Logger } from "@nestjs/common"; import type { Repository } from "typeorm"; import { BaseRepository, RequestLogMiddleware, getLogContext, logCtx, runWithLogContext, } from "@edr/api-common"; describe("logCtx", () => { it("is a no-op outside a request", () => { expect(() => logCtx({ bookingId: "b1" })).not.toThrow(); expect(getLogContext()).toBeUndefined(); }); it("collects data points across the request and isolates concurrent ones", async () => { const collect = async (id: string) => runWithLogContext({ requestId: id }, async () => { logCtx({ bookingId: id }); await Promise.resolve(); logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" }); logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" }); logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" }); logCtx(1, { path: "smsSent", mode: "count" }); logCtx(1, { path: "smsSent", mode: "count" }); logCtx("PAID", { path: "payment.state", mode: "set" }); logCtx({ ignored: true }, (ctx) => { ctx.custom = "yes"; }); return getLogContext(); }); const [a, b] = await Promise.all([collect("r1"), collect("r2")]); expect(a).toEqual({ requestId: "r1", bookingId: "r1", booking: { status: { from: "DRAFT", to: "SUBMITTED" } }, wagons: [{ wagonId: "w1" }, { wagonId: "w2" }], smsSent: 2, payment: { state: "PAID" }, custom: "yes", }); expect(b?.requestId).toBe("r2"); expect(b?.bookingId).toBe("r2"); }); }); describe("RequestLogMiddleware", () => { it("emits one canonical JSON line carrying the collected context", () => { // Raw stdout, not the Nest logger — the line must be parsable JSON with no // "[Nest] … LOG [request]" prefix in front of it. const lines: string[] = []; jest.spyOn(process.stdout, "write").mockImplementation((chunk) => { lines.push(String(chunk)); return true; }); jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); const listeners: Record void> = {}; const req = { method: "POST", url: "/api/bookings/1/submit", originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, headers: { "user-agent": "jest", "x-request-id": "req-42", authorization: "Bearer tok", "x-client-app": "freight-backoffice", "current-project-id": "proj-3", }, ip: "10.0.0.1", query: { dry: "1" }, user: { id: "u-7", sessionId: "sess-9", userType: "STAFF", status: "ACTIVE", username: "nati", email: "nati@example.com", phoneNumber: "0911000000", name: { en: "Nati" }, roles: [{ key: "freight_operations" }], permissions: [{ key: "a" }, { key: "b" }], employee: { id: "emp-1", organizationId: "org-1", unitId: "unit-2", position: { id: "pos-5", key: "ops_officer", employeePositionId: "ep-6", isDelegate: true, delegatorId: "pos-1", positionType: { key: "operations" }, }, }, }, }; const res = { statusCode: 409, writableEnded: true, setHeader: jest.fn(), on: (event: string, fn: () => void) => { listeners[event] = fn; }, }; new RequestLogMiddleware().use(req, res, () => { logCtx({ bookingId: "b-1" }); logCtx("REJECTED", { path: "booking.outcome", mode: "set" }); }); listeners.finish(); listeners.close(); // aborts/close after finish must not double-log expect(lines).toHaveLength(1); expect(lines[0].endsWith("\n")).toBe(true); expect(lines[0].startsWith("{")).toBe(true); expect(JSON.parse(lines[0])).toMatchObject({ level: "warn", logger: "request", type: "http_request", requestId: "req-42", method: "POST", route: "/api/bookings/:id/submit", url: "/api/bookings/1/submit?dry=1", status: 409, userId: "u-7", ip: "10.0.0.1", userAgent: "jest", query: { dry: "1" }, bookingId: "b-1", booking: { outcome: "REJECTED" }, }); expect(JSON.parse(lines[0]).auth).toEqual({ authenticated: true, hasBearer: true, clientApp: "freight-backoffice", userId: "u-7", sessionId: "sess-9", userType: "STAFF", userStatus: "ACTIVE", roles: ["freight_operations"], permissionCount: 2, employeeId: "emp-1", organizationId: "org-1", unitId: "unit-2", positionId: "pos-5", positionKey: "ops_officer", positionType: "operations", employeePositionId: "ep-6", isDelegate: true, delegatorId: "pos-1", projectId: "proj-3", }); // No personal data reaches the line, whatever the token carried. expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); }); describe("BaseRepository write trail", () => { class TestRepo extends BaseRepository<{ id: string; status?: string }> { constructor(repo: Repository<{ id: string; status?: string }>) { super(repo); } } const typeormRepo = { metadata: { tableName: "booking" }, create: (data: unknown) => data, save: async (data: unknown) => data, update: async () => undefined, findOne: async () => ({ id: "b-1", status: "SUBMITTED" }), softDelete: async () => undefined, delete: async () => undefined, } as unknown as Repository<{ id: string; status?: string }>; it("records creates, status changes and deletes without any service opting in", async () => { const ctx = await runWithLogContext({}, async () => { const repo = new TestRepo(typeormRepo); await repo.create({ id: "b-1" }); await repo.update("b-1", { status: "SUBMITTED" }); await repo.update("b-1", { id: "b-1" }); // no status → no transition entry await repo.softDelete("b-1"); return getLogContext(); }); expect(ctx).toEqual({ db: { created: { booking: 1 }, updated: { booking: 2 } }, statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }], deleted: [{ entity: "booking", id: "b-1" }], }); }); });