Files
edr-platform/apps/edr-freight-api/src/common/request-log-context.spec.ts

200 lines
6.3 KiB
TypeScript

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<string, () => 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" }],
});
});
});