mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Emit the request line as raw JSON on stdout (level/time/logger as fields) instead of through Nest's console logger, whose prefix made it unparsable. Collect data points via logCtx at the flow chokepoints: BaseRepository writes (status changes, creates, deletes), invoice transitions, payment intent lifecycle + outbound payment-service calls, booking/contract entry state, review-note reasons, signatures and OTP verify outcomes.
147 lines
4.8 KiB
TypeScript
147 lines
4.8 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" },
|
|
ip: "10.0.0.1",
|
|
query: { dry: "1" },
|
|
user: { id: "u-7" },
|
|
};
|
|
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(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" }],
|
|
});
|
|
});
|
|
});
|