This commit is contained in:
Nathnael
2026-08-12 07:53:05 +00:00
parent d4767e0d06
commit 03e498d6e8
11 changed files with 391 additions and 26 deletions

View File

@@ -114,7 +114,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { RequestLogMiddleware } from "@edr/api-common";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -392,7 +392,9 @@ export class AppModule implements OnApplicationBootstrap {
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
// FIRST: opens the request log context every later middleware/guard/service
// writes into via logCtx(). Anything applied above it logs into the void.
consumer.apply(RequestLogMiddleware).forRoutes("*");
consumer
.apply(LoginAudienceMiddleware)
.forRoutes(

View File

@@ -0,0 +1,102 @@
import { Logger } from "@nestjs/common";
import {
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", () => {
const lines: string[] = [];
jest
.spyOn(Logger.prototype, "warn")
.mockImplementation((m) => lines.push(String(m)));
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(JSON.parse(lines[0])).toMatchObject({
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();
});
});

View File

@@ -1,21 +0,0 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -63,6 +63,21 @@ describe('ETradeService business selection', () => {
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('survives the null entries eTrade puts in SubGroups', () => {
const { service } = build();
const info = companyInfo();
(info.Businesses[0] as any).SubGroups = [
null,
{ Code: 66331, Description: 'Export trade in minerals' },
{ Code: 1, Description: null },
];
const data = service.extractRegistrationData(
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
info,
);
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(

View File

@@ -142,7 +142,8 @@ export class ETradeService {
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
// eTrade also puts null entries in this array, so every hop is optional.
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",