diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts new file mode 100644 index 000000000..bc79d8acb --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts @@ -0,0 +1,29 @@ +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +const gateway = () => new NotificationsGateway({} as WsAuthService); + +describe("NotificationsGateway", () => { + it("skips emitNew rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow(); + }); + + it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow(); + }); + + it("pushes to the user's room once a server is attached", () => { + const g = gateway(); + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + (g as unknown as { server: { to: typeof to } }).server = { to }; + + g.emitNew("user-1", { id: "n-1" } as never, 3); + + expect(to).toHaveBeenCalledWith("user:user-1"); + expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" }); + expect(emit).toHaveBeenCalledWith("notification:unread-count", 3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index 0c4704170..4658dfac0 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service"; export class NotificationsGateway implements OnGatewayConnection { private readonly logger = new Logger(NotificationsGateway.name); + // Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP + // listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`. @WebSocketServer() - private readonly server!: Server; + private readonly server?: Server; constructor(private readonly wsAuth: WsAuthService) {} @@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push a freshly-created notification + the new unread count to a user. */ emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + if (!this.server) return this.skip("emitNew"); const room = this.server.to(this.room(userId)); room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); @@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push only an updated unread count (e.g. after a read on another tab). */ emitUnreadCount(userId: string, unreadCount: number): void { + if (!this.server) return this.skip("emitUnreadCount"); this.server .to(this.room(userId)) .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); } + /** + * `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP + * listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not + * for the brief window before `app.listen()` completes in a real boot either. The notification row + * is already persisted by this point (the caller writes it before pushing), so a missing socket + * server just means "no live push this time" — skip it rather than throw and lose the caller's + * own result (e.g. an EIMS registration outcome that already succeeded or failed for real). + */ + private skip(method: string): void { + this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`); + } + private room(userId: string): string { return `user:${userId}`; }