import { TicketsService } from './tickets.service'; /** * "USER X boarded TICKET Y" has to be answerable from AuditLog alone. * * Before this, boarding wrote `action: 'VERIFY'` with no actor and no previous status, and the * only name on the row was `validatorId` — a request-body field, so whoever scanned could put * anyone's id in the trail. These pin: one row per boarding (not one per layer), the actor * coming from the session, and the ticket's before/after status both being recorded. */ describe('TicketsService — boarding audit', () => { const ACTOR = { id: 'iam-staff-1', name: 'Abebe Kebede', phone: '+251911223344' }; const TICKET_ID = 'ticket-1'; const BOOKING_ID = 'booking-1'; const BOOKING_REF = 'EDR-0001'; let prisma: Record; let audit: { log: jest.Mock }; let service: TicketsService; const build = ( opts: { bookingType?: string; ticket?: Record; booking?: Record; approvedLegs?: string[]; } = {}, ) => { const ticket = { id: TICKET_ID, bookingId: BOOKING_ID, bookingRef: BOOKING_REF, seatId: 'seat-9', leg: 1, status: 'ACTIVE', validatedAt: null, boardedAt: null, ...opts.ticket, }; // Departure an hour out, so scanAndBoard's boarding window is open. const departureAt = new Date(Date.now() + 60 * 60 * 1000); const booking = { id: BOOKING_ID, bookingRef: BOOKING_REF, bookingType: opts.bookingType ?? 'ONE_WAY', status: 'CONFIRMED', originStationId: 'station-a', destinationStationId: 'station-b', outboundBoardedAt: null, returnBoardedAt: null, tickets: [ticket], seats: [], schedule: { id: 'sched-1', departureAt, arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000), originStationId: 'station-a', destinationStationId: 'station-b', originStation: { id: 'station-a', name: 'Furi Labu' }, destinationStation: { id: 'station-b', name: 'Dire Dawa' }, train: { id: 'train-1', number: 'T1' }, stopTimes: [], }, returnSchedule: null, ...opts.booking, }; prisma = { ticket: { findUnique: jest.fn().mockResolvedValue(ticket), findFirst: jest.fn().mockResolvedValue(ticket), update: jest.fn().mockResolvedValue(ticket), }, booking: { findUnique: jest.fn().mockResolvedValue(booking), update: jest.fn().mockResolvedValue(booking), }, gateValidationLog: { create: jest.fn().mockResolvedValue({}), findMany: jest .fn() .mockResolvedValue((opts.approvedLegs ?? []).map((leg) => ({ leg, status: 'APPROVED' }))), }, }; audit = { log: jest.fn().mockResolvedValue(undefined) }; // Constructor order: prisma, notifications, systemConfig, auditService, dataSource. service = new TicketsService( prisma as any, { sendSms: jest.fn(), sendEmail: jest.fn() } as any, { get: jest.fn().mockResolvedValue(null), getNumber: jest.fn().mockResolvedValue(4) } as any, audit as any, {} as any, ); return { ticket, booking }; }; const rows = () => audit.log.mock.calls.map((c) => c[0]); const boardRows = () => rows().filter((r) => r.action === 'BOARD'); describe('a successful one-way boarding', () => { it('writes exactly one BOARD row', async () => { build(); await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR); expect(boardRows()).toHaveLength(1); }); it('identifies the ticket and the booking it belongs to', async () => { build(); await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR); expect(boardRows()[0]).toMatchObject({ action: 'BOARD', entityType: 'Ticket', entityId: TICKET_ID, }); expect(boardRows()[0].newData).toMatchObject({ bookingRef: BOOKING_REF, bookingId: BOOKING_ID, seatId: 'seat-9', }); }); it('records the status the ticket moved from and to', async () => { build(); await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR); const row = boardRows()[0]; expect(row.oldData).toMatchObject({ status: 'ACTIVE', validatedAt: null }); expect(row.newData.status).toBe('USED'); expect(row.newData.boardedAt).toEqual(expect.any(String)); }); it('leaves the actor to AuditService rather than passing a client-supplied id', async () => { build(); await service.validate(BOOKING_REF, 'anyone-can-type-this', undefined, undefined, ACTOR); // `userId` is never set at the call site — AuditService reads the guarded session, so the // body value below can only ever appear as descriptive context. expect(boardRows()[0].userId).toBeUndefined(); expect(boardRows()[0].newData.validatorId).toBe('anyone-can-type-this'); }); it('names the authenticated user on the gate log when no validatorId is sent', async () => { build(); await service.validate(BOOKING_REF, '', undefined, undefined, ACTOR); // Previously fell straight through to the anonymous 'BACKOFFICE' literal. expect(prisma.gateValidationLog.create.mock.calls[0][0].data.validatorId).toBe(ACTOR.id); }); }); describe('scanAndBoard', () => { it('produces one row, not one per layer', async () => { build(); // scanAndBoard delegates to validate(); logging in both would double every boarding. await service.scanAndBoard(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', ACTOR); expect(boardRows()).toHaveLength(1); }); }); describe('a refused boarding', () => { it('records BOARD_DENIED with the reason on a round-trip leg already used', async () => { build({ bookingType: 'ROUND_TRIP', booking: { outboundBoardedAt: new Date() } }); await expect( service.validate(BOOKING_REF, 'GATE-1', undefined, 'OUTBOUND', ACTOR), ).rejects.toThrow(); const denied = rows().filter((r) => r.action === 'BOARD_DENIED'); expect(denied).toHaveLength(1); expect(denied[0]).toMatchObject({ entityType: 'Ticket', entityId: TICKET_ID }); expect(denied[0].newData).toMatchObject({ result: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED', bookingRef: BOOKING_REF, }); }); it('writes no BOARD row when the boarding was refused', async () => { build({ bookingType: 'TRANSIT', approvedLegs: ['LEG1'] }); await expect( service.validate(BOOKING_REF, 'GATE-1', undefined, 'LEG1', ACTOR), ).rejects.toThrow(); expect(boardRows()).toHaveLength(0); }); }); describe('reads', () => { it('writes nothing when simply fetching a ticket', async () => { build(); await service.getByRef(BOOKING_REF).catch(() => undefined); expect(audit.log).not.toHaveBeenCalled(); }); }); describe('failed operations', () => { it('records nothing when the booking does not exist', async () => { build(); prisma.booking.findUnique.mockResolvedValue(null); await expect( service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR), ).rejects.toThrow(); expect(audit.log).not.toHaveBeenCalled(); }); it('records nothing when the ticket write itself fails', async () => { build(); prisma.ticket.update.mockRejectedValue(new Error('db down')); await expect( service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR), ).rejects.toThrow(); expect(boardRows()).toHaveLength(0); }); }); describe('sensitive data', () => { it('keeps the QR payload and passenger name off the row', async () => { build({ ticket: { qrPayload: 'QR-SECRET', passengerName: 'Abebe Kebede' } }); await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR); const serialized = JSON.stringify(boardRows()[0]); expect(serialized).not.toContain('QR-SECRET'); expect(serialized).not.toContain('Abebe Kebede'); }); }); });