import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; import { Hono } from 'hono'; const db = { group: { findUnique: mock(), }, attendee: { findFirst: mock(), findMany: mock(), count: mock(), create: mock(), delete: mock(), }, groupWaitlistEntry: { findMany: mock(), findFirst: mock(), count: mock(), create: mock(), delete: mock(), }, payment: { count: mock(), }, }; mock.module('@/lib/prisma', () => ({ default: db, getPrismaClient: mock(), UnitOfWork: class { executeResult = mock(async (cb: (tx: unknown) => Promise) => cb(db)); execute = mock(async (cb: (tx: unknown) => Promise) => cb(db)); }, })); import prisma from '@/lib/prisma'; import { groupsRoutes } from '@/modules/groups'; const teacherId = 'teacher-1'; const mockGroup = { id: 'group-1', name: 'Taller de Pintura', description: null, createdById: teacherId, inviteToken: null, createdAt: new Date('2026-09-01T10:00:00.000Z'), updatedAt: new Date('2026-09-01T10:00:00.000Z'), days: ['MONDAY'], time: '18:00', capacity: 20, price: 1500, billingType: 'MONTHLY', dueDay: 10, members: [], }; const waitlistEntry = (overrides: Record = {}) => ({ id: 'wl-1', groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566', email: null, notes: null, status: 'PENDING', createdAt: new Date('2026-09-10T10:00:00.000Z'), updatedAt: new Date('2026-09-10T10:00:00.000Z'), ...overrides, }); const attendee = (overrides: Record = {}) => ({ id: 'att-1', groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566', email: null, guardianName: null, guardianPhone: null, notes: null, createdAt: new Date('2026-09-18T10:00:00.000Z'), updatedAt: new Date('2026-09-18T10:00:00.000Z'), ...overrides, }); function makeApp(userValue: unknown) { const app = new Hono(); app.use('*', async (c, next) => { c.set('user', userValue as never); await next(); }); app.route('/groups', groupsRoutes); return app; } describe('group waitlist management', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('GET /groups/:groupId/waitlist', () => { it('lists pending entries in FIFO order with pagination', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findMany.mockResolvedValue([ waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }), waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }), ] as never); prisma.groupWaitlistEntry.count.mockResolvedValue(2); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist'); expect(res.status).toBe(200); const data = await res.json(); expect(data.data).toHaveLength(2); expect(data.data[0].fullName).toBe('Ana García'); expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 }); expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({ where: { groupId: 'group-1', status: 'PENDING' }, skip: 0, take: 10, orderBy: { createdAt: 'asc' }, }); }); it('rejects user without access to the group', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist'); expect(res.status).toBe(403); expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled(); }); }); describe('POST /groups/:groupId/waitlist/:entryId/promote', () => { it('creates an attendee from the entry and removes it from the waitlist', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); prisma.attendee.count.mockResolvedValue(5); prisma.attendee.findFirst.mockResolvedValue(null); prisma.attendee.create.mockResolvedValue(attendee() as never); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { method: 'POST', }); expect(res.status).toBe(200); const data = await res.json(); expect(data.attendee.fullName).toBe('Martín Gómez'); expect(prisma.attendee.create).toHaveBeenCalledWith({ data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }), }); expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); }); it('rejects with 409 when the group reached its capacity', async () => { prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); prisma.attendee.count.mockResolvedValue(5); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { method: 'POST', }); expect(res.status).toBe(409); const body = await res.json(); expect(body.code).toBe('group_capacity_reached'); expect(prisma.attendee.create).not.toHaveBeenCalled(); expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); }); it('rejects with 409 when the phone already belongs to an attendee', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); prisma.attendee.count.mockResolvedValue(5); prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { method: 'POST', }); expect(res.status).toBe(409); const body = await res.json(); expect(body.code).toBe('attendee_already_registered'); expect(prisma.attendee.create).not.toHaveBeenCalled(); }); it('returns 404 when the entry does not exist', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', { method: 'POST', }); expect(res.status).toBe(404); }); it('rejects a non-owner user', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', { method: 'POST', }); expect(res.status).toBe(403); expect(prisma.attendee.create).not.toHaveBeenCalled(); }); }); describe('DELETE /groups/:groupId/waitlist/:entryId', () => { it('removes a waitlist entry', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', { method: 'DELETE', }); expect(res.status).toBe(200); const data = await res.json(); expect(data.deleted).toBe(true); expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); }); it('returns 404 when the entry does not exist', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', { method: 'DELETE', }); expect(res.status).toBe(404); expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); }); it('rejects a non-owner user', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', { method: 'DELETE', }); expect(res.status).toBe(403); expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); }); }); describe('DELETE /groups/:groupId/attendees/:attendeeId', () => { it('removes an attendee without promotion', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.attendee.findFirst.mockResolvedValue(attendee() as never); prisma.payment.count.mockResolvedValue(0); const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { method: 'DELETE', }); expect(res.status).toBe(200); const body = await res.json(); expect(body.removedAttendeeId).toBe('att-1'); expect(body.promoted).toBeNull(); expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); }); it('removes an attendee and promotes the first pending waitlist entry atomically', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => { if (where?.id) return attendee() as never; return null; }); prisma.payment.count.mockResolvedValue(0); prisma.groupWaitlistEntry.findFirst.mockResolvedValue( waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, ); prisma.attendee.count.mockResolvedValue(5); prisma.attendee.create.mockResolvedValue( attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, ); const res = await makeApp({ id: teacherId }).request( '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', { method: 'DELETE' }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.removedAttendeeId).toBe('att-1'); expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' }); expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); expect(prisma.attendee.create).toHaveBeenCalledWith({ data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }), }); }); it('removes the attendee when promote requested but waitlist is empty', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.attendee.findFirst.mockResolvedValue(attendee() as never); prisma.payment.count.mockResolvedValue(0); prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); const res = await makeApp({ id: teacherId }).request( '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', { method: 'DELETE' }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.removedAttendeeId).toBe('att-1'); expect(body.promoted).toBeNull(); expect(prisma.attendee.create).not.toHaveBeenCalled(); }); it('blocks removal when the attendee has payments', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.attendee.findFirst.mockResolvedValue(attendee() as never); prisma.payment.count.mockResolvedValue(2); const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { method: 'DELETE', }); expect(res.status).toBe(409); const body = await res.json(); expect(body.code).toBe('attendee_has_payments'); expect(prisma.attendee.delete).not.toHaveBeenCalled(); }); it('returns 404 when the attendee does not belong to the group', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); prisma.attendee.findFirst.mockResolvedValue(null); const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', { method: 'DELETE', }); expect(res.status).toBe(404); expect(prisma.attendee.delete).not.toHaveBeenCalled(); }); it('rejects a non-owner user', async () => { prisma.group.findUnique.mockResolvedValue(mockGroup as never); const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', { method: 'DELETE', }); expect(res.status).toBe(403); expect(prisma.attendee.delete).not.toHaveBeenCalled(); }); }); });