diff --git a/src/controllers/event-ticket.controller.ts b/src/controllers/event-ticket.controller.ts index 298f822..6ee9f86 100644 --- a/src/controllers/event-ticket.controller.ts +++ b/src/controllers/event-ticket.controller.ts @@ -6,13 +6,26 @@ import { } from '../validators/event.validator'; import { UserAuthenticatedReq } from '../utils/types'; import { PaymentPrivacyDisclosureService } from '../services/payment-privacy-disclosure.service'; +import { decodePaginationCursor } from '../utils/pagination-cursor'; export const getEventTickets: RequestHandler = async (req, res) => { try { + const rawCursor = req.query.cursor; + const cursorString = + typeof rawCursor === 'string' ? rawCursor.trim() : undefined; + const cursor = cursorString ? decodePaginationCursor(cursorString) : null; const page = parseInt(req.query.page as string, 10) || 1; const limit = parseInt(req.query.limit as string, 10) || 8; - if (page < 1) { + if (cursorString && !cursor) { + return res.status(400).json({ + error: 'Invalid cursor', + message: + 'Cursor must be a valid pagination cursor generated by the API', + }); + } + + if (!cursor && page < 1) { return res.status(400).json({ error: 'Invalid page number', message: 'Page number must be greater than 0', @@ -26,7 +39,11 @@ export const getEventTickets: RequestHandler = async (req, res) => { }); } - const result = await EventTicketService.getEventTickets(page, limit); + const result = await EventTicketService.getEventTickets( + page, + limit, + cursor ?? undefined, + ); res.status(200).json(result); } catch (error) { diff --git a/src/controllers/ticket-order.controller.ts b/src/controllers/ticket-order.controller.ts index 018cf1e..469a0a8 100644 --- a/src/controllers/ticket-order.controller.ts +++ b/src/controllers/ticket-order.controller.ts @@ -6,6 +6,7 @@ import { UserAuthenticatedReq } from '../utils/types'; import { getIdempotencyKey } from '../middlewares/idempotency'; import TicketOrder from '../models/ticket-order'; import EventTicket from '../models/event-ticket'; +import { decodePaginationCursor } from '../utils/pagination-cursor'; /** * Controller for Ticket Orders and Payments transparency @@ -24,13 +25,26 @@ export const getUserOrders: RequestHandler = async ( }); } + const rawCursor = req.query.cursor; + const cursorString = + typeof rawCursor === 'string' ? rawCursor.trim() : undefined; + const cursor = cursorString ? decodePaginationCursor(cursorString) : null; const page = parseInt(req.query.page as string, 10) || 1; const limit = parseInt(req.query.limit as string, 10) || 10; + if (cursorString && !cursor) { + return res.status(400).json({ + error: 'Invalid cursor', + message: + 'Cursor must be a valid pagination cursor generated by the API', + }); + } + const result = await TicketOrderService.getUserOrders( userId.toString(), page, limit, + ...(cursor ? [cursor] : []), ); res.status(200).json({ @@ -63,13 +77,26 @@ export const getOrganizerOrders: RequestHandler = async ( }); } + const rawCursor = req.query.cursor; + const cursorString = + typeof rawCursor === 'string' ? rawCursor.trim() : undefined; + const cursor = cursorString ? decodePaginationCursor(cursorString) : null; const page = parseInt(req.query.page as string, 10) || 1; const limit = parseInt(req.query.limit as string, 10) || 10; + if (cursorString && !cursor) { + return res.status(400).json({ + error: 'Invalid cursor', + message: + 'Cursor must be a valid pagination cursor generated by the API', + }); + } + const result = await TicketOrderService.getOrganizerOrders( userId.toString(), page, limit, + ...(cursor ? [cursor] : []), ); res.status(200).json({ diff --git a/src/models/event-ticket.ts b/src/models/event-ticket.ts index 690cc1d..5fae02b 100644 --- a/src/models/event-ticket.ts +++ b/src/models/event-ticket.ts @@ -43,6 +43,8 @@ export interface IEventTicket extends Document { isPublished: boolean; // whether the event is published allowAnonymous: boolean; // whether unauthenticated users may purchase tickets requiresVerification: boolean; // whether attendees must have a verified email + createdAt?: Date; + updatedAt?: Date; __v?: number; // Mongoose version key for optimistic locking } diff --git a/src/routes/event-ticket.route.ts b/src/routes/event-ticket.route.ts index 7fb4b5d..eedc2ea 100644 --- a/src/routes/event-ticket.route.ts +++ b/src/routes/event-ticket.route.ts @@ -29,7 +29,8 @@ eventTicketRoutes.post('/scan', authGuard, scanTicket); // POST /api/event-tickets/validate - Validate ticket without marking as used eventTicketRoutes.post('/validate', authGuard, validateTicket); -// GET /api/event-tickets - Fetch paginated event tickets +// GET /api/event-tickets - Fetch tickets with cursor pagination via `cursor` and `limit`, +// or `page` and `limit` as a fallback. Responses include `nextCursor` when more data exists. eventTicketRoutes.get('/', getEventTickets); // GET /api/event-tickets/category/:category - Fetch event tickets by category diff --git a/src/routes/ticket-order.route.ts b/src/routes/ticket-order.route.ts index 24bd286..caca4ca 100644 --- a/src/routes/ticket-order.route.ts +++ b/src/routes/ticket-order.route.ts @@ -13,14 +13,14 @@ const ticketOrderRoutes = Router(); /** * @route GET /ticket-orders/my-orders - * @desc Get ticket orders for the logged-in user + * @desc Get ticket orders for the logged-in user using `cursor` and `limit`, or `page` fallback * @access Private */ ticketOrderRoutes.get('/my-orders', authGuard, getUserOrders); /** * @route GET /ticket-orders/organizer-orders - * @desc Get ticket orders for events organized by the logged-in user + * @desc Get ticket orders for events organized by the logged-in user using `cursor` and `limit`, or `page` fallback * @access Private */ ticketOrderRoutes.get('/organizer-orders', authGuard, getOrganizerOrders); diff --git a/src/services/event-ticket.service.ts b/src/services/event-ticket.service.ts index c82649a..97cb6af 100644 --- a/src/services/event-ticket.service.ts +++ b/src/services/event-ticket.service.ts @@ -5,6 +5,10 @@ import TicketOrder, { ITicketOrder } from '../models/ticket-order'; import User from '../models/user'; import Media from '../models/media'; import zkEmailNotificationService from './zk-email-notification.service'; +import { + encodePaginationCursor, + PaginationCursor, +} from '../utils/pagination-cursor'; import { CreateEventStepTwoInput } from '../validators/event.validator'; @@ -25,6 +29,7 @@ export interface PaginatedEventTicketsResponse { page: number; limit: number; total: number; + nextCursor?: string | null; tickets: EventTicketResponse[]; } @@ -153,6 +158,7 @@ export class EventTicketService { page: validPage, limit: validLimit, total, + nextCursor: null, tickets: transformedTickets, }; } catch (error) { @@ -168,34 +174,71 @@ export class EventTicketService { static async getEventTickets( page: number = 1, limit: number = this.DEFAULT_LIMIT, + cursor?: PaginationCursor, ): Promise { try { // Validate pagination parameters const validPage = Math.max(1, page); const validLimit = Math.min(Math.max(1, limit), 50); // Cap at 50 for performance - - // Calculate skip value - const skip = (validPage - 1) * validLimit; + const useCursor = Boolean(cursor); + + const cursorFilter = cursor + ? { + $or: [ + { createdAt: { $lt: cursor.sortValue } }, + { + createdAt: cursor.sortValue, + _id: { $lt: new mongoose.Types.ObjectId(cursor.id) }, + }, + ], + } + : {}; + + let query = EventTicket.find(cursorFilter).sort({ + createdAt: -1, + _id: -1, + }); + + if (useCursor) { + query = query.limit(validLimit + 1); + } else { + const skip = (validPage - 1) * validLimit; + query = query.skip(skip).limit(validLimit); + } // Get total count const total = await EventTicket.countDocuments(); // Fetch tickets with pagination - const tickets = await EventTicket.find() - .sort({ createdAt: -1 }) // Sort by newest first - .skip(skip) - .limit(validLimit) - .lean(); // Use lean() for better performance + const tickets = await query.lean(); // Use lean() for better performance + + const paginatedTickets = useCursor ? tickets.slice(0, validLimit) : tickets; // Transform tickets to response format - const transformedTickets = tickets.map((ticket) => + const transformedTickets = paginatedTickets.map((ticket) => this.transformEventTicket(ticket as unknown as IEventTicket), ); + const hasNextPage = useCursor && tickets.length > validLimit; + const nextCursor = + hasNextPage && paginatedTickets.length > 0 + ? encodePaginationCursor( + new Date( + (paginatedTickets[paginatedTickets.length - 1] as any) + .createdAt, + ), + String( + (paginatedTickets[paginatedTickets.length - 1] as any) + ._id, + ), + ) + : null; + return { page: validPage, limit: validLimit, total, + nextCursor, tickets: transformedTickets, }; } catch (error) { diff --git a/src/services/ticket-order.service.ts b/src/services/ticket-order.service.ts index 9d19266..c6be2aa 100644 --- a/src/services/ticket-order.service.ts +++ b/src/services/ticket-order.service.ts @@ -4,12 +4,17 @@ import EventTicket from '../models/event-ticket'; import User from '../models/user'; import zkEmailNotificationService from './zk-email-notification.service'; import WaitlistService from './waitlist.service'; +import { + encodePaginationCursor, + PaginationCursor, +} from '../utils/pagination-cursor'; export interface PaginatedTicketOrdersResponse { page: number; limit: number; total: number; totalPages: number; + nextCursor?: string | null; orders: ITicketOrder[]; } @@ -21,29 +26,70 @@ export class TicketOrderService { userId: string, page: number = 1, limit: number = 10, + cursor?: PaginationCursor, ): Promise { try { const validPage = Math.max(1, page); const validLimit = Math.max(1, Math.min(limit, 50)); - const skip = (validPage - 1) * validLimit; + const useCursor = Boolean(cursor); + const baseFilter = { user: new (mongoose.Types.ObjectId as any)(userId) }; + const cursorFilter = cursor + ? { + $or: [ + { datePurchased: { $lt: cursor.sortValue } }, + { + datePurchased: cursor.sortValue, + _id: { $lt: new mongoose.Types.ObjectId(cursor.id) }, + }, + ], + } + : {}; + + const filter = { + ...baseFilter, + ...cursorFilter, + }; - const filter = { user: new (mongoose.Types.ObjectId as any)(userId) }; + let query = TicketOrder.find(filter).sort({ + datePurchased: -1, + _id: -1, + }); + + if (useCursor) { + query = query.limit(validLimit + 1); + } else { + const skip = (validPage - 1) * validLimit; + query = query.skip(skip).limit(validLimit); + } const [orders, total] = await Promise.all([ - TicketOrder.find(filter) - .sort({ datePurchased: -1 }) - .skip(skip) - .limit(validLimit) - .lean(), - TicketOrder.countDocuments(filter), + query.lean(), + TicketOrder.countDocuments(baseFilter), ]); + const paginatedOrders = useCursor ? orders.slice(0, validLimit) : orders; + const hasNextPage = useCursor && orders.length > validLimit; + const nextCursor = + hasNextPage && paginatedOrders.length > 0 + ? encodePaginationCursor( + new Date( + (paginatedOrders[paginatedOrders.length - 1] as any) + .datePurchased, + ), + String( + (paginatedOrders[paginatedOrders.length - 1] as any) + ._id, + ), + ) + : null; + return { page: validPage, limit: validLimit, total, totalPages: Math.ceil(total / validLimit), - orders: orders as unknown as ITicketOrder[], + nextCursor, + orders: paginatedOrders as unknown as ITicketOrder[], }; } catch (error) { throw new Error( @@ -59,11 +105,12 @@ export class TicketOrderService { organizerId: string, page: number = 1, limit: number = 10, + cursor?: PaginationCursor, ): Promise { try { const validPage = Math.max(1, page); const validLimit = Math.max(1, Math.min(limit, 50)); - const skip = (validPage - 1) * validLimit; + const useCursor = Boolean(cursor); // 1. Find all events organized by this user const events = await EventTicket.find({ @@ -78,6 +125,7 @@ export class TicketOrderService { limit: validLimit, total: 0, totalPages: 0, + nextCursor: null, orders: [], }; } @@ -85,23 +133,64 @@ export class TicketOrderService { const eventIds = events.map((event) => event._id); // 2. Find all orders for those events - const filter = { eventTicket: { $in: eventIds } }; + const baseFilter = { eventTicket: { $in: eventIds } }; + const cursorFilter = cursor + ? { + $or: [ + { datePurchased: { $lt: cursor.sortValue } }, + { + datePurchased: cursor.sortValue, + _id: { $lt: new mongoose.Types.ObjectId(cursor.id) }, + }, + ], + } + : {}; + + const filter = { + ...baseFilter, + ...cursorFilter, + }; + + let query = TicketOrder.find(filter).sort({ + datePurchased: -1, + _id: -1, + }); + + if (useCursor) { + query = query.limit(validLimit + 1); + } else { + const skip = (validPage - 1) * validLimit; + query = query.skip(skip).limit(validLimit); + } const [orders, total] = await Promise.all([ - TicketOrder.find(filter) - .sort({ datePurchased: -1 }) - .skip(skip) - .limit(validLimit) - .lean(), - TicketOrder.countDocuments(filter), + query.lean(), + TicketOrder.countDocuments(baseFilter), ]); + const paginatedOrders = useCursor ? orders.slice(0, validLimit) : orders; + const hasNextPage = useCursor && orders.length > validLimit; + const nextCursor = + hasNextPage && paginatedOrders.length > 0 + ? encodePaginationCursor( + new Date( + (paginatedOrders[paginatedOrders.length - 1] as any) + .datePurchased, + ), + String( + (paginatedOrders[paginatedOrders.length - 1] as any) + ._id, + ), + ) + : null; + return { page: validPage, limit: validLimit, total, totalPages: Math.ceil(total / validLimit), - orders: orders as unknown as ITicketOrder[], + nextCursor, + orders: paginatedOrders as unknown as ITicketOrder[], }; } catch (error) { throw new Error( diff --git a/src/utils/pagination-cursor.ts b/src/utils/pagination-cursor.ts new file mode 100644 index 0000000..134369c --- /dev/null +++ b/src/utils/pagination-cursor.ts @@ -0,0 +1,53 @@ +import mongoose from 'mongoose'; + +export interface PaginationCursor { + sortValue: Date; + id: string; +} + +interface EncodedPaginationCursor { + sortValue: string; + id: string; +} + +export const encodePaginationCursor = ( + sortValue: Date, + id: string, +): string => { + return Buffer.from( + JSON.stringify({ + sortValue: sortValue.toISOString(), + id, + }), + ).toString('base64url'); +}; + +export const decodePaginationCursor = ( + cursor: string, +): PaginationCursor | null => { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const parsed = JSON.parse(decoded) as Partial; + + if ( + typeof parsed.sortValue !== 'string' || + typeof parsed.id !== 'string' || + !mongoose.Types.ObjectId.isValid(parsed.id) + ) { + return null; + } + + const sortValue = new Date(parsed.sortValue); + + if (Number.isNaN(sortValue.getTime())) { + return null; + } + + return { + sortValue, + id: parsed.id, + }; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/tests/event-ticket.controller.test.ts b/tests/event-ticket.controller.test.ts index 8acdecd..5b86aa8 100644 --- a/tests/event-ticket.controller.test.ts +++ b/tests/event-ticket.controller.test.ts @@ -1,14 +1,20 @@ -import { searchEventTickets } from '../src/controllers/event-ticket.controller'; +import { + getEventTickets, + searchEventTickets, +} from '../src/controllers/event-ticket.controller'; import { EventTicketService } from '../src/services/event-ticket.service'; +import { encodePaginationCursor } from '../src/utils/pagination-cursor'; jest.mock('../src/services/event-ticket.service', () => ({ EventTicketService: { + getEventTickets: jest.fn(), searchEventTickets: jest.fn(), }, })); describe('event-ticket controller search', () => { const eventTicketService = EventTicketService as unknown as { + getEventTickets: jest.Mock; searchEventTickets: jest.Mock; }; @@ -29,6 +35,63 @@ describe('event-ticket controller search', () => { jest.restoreAllMocks(); }); + describe('getEventTickets', () => { + it('passes a decoded cursor to the service when present', async () => { + const cursor = encodePaginationCursor( + new Date('2026-07-01T12:00:00.000Z'), + '507f191e810c19729de860ea', + ); + const req = { + query: { + cursor, + limit: '8', + }, + }; + const res = createResponse(); + + const serviceResult = { + page: 1, + limit: 8, + total: 2, + nextCursor: null, + tickets: [], + }; + + eventTicketService.getEventTickets.mockResolvedValue(serviceResult); + + await getEventTickets(req as any, res as any, jest.fn()); + + expect(eventTicketService.getEventTickets).toHaveBeenCalledWith( + 1, + 8, + { + sortValue: new Date('2026-07-01T12:00:00.000Z'), + id: '507f191e810c19729de860ea', + }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(serviceResult); + }); + + it('returns 400 for invalid cursor payloads', async () => { + const req = { + query: { + cursor: 'not-a-valid-cursor', + }, + }; + const res = createResponse(); + + await getEventTickets(req as any, res as any, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Invalid cursor', + message: + 'Cursor must be a valid pagination cursor generated by the API', + }); + }); + }); + it('returns 400 when both search query and filters are missing', async () => { const req = { query: {}, diff --git a/tests/event-ticket.service.test.ts b/tests/event-ticket.service.test.ts new file mode 100644 index 0000000..892e1f3 --- /dev/null +++ b/tests/event-ticket.service.test.ts @@ -0,0 +1,121 @@ +import EventTicket from '../src/models/event-ticket'; +import { EventTicketService } from '../src/services/event-ticket.service'; +import { encodePaginationCursor } from '../src/utils/pagination-cursor'; + +jest.mock('../src/models/event-ticket', () => ({ + __esModule: true, + default: { + find: jest.fn(), + countDocuments: jest.fn(), + }, +})); + +describe('EventTicketService pagination', () => { + const eventTicketModel = EventTicket as unknown as { + find: jest.Mock; + countDocuments: jest.Mock; + }; + + const mockLean = jest.fn(); + const mockLimit = jest.fn(() => ({ lean: mockLean })); + const mockSkip = jest.fn(() => ({ limit: mockLimit })); + const mockSort = jest.fn(() => ({ skip: mockSkip, limit: mockLimit })); + + beforeEach(() => { + jest.clearAllMocks(); + eventTicketModel.find.mockReturnValue({ sort: mockSort }); + }); + + it('uses skip-based pagination when cursor is not provided', async () => { + mockLean.mockResolvedValue([ + { + _id: '507f191e810c19729de860ea', + createdAt: new Date('2026-07-10T12:00:00.000Z'), + name: 'Ticket 1', + privacyLevel: 1, + soldTickets: 10, + eventDate: new Date('2026-08-01T12:00:00.000Z'), + location: 'Lagos', + price: 100, + imageUrl: 'https://example.com/1.png', + }, + ]); + eventTicketModel.countDocuments.mockResolvedValue(9); + + const result = await EventTicketService.getEventTickets(2, 1); + + expect(eventTicketModel.find).toHaveBeenCalledWith({}); + expect(mockSort).toHaveBeenCalledWith({ createdAt: -1, _id: -1 }); + expect(mockSkip).toHaveBeenCalledWith(1); + expect(mockLimit).toHaveBeenCalledWith(1); + expect(result.nextCursor).toBeNull(); + expect(result.page).toBe(2); + expect(result.limit).toBe(1); + expect(result.total).toBe(9); + }); + + it('uses cursor-based pagination and returns a next cursor when more data exists', async () => { + mockLean.mockResolvedValue([ + { + _id: '507f191e810c19729de860ea', + createdAt: new Date('2026-07-10T12:00:00.000Z'), + name: 'Ticket 1', + privacyLevel: 1, + soldTickets: 10, + eventDate: new Date('2026-08-01T12:00:00.000Z'), + location: 'Lagos', + price: 100, + imageUrl: 'https://example.com/1.png', + }, + { + _id: '507f191e810c19729de860eb', + createdAt: new Date('2026-07-09T12:00:00.000Z'), + name: 'Ticket 2', + privacyLevel: 1, + soldTickets: 8, + eventDate: new Date('2026-08-02T12:00:00.000Z'), + location: 'Abuja', + price: 50, + imageUrl: 'https://example.com/2.png', + }, + { + _id: '507f191e810c19729de860ec', + createdAt: new Date('2026-07-08T12:00:00.000Z'), + name: 'Ticket 3', + privacyLevel: 1, + soldTickets: 5, + eventDate: new Date('2026-08-03T12:00:00.000Z'), + location: 'Port Harcourt', + price: 25, + imageUrl: 'https://example.com/3.png', + }, + ]); + eventTicketModel.countDocuments.mockResolvedValue(12); + + const result = await EventTicketService.getEventTickets(1, 2, { + sortValue: new Date('2026-07-11T12:00:00.000Z'), + id: '507f191e810c19729de860ff', + }); + + expect(eventTicketModel.find).toHaveBeenCalledWith({ + $or: [ + { createdAt: { $lt: new Date('2026-07-11T12:00:00.000Z') } }, + { + createdAt: new Date('2026-07-11T12:00:00.000Z'), + _id: { $lt: expect.anything() }, + }, + ], + }); + expect(mockSort).toHaveBeenCalledWith({ createdAt: -1, _id: -1 }); + expect(mockSkip).not.toHaveBeenCalled(); + expect(mockLimit).toHaveBeenCalledWith(3); + expect(result.tickets).toHaveLength(2); + expect(result.nextCursor).toBe( + encodePaginationCursor( + new Date('2026-07-09T12:00:00.000Z'), + '507f191e810c19729de860eb', + ), + ); + expect(result.total).toBe(12); + }); +}); \ No newline at end of file diff --git a/tests/ticket-order.controller.test.ts b/tests/ticket-order.controller.test.ts index 1b7aea8..08e22ae 100644 --- a/tests/ticket-order.controller.test.ts +++ b/tests/ticket-order.controller.test.ts @@ -6,6 +6,7 @@ import { import { TicketOrderService } from '../src/services/ticket-order.service'; import TicketOrder from '../src/models/ticket-order'; import EventTicket from '../src/models/event-ticket'; +import { encodePaginationCursor } from '../src/utils/pagination-cursor'; jest.mock('../src/services/ticket-order.service', () => ({ TicketOrderService: { @@ -93,6 +94,46 @@ describe('TicketOrder controller', () => { data: serviceResult, }); }); + + it('passes a decoded cursor to the service when present', async () => { + const cursor = encodePaginationCursor( + new Date('2026-07-10T12:00:00.000Z'), + '507f191e810c19729de860ea', + ); + const req = { + user: { _id: 'user123' }, + query: { cursor, limit: '10' }, + }; + const res = createResponse(); + + const serviceResult = { + page: 1, + limit: 10, + total: 1, + totalPages: 1, + nextCursor: null, + orders: [], + }; + + ticketOrderService.getUserOrders.mockResolvedValue(serviceResult); + + await getUserOrders(req as any, res as any, jest.fn()); + + expect(ticketOrderService.getUserOrders).toHaveBeenCalledWith( + 'user123', + 1, + 10, + { + sortValue: new Date('2026-07-10T12:00:00.000Z'), + id: '507f191e810c19729de860ea', + }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: serviceResult, + }); + }); }); describe('getOrganizerOrders', () => { @@ -133,6 +174,46 @@ describe('TicketOrder controller', () => { data: serviceResult, }); }); + + it('passes a decoded cursor to the service when present', async () => { + const cursor = encodePaginationCursor( + new Date('2026-07-10T12:00:00.000Z'), + '507f191e810c19729de860eb', + ); + const req = { + user: { _id: 'organizer123' }, + query: { cursor, limit: '10' }, + }; + const res = createResponse(); + + const serviceResult = { + page: 1, + limit: 10, + total: 1, + totalPages: 1, + nextCursor: null, + orders: [], + }; + + ticketOrderService.getOrganizerOrders.mockResolvedValue(serviceResult); + + await getOrganizerOrders(req as any, res as any, jest.fn()); + + expect(ticketOrderService.getOrganizerOrders).toHaveBeenCalledWith( + 'organizer123', + 1, + 10, + { + sortValue: new Date('2026-07-10T12:00:00.000Z'), + id: '507f191e810c19729de860eb', + }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: serviceResult, + }); + }); }); describe('updateTicketOrderStatus', () => { diff --git a/tests/ticket-order.service.test.ts b/tests/ticket-order.service.test.ts new file mode 100644 index 0000000..d4f434f --- /dev/null +++ b/tests/ticket-order.service.test.ts @@ -0,0 +1,128 @@ +import mongoose from 'mongoose'; +import EventTicket from '../src/models/event-ticket'; +import TicketOrder from '../src/models/ticket-order'; +import { TicketOrderService } from '../src/services/ticket-order.service'; +import { encodePaginationCursor } from '../src/utils/pagination-cursor'; + +jest.mock('../src/models/ticket-order', () => ({ + __esModule: true, + default: { + find: jest.fn(), + countDocuments: jest.fn(), + }, +})); + +jest.mock('../src/models/event-ticket', () => ({ + __esModule: true, + default: { + find: jest.fn(), + }, +})); + +describe('TicketOrderService pagination', () => { + const ticketOrderModel = TicketOrder as unknown as { + find: jest.Mock; + countDocuments: jest.Mock; + }; + const eventTicketModel = EventTicket as unknown as { + find: jest.Mock; + }; + + const mockLean = jest.fn(); + const mockLimit = jest.fn(() => ({ lean: mockLean })); + const mockSkip = jest.fn(() => ({ limit: mockLimit })); + const mockSort = jest.fn(() => ({ skip: mockSkip, limit: mockLimit })); + + beforeEach(() => { + jest.clearAllMocks(); + ticketOrderModel.find.mockReturnValue({ sort: mockSort }); + eventTicketModel.find.mockReturnValue({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([ + { _id: new mongoose.Types.ObjectId('507f191e810c19729de860ed') }, + ]), + }); + }); + + it('uses cursor-based pagination for user orders', async () => { + mockLean.mockResolvedValue([ + { + _id: '507f191e810c19729de860ea', + datePurchased: new Date('2026-07-10T12:00:00.000Z'), + eventName: 'Event 1', + }, + { + _id: '507f191e810c19729de860eb', + datePurchased: new Date('2026-07-09T12:00:00.000Z'), + eventName: 'Event 2', + }, + { + _id: '507f191e810c19729de860ec', + datePurchased: new Date('2026-07-08T12:00:00.000Z'), + eventName: 'Event 3', + }, + ]); + ticketOrderModel.countDocuments.mockResolvedValue(7); + + const result = await TicketOrderService.getUserOrders( + '507f191e810c19729de860ff', + 1, + 2, + { + sortValue: new Date('2026-07-11T12:00:00.000Z'), + id: '507f191e810c19729de860aa', + }, + ); + + expect(ticketOrderModel.find).toHaveBeenCalledWith({ + user: expect.any(mongoose.Types.ObjectId), + $or: [ + { datePurchased: { $lt: new Date('2026-07-11T12:00:00.000Z') } }, + { + datePurchased: new Date('2026-07-11T12:00:00.000Z'), + _id: { $lt: expect.any(mongoose.Types.ObjectId) }, + }, + ], + }); + expect(mockLimit).toHaveBeenCalledWith(3); + expect(mockSkip).not.toHaveBeenCalled(); + expect(result.orders).toHaveLength(2); + expect(result.nextCursor).toBe( + encodePaginationCursor( + new Date('2026-07-09T12:00:00.000Z'), + '507f191e810c19729de860eb', + ), + ); + expect(result.totalPages).toBe(4); + }); + + it('uses cursor-based pagination for organizer orders', async () => { + mockLean.mockResolvedValue([ + { + _id: '507f191e810c19729de860ea', + datePurchased: new Date('2026-07-10T12:00:00.000Z'), + eventName: 'Event 1', + }, + { + _id: '507f191e810c19729de860eb', + datePurchased: new Date('2026-07-09T12:00:00.000Z'), + eventName: 'Event 2', + }, + ]); + ticketOrderModel.countDocuments.mockResolvedValue(2); + + const result = await TicketOrderService.getOrganizerOrders( + '507f191e810c19729de860ff', + 1, + 2, + { + sortValue: new Date('2026-07-11T12:00:00.000Z'), + id: '507f191e810c19729de860aa', + }, + ); + + expect(result.orders).toHaveLength(2); + expect(result.nextCursor).toBeNull(); + expect(mockLimit).toHaveBeenCalledWith(3); + }); +}); \ No newline at end of file