diff --git a/src/tickets/dto/my-tickets-query.dto.spec.ts b/src/tickets/dto/my-tickets-query.dto.spec.ts new file mode 100644 index 0000000..ef1682b --- /dev/null +++ b/src/tickets/dto/my-tickets-query.dto.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { MyTicketsQueryDto } from './my-tickets-query.dto'; + +describe('MyTicketsQueryDto', () => { + it('accepts an empty query', async () => { + const dto = plainToInstance(MyTicketsQueryDto, {}); + expect(await validate(dto)).toHaveLength(0); + }); + + it.each(['VALID', 'USED', 'REVOKED', 'RESALE'])( + 'accepts status %s', + async (status) => { + const dto = plainToInstance(MyTicketsQueryDto, { status }); + expect(await validate(dto)).toHaveLength(0); + }, + ); + + it.each(['valid', 'EXPIRED', ''])('rejects status %p', async (status) => { + const dto = plainToInstance(MyTicketsQueryDto, { status }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'status')).toBe(true); + }); +}); diff --git a/src/tickets/dto/my-tickets-query.dto.ts b/src/tickets/dto/my-tickets-query.dto.ts new file mode 100644 index 0000000..c805c38 --- /dev/null +++ b/src/tickets/dto/my-tickets-query.dto.ts @@ -0,0 +1,9 @@ +import { TicketStatus } from '@prisma/client'; +import { IsEnum, IsOptional } from 'class-validator'; + +/** Optional status filter for `GET /tickets/mine`. */ +export class MyTicketsQueryDto { + @IsOptional() + @IsEnum(TicketStatus) + status?: TicketStatus; +} diff --git a/src/tickets/tickets.controller.spec.ts b/src/tickets/tickets.controller.spec.ts index eedb1cf..b951aea 100644 --- a/src/tickets/tickets.controller.spec.ts +++ b/src/tickets/tickets.controller.spec.ts @@ -188,6 +188,15 @@ describe('TicketsController', () => { expect(service.findMine).toHaveBeenCalledWith(uid, 'VALID'); }); + it('GET /mine rejects an unknown status', async () => { + await http() + .get('/tickets/mine') + .query({ status: 'EXPIRED' }) + .set(AUTH) + .expect(400); + expect(service.findMine).not.toHaveBeenCalled(); + }); + it('GET /verify/:qrSecret verifies as the caller', async () => { service.verify.mockResolvedValue({ valid: true }); await http().get('/tickets/verify/s3cret').set(AUTH).expect(200); @@ -382,7 +391,9 @@ describe('TicketsController', () => { it('POST /confirm-issue returns 409 when the seat is already taken for the event (#135)', async () => { service.confirmIssue.mockRejectedValue( - new ConflictException('That seat has already been issued for this event'), + new ConflictException( + 'That seat has already been issued for this event', + ), ); const res = await http() .post('/tickets/confirm-issue') @@ -400,9 +411,20 @@ describe('TicketsController', () => { const body = { ticketTypeId: UUID, recipients: [recipient] }; it('POST /issue-batch forwards ticketTypeId and recipients to the service', async () => { - service.buildIssueBatch.mockResolvedValue({ total: 1, succeeded: 1, failed: 0, results: [] }); - await http().post('/tickets/issue-batch').set(AUTH).send(body).expect(201); - expect(service.buildIssueBatch).toHaveBeenCalledWith(uid, UUID, [recipient]); + service.buildIssueBatch.mockResolvedValue({ + total: 1, + succeeded: 1, + failed: 0, + results: [], + }); + await http() + .post('/tickets/issue-batch') + .set(AUTH) + .send(body) + .expect(201); + expect(service.buildIssueBatch).toHaveBeenCalledWith(uid, UUID, [ + recipient, + ]); }); it('POST /issue-batch rejects a non-UUID ticketTypeId', async () => { @@ -427,14 +449,19 @@ describe('TicketsController', () => { await http() .post('/tickets/issue-batch') .set(AUTH) - .send({ ticketTypeId: UUID, recipients: [{ toUserId: UUID2, toPublicKey: 'bad' }] }) + .send({ + ticketTypeId: UUID, + recipients: [{ toUserId: UUID2, toPublicKey: 'bad' }], + }) .expect(400); expect(service.buildIssueBatch).not.toHaveBeenCalled(); }); it('POST /issue-batch returns 400 when the batch exceeds the configured max size', async () => { service.buildIssueBatch.mockRejectedValue( - new BadRequestException('Batch size exceeds the maximum of 50 recipients'), + new BadRequestException( + 'Batch size exceeds the maximum of 50 recipients', + ), ); const bigBatch = Array.from({ length: 51 }, () => recipient); const res = await http() @@ -442,7 +469,9 @@ describe('TicketsController', () => { .set(AUTH) .send({ ticketTypeId: UUID, recipients: bigBatch }) .expect(400); - expect((res.body as { message: string }).message).toContain('Batch size exceeds'); + expect((res.body as { message: string }).message).toContain( + 'Batch size exceeds', + ); }); }); diff --git a/src/tickets/tickets.controller.ts b/src/tickets/tickets.controller.ts index f1a6dd5..badac36 100644 --- a/src/tickets/tickets.controller.ts +++ b/src/tickets/tickets.controller.ts @@ -16,7 +16,6 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; -import { TicketStatus } from '@prisma/client'; import { ScanRateLimitGuard } from '../common/guards/scan-rate-limit.guard'; import { IdempotencyInterceptor } from '../common/interceptors/idempotency.interceptor'; import { TicketsService } from './tickets.service'; @@ -32,6 +31,7 @@ import { ListForResaleDto } from './dto/list-for-resale.dto'; import { ConfirmListForResaleDto } from './dto/confirm-list-for-resale.dto'; import { UpdateResalePriceDto } from './dto/update-resale-price.dto'; import { ResaleListingsQueryDto } from './dto/resale-listings-query.dto'; +import { MyTicketsQueryDto } from './dto/my-tickets-query.dto'; import { RevokeBatchDto } from './dto/revoke-batch.dto'; import { IssueBatchDto } from './dto/issue-batch.dto'; @@ -60,9 +60,9 @@ export class TicketsController { @Get('mine') findMine( @CurrentUser() user: CurrentUserPayload, - @Query('status') status?: TicketStatus, + @Query() query: MyTicketsQueryDto, ) { - return this.ticketsService.findMine(user.userId, status); + return this.ticketsService.findMine(user.userId, query.status); } @Get('verify/:qrSecret')