import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { io, type Socket } from 'socket.io-client'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingWindowGateway } from './booking-window.gateway'; import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; /** * End-to-end proof the booking-window socket works: boots a real Nest app with * the gateway, connects a real socket.io client to the namespace, emits a phase * change, and asserts the client receives the exact payload. If this passes, * any "no live update" report is environmental (stale server process, wrong * checkout running, client not connecting) — not the gateway. */ describe('BookingWindowGateway (e2e)', () => { let app: INestApplication; let gateway: BookingWindowGateway; let client: Socket; let baseUrl: string; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ BookingWindowGateway, // Accept any token — auth plumbing is covered by the real WsAuthService. { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } }, ], }).compile(); app = moduleRef.createNestApplication(); await app.listen(0); const address = app.getHttpServer().address() as { port: number }; baseUrl = `http://127.0.0.1:${address.port}`; gateway = app.get(BookingWindowGateway); }); afterAll(async () => { client?.disconnect(); await app?.close(); }); it('authenticated client receives the phase event with the schedule state', async () => { client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, { auth: { token: 'any' }, transports: ['websocket'], }); await new Promise((resolve, reject) => { client.on('connect', () => resolve()); client.on('connect_error', (err) => reject(err)); }); const received = new Promise>((resolve) => { client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload)); }); gateway.emitPhase({ id: 'sched-1', originStationId: 'yard-a', destinationStationId: 'yard-b', direction: 'IMPORT', windowPhase: 'OPEN', bookingWindowStatus: 'OPEN', bookingCycleNo: 2, windowOpensAt: new Date('2026-07-06T16:15:00Z'), windowClosesAt: new Date('2026-07-06T16:18:00Z'), docReviewEndsAt: null, paymentPhaseEndsAt: null, scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'), } as unknown as TrainSchedule); const payload = await received; expect(payload).toMatchObject({ scheduleId: 'sched-1', phase: 'OPEN', bookingWindowStatus: 'OPEN', bookingCycleNo: 2, windowOpensAt: '2026-07-06T16:15:00.000Z', }); }); it('rejects a client whose token does not resolve to a user', async () => { const moduleRef = await Test.createTestingModule({ providers: [ BookingWindowGateway, { provide: WsAuthService, useValue: { resolveUserId: async () => null } }, ], }).compile(); const rejectingApp = moduleRef.createNestApplication(); await rejectingApp.listen(0); const addr = rejectingApp.getHttpServer().address() as { port: number }; const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, { auth: { token: 'bad' }, transports: ['websocket'], reconnection: false, }); const outcome = await new Promise((resolve) => { rejected.on('disconnect', () => resolve('disconnected')); rejected.on('connect_error', () => resolve('rejected')); // The server accepts the transport then drops it in handleConnection. setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500); }); rejected.disconnect(); await rejectingApp.close(); expect(outcome).not.toBe('still-connected'); }); });