Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
30 changes: 30 additions & 0 deletions src/ticket/dto/create-ticket.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID, IsOptional, IsEnum } from 'class-validator';

// Business request uses: valid | used | transferred
// Map to entity enum: ACTIVE | USED | TRANSFERRED
export enum CreateTicketStatusInput {
VALID = 'valid',
USED = 'used',
TRANSFERRED = 'transferred',
}

export class CreateTicketDto {
@ApiProperty({ description: 'Event ID the ticket belongs to' })
@IsUUID()
eventId: string;

@ApiProperty({ description: 'Owner (user) ID for the ticket' })
@IsUUID()
ownerId: string;

@ApiProperty({
description: 'Initial status of the ticket',
enum: CreateTicketStatusInput,
required: false,
default: CreateTicketStatusInput.VALID,
})
@IsOptional()
@IsEnum(CreateTicketStatusInput)
status?: CreateTicketStatusInput;
}
15 changes: 15 additions & 0 deletions src/ticket/dto/update-ticket.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';

export enum UpdateTicketStatusInput {
VALID = 'valid',
USED = 'used',
TRANSFERRED = 'transferred',
}

export class UpdateTicketDto {
@ApiProperty({ description: 'Update status', enum: UpdateTicketStatusInput, required: false })
@IsOptional()
@IsEnum(UpdateTicketStatusInput)
status?: UpdateTicketStatusInput;
}
142 changes: 142 additions & 0 deletions src/ticket/ticket-crud.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TicketCrudService } from './ticket-crud.service';
import { Ticket, TicketStatus } from './ticket.entity';
import { Event } from '../modules/event/event.entity';
import { User } from '../user/user.entity';
import { CreateTicketDto, CreateTicketStatusInput } from './dto/create-ticket.dto';
import { UpdateTicketDto, UpdateTicketStatusInput } from './dto/update-ticket.dto';
import { BadRequestException, NotFoundException } from '@nestjs/common';


describe('TicketCrudService', () => {
let service: TicketCrudService;
let ticketRepo: jest.Mocked<Repository<Ticket>>;
let eventRepo: jest.Mocked<Repository<Event>>;
let userRepo: jest.Mocked<Repository<User>>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TicketCrudService,
{
provide: getRepositoryToken(Ticket),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(Event),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();

service = module.get<TicketCrudService>(TicketCrudService);
ticketRepo = module.get(getRepositoryToken(Ticket));
eventRepo = module.get(getRepositoryToken(Event));
userRepo = module.get(getRepositoryToken(User));
});

describe('create', () => {
const dto: CreateTicketDto = {
eventId: 'event-1',
ownerId: 'user-1',
status: CreateTicketStatusInput.VALID,
};

it('creates a ticket when event and owner exist', async () => {
const event: Partial<Event> = { id: 'event-1', ticketPrice: 50 } as any;
const owner: Partial<User> = { id: 'user-1' } as any;
(eventRepo.findOne as jest.Mock).mockResolvedValue(event as Event);
(userRepo.findOne as jest.Mock).mockResolvedValue(owner as User);

const created: Partial<Ticket> = {
id: 'ticket-1',
status: TicketStatus.ACTIVE,
event: event as Event,
originalOwner: owner as User,
currentOwner: owner as User,
} as any;

(ticketRepo.create as jest.Mock).mockReturnValue(created);
(ticketRepo.save as jest.Mock).mockResolvedValue(created as Ticket);

const result = await service.create(dto);

expect(eventRepo.findOne).toHaveBeenCalledWith({ where: { id: 'event-1' } });
expect(userRepo.findOne).toHaveBeenCalledWith({ where: { id: 'user-1' } });
expect(ticketRepo.create).toHaveBeenCalled();
expect(ticketRepo.save).toHaveBeenCalledWith(created);
expect(result.status).toBe(TicketStatus.ACTIVE);
});

it('throws when event does not exist', async () => {
(eventRepo.findOne as jest.Mock).mockResolvedValue(null);
await expect(service.create(dto)).rejects.toThrow(BadRequestException);
});

it('throws when owner does not exist', async () => {
(eventRepo.findOne as jest.Mock).mockResolvedValue({ id: 'event-1', ticketPrice: 10 } as Event);
(userRepo.findOne as jest.Mock).mockResolvedValue(null);
await expect(service.create(dto)).rejects.toThrow(BadRequestException);
});
});

describe('findOne', () => {
it('returns the ticket when found', async () => {
(ticketRepo.findOne as jest.Mock).mockResolvedValue({ id: 't1' } as Ticket);
const res = await service.findOne('t1');
expect(ticketRepo.findOne).toHaveBeenCalledWith({
where: { id: 't1' },
relations: ['event', 'currentOwner', 'originalOwner'],
});
expect(res.id).toBe('t1');
});

it('throws NotFound when missing', async () => {
(ticketRepo.findOne as jest.Mock).mockResolvedValue(null);
await expect(service.findOne('nope')).rejects.toThrow(NotFoundException);
});
});

describe('update', () => {
it('updates status from ACTIVE to USED', async () => {
const existing: Ticket = { id: 't1', status: TicketStatus.ACTIVE } as any;
jest.spyOn(service, 'findOne').mockResolvedValue(existing);

(ticketRepo.save as jest.Mock).mockImplementation(async (t: Ticket) => t);

const dto: UpdateTicketDto = { status: UpdateTicketStatusInput.USED };
const res = await service.update('t1', dto);
expect(res.status).toBe(TicketStatus.USED);
});

it('prevents reverting USED back to ACTIVE', async () => {
const existing: Ticket = { id: 't1', status: TicketStatus.USED } as any;
jest.spyOn(service, 'findOne').mockResolvedValue(existing);

const dto: UpdateTicketDto = { status: UpdateTicketStatusInput.VALID };
await expect(service.update('t1', dto)).rejects.toThrow(BadRequestException);
});

it('prevents setting TRANSFERRED via PATCH', async () => {
const existing: Ticket = { id: 't1', status: TicketStatus.ACTIVE } as any;
jest.spyOn(service, 'findOne').mockResolvedValue(existing);

const dto: UpdateTicketDto = { status: UpdateTicketStatusInput.TRANSFERRED };
await expect(service.update('t1', dto)).rejects.toThrow(BadRequestException);
});
});
});
88 changes: 88 additions & 0 deletions src/ticket/ticket-crud.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Ticket, TicketStatus } from './ticket.entity';
import { Event } from '../modules/event/event.entity';
import { User } from '../user/user.entity';
import { CreateTicketDto, CreateTicketStatusInput } from './dto/create-ticket.dto';
import { UpdateTicketDto, UpdateTicketStatusInput } from './dto/update-ticket.dto';

function mapInputToStatus(input?: CreateTicketStatusInput | UpdateTicketStatusInput): TicketStatus {
if (!input || input === CreateTicketStatusInput.VALID || input === UpdateTicketStatusInput.VALID) {
return TicketStatus.ACTIVE;
}
if (input === CreateTicketStatusInput.USED || input === UpdateTicketStatusInput.USED) {
return TicketStatus.USED;
}
if (
input === CreateTicketStatusInput.TRANSFERRED ||
input === UpdateTicketStatusInput.TRANSFERRED
) {
return TicketStatus.TRANSFERRED;
}
return TicketStatus.ACTIVE;
}

@Injectable()
export class TicketCrudService {
constructor(
@InjectRepository(Ticket) private readonly ticketRepo: Repository<Ticket>,
@InjectRepository(Event) private readonly eventRepo: Repository<Event>,
@InjectRepository(User) private readonly userRepo: Repository<User>,
) {}

async create(dto: CreateTicketDto): Promise<Ticket> {
const event = await this.eventRepo.findOne({ where: { id: dto.eventId } });
if (!event) throw new BadRequestException('Event not found');

const owner = await this.userRepo.findOne({ where: { id: dto.ownerId } });
if (!owner) throw new BadRequestException('Owner not found');

const status = mapInputToStatus(dto.status);

const ticket = this.ticketRepo.create({
status,
ticketNumber: `T-${Date.now()}-${Math.floor(Math.random() * 10000)}`,
originalPrice: event.ticketPrice,
currentPrice: event.ticketPrice,
purchaseDate: new Date(),
transferCount: 0,
event,
originalOwner: owner,
currentOwner: owner,
});

return this.ticketRepo.save(ticket);
}

async findOne(id: string): Promise<Ticket> {
const ticket = await this.ticketRepo.findOne({
where: { id },
relations: ['event', 'currentOwner', 'originalOwner'],
});
if (!ticket) throw new NotFoundException('Ticket not found');
return ticket;
}

async update(id: string, dto: UpdateTicketDto): Promise<Ticket> {
const ticket = await this.findOne(id);

if (dto.status) {
const newStatus = mapInputToStatus(dto.status);

// Prevent setting TRANSFERRED via PATCH: use dedicated transfer flow
if (newStatus === TicketStatus.TRANSFERRED) {
throw new BadRequestException('Use transfer endpoint to transfer tickets');
}

// Basic lifecycle: ACTIVE -> USED is allowed; USED cannot revert to ACTIVE
if (ticket.status === TicketStatus.USED && newStatus === TicketStatus.ACTIVE) {
throw new BadRequestException('Cannot revert a used ticket to active');
}

ticket.status = newStatus;
}

return this.ticketRepo.save(ticket);
}
}
79 changes: 79 additions & 0 deletions src/ticket/ticket.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TicketController } from './ticket.controller';
import { TicketCrudService } from './ticket-crud.service';
import { TicketQrService } from './ticket-qr.service';
import { CreateTicketDto, CreateTicketStatusInput } from './dto/create-ticket.dto';
import { UpdateTicketDto, UpdateTicketStatusInput } from './dto/update-ticket.dto';


describe('TicketController', () => {
let controller: TicketController;
let crud: { create: jest.Mock; findOne: jest.Mock; update: jest.Mock };
let qr: { generateQrSvg: jest.Mock; validateCode: jest.Mock };

beforeEach(async () => {
crud = {
create: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
qr = {
generateQrSvg: jest.fn(),
validateCode: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [TicketController],
providers: [
{ provide: TicketCrudService, useValue: crud },
{ provide: TicketQrService, useValue: qr },
],
}).compile();

controller = module.get<TicketController>(TicketController);
});

it('creates a ticket', async () => {
const dto: CreateTicketDto = {
eventId: 'e1',
ownerId: 'u1',
status: CreateTicketStatusInput.VALID,
};
const created = { id: 't1' };
crud.create.mockResolvedValue(created);

const res = await controller.createTicket(dto);
expect(crud.create).toHaveBeenCalledWith(dto);
expect(res).toEqual(created);
});

it('gets a ticket', async () => {
const ticket = { id: 't1' };
crud.findOne.mockResolvedValue(ticket);
const res = await controller.getTicket('t1');
expect(crud.findOne).toHaveBeenCalledWith('t1');
expect(res).toEqual(ticket);
});

it('updates a ticket', async () => {
const dto: UpdateTicketDto = { status: UpdateTicketStatusInput.USED };
const updated = { id: 't1', status: 'used' };
crud.update.mockResolvedValue(updated);
const res = await controller.updateTicket('t1', dto);
expect(crud.update).toHaveBeenCalledWith('t1', dto);
expect(res).toEqual(updated);
});

it('returns svg QR', async () => {
qr.generateQrSvg.mockResolvedValue('<svg></svg>');
const svg = await controller.getTicketQr('t1');
expect(qr.generateQrSvg).toHaveBeenCalledWith('t1');
expect(svg).toBe('<svg></svg>');
});

it('validates QR and returns ok', async () => {
qr.validateCode.mockReturnValue({ valid: true, expired: false, ticketId: 't1' });
const res = await controller.validateTicketQr({ code: 'c' });
expect(res).toEqual({ status: 'ok', ticketId: 't1' });
});
});
Loading
Loading