mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
232 lines
8.0 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|