mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(notifications): guard emitNew/emitUnreadCount against no WS server
@WebSocketServer() only wires `server` once the WS adapter attaches to a running HTTP listener. It never does under NestFactory.createApplicationContext (scripts, one-off jobs) -- confirmed live tonight, when the EIMS self-test registration's failure alert crashed with "Cannot read properties of null (reading 'to')" instead of just logging that no socket was available. The registration result itself was unaffected (postSigned already resolved, the EimsApiException was correctly re-thrown), but the crash happened inside an await'd call in the same chain -- in a context where it wasn't caught, it would have masked whatever result the caller actually cared about. Both push methods now skip and log at debug level when no server is attached, since the notification row is already persisted by the time they're called -- a missing socket just means "no live push this time", not a reason to lose the caller's own outcome. `server` drops its `!` non-null assertion to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
export class NotificationsGateway implements OnGatewayConnection {
|
export class NotificationsGateway implements OnGatewayConnection {
|
||||||
private readonly logger = new Logger(NotificationsGateway.name);
|
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()
|
@WebSocketServer()
|
||||||
private readonly server!: Server;
|
private readonly server?: Server;
|
||||||
|
|
||||||
constructor(private readonly wsAuth: WsAuthService) {}
|
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. */
|
/** Push a freshly-created notification + the new unread count to a user. */
|
||||||
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
||||||
|
if (!this.server) return this.skip("emitNew");
|
||||||
const room = this.server.to(this.room(userId));
|
const room = this.server.to(this.room(userId));
|
||||||
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
||||||
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
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). */
|
/** Push only an updated unread count (e.g. after a read on another tab). */
|
||||||
emitUnreadCount(userId: string, unreadCount: number): void {
|
emitUnreadCount(userId: string, unreadCount: number): void {
|
||||||
|
if (!this.server) return this.skip("emitUnreadCount");
|
||||||
this.server
|
this.server
|
||||||
.to(this.room(userId))
|
.to(this.room(userId))
|
||||||
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
.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 {
|
private room(userId: string): string {
|
||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user