Files
edr-platform/apps/edr-passenger-api/src/modules/tickets/boarding-audit.spec.ts

232 lines
8.0 KiB
TypeScript

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<string, any>;
let audit: { log: jest.Mock };
let service: TicketsService;
const build = (
opts: {
bookingType?: string;
ticket?: Record<string, any>;
booking?: Record<string, any>;
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');
});
});
});