- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups. - Implemented API route to add users to the group waitlist with validation. - Enhanced attendee creation logic to handle group capacity and waitlisting scenarios. - Added new problem builders for handling waitlist-related errors. - Updated frontend to support waitlist interactions, including modals for capacity warnings. - Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
|
import { Hono } from 'hono';
|
|
|
|
const db = {
|
|
group: {
|
|
findUnique: mock(),
|
|
findFirst: mock(),
|
|
update: mock(),
|
|
count: mock(),
|
|
},
|
|
attendee: {
|
|
findMany: mock(),
|
|
findFirst: mock(),
|
|
create: mock(),
|
|
count: mock(),
|
|
},
|
|
groupWaitlistEntry: {
|
|
findMany: mock(),
|
|
findFirst: mock(),
|
|
create: mock(),
|
|
count: mock(),
|
|
},
|
|
};
|
|
|
|
mock.module('@/lib/prisma', () => ({
|
|
default: db,
|
|
getPrismaClient: mock(),
|
|
UnitOfWork: class {},
|
|
}));
|
|
|
|
import prisma from '@/lib/prisma';
|
|
import { groupsRoutes } from '@/modules/groups';
|
|
|
|
const teacherId = 'teacher-1';
|
|
const mockGroup = {
|
|
id: 'group-1',
|
|
name: 'Taller de Pintura',
|
|
description: 'Acuarelas para principiantes',
|
|
createdById: teacherId,
|
|
inviteToken: 'pintura-token-xyz',
|
|
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: [],
|
|
};
|
|
|
|
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('attendee incorporation & invite-token in groups', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('GET /groups/:groupId/invite-token', () => {
|
|
it('returns existing invite token and url', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/invite-token');
|
|
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
expect(data.token).toBe('pintura-token-xyz');
|
|
expect(data.inviteUrl).toContain('/join/pintura-token-xyz');
|
|
});
|
|
|
|
it('generates a new token if group did not have one', async () => {
|
|
prisma.group.findUnique.mockResolvedValue({
|
|
...mockGroup,
|
|
inviteToken: null,
|
|
} as never);
|
|
prisma.group.update.mockResolvedValue({} as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/invite-token');
|
|
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
expect(data.token).toBeDefined();
|
|
expect(data.token.length).toBeGreaterThan(5);
|
|
expect(prisma.group.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects user without access to the group', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
|
|
const app = makeApp({ id: 'other-user' });
|
|
const res = await app.request('/groups/group-1/invite-token');
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /groups/:groupId/attendees (individual quick add)', () => {
|
|
it('creates an attendee for the group', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.attendee.create.mockResolvedValue({
|
|
id: 'att-1',
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
email: 'martin@example.com',
|
|
phone: '+5491133445566',
|
|
guardianName: null,
|
|
guardianPhone: null,
|
|
notes: 'Viene con su hermano',
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
} as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '+54 9 11 3344-5566',
|
|
email: 'martin@example.com',
|
|
notes: 'Viene con su hermano',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const data = await res.json();
|
|
expect(data.outcome).toBe('created');
|
|
expect(data.attendee.fullName).toBe('Martín Gómez');
|
|
expect(data.attendee.phone).toBe('+5491133445566');
|
|
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
|
data: {
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
phone: '+5491133445566',
|
|
email: 'martin@example.com',
|
|
notes: 'Viene con su hermano',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects duplicate phone number in individual add', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects owner with 409 group_capacity_reached when the group is full', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.attendee.count.mockResolvedValue(20); // capacity full
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
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.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows the owner to add anyway when allowOverflow is set', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.attendee.count.mockResolvedValue(20);
|
|
prisma.attendee.create.mockResolvedValue({
|
|
id: 'att-1',
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
email: null,
|
|
phone: '+5491133445566',
|
|
guardianName: null,
|
|
guardianPhone: null,
|
|
notes: null,
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
} as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.outcome).toBe('created');
|
|
expect(body.attendee.fullName).toBe('Martín Gómez');
|
|
expect(prisma.attendee.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('adds to the waitlist when a non-owner member tries to add to a full group', async () => {
|
|
const memberGroup = {
|
|
...mockGroup,
|
|
members: [{ id: 'gm-1', role: 'MEMBER' }],
|
|
};
|
|
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.attendee.count.mockResolvedValue(20);
|
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
|
prisma.groupWaitlistEntry.create.mockResolvedValue({
|
|
id: 'wl-1',
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
phone: '+5491133445566',
|
|
email: null,
|
|
notes: null,
|
|
status: 'PENDING',
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
} as never);
|
|
|
|
const app = makeApp({ id: 'member-1' });
|
|
const res = await app.request('/groups/group-1/attendees', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.outcome).toBe('waitlisted');
|
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
|
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
|
|
data: {
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
phone: '1133445566',
|
|
email: null,
|
|
notes: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('still waitlists a non-owner member even when allowOverflow is set', async () => {
|
|
const memberGroup = {
|
|
...mockGroup,
|
|
members: [{ id: 'gm-1', role: 'MEMBER' }],
|
|
};
|
|
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.attendee.count.mockResolvedValue(20);
|
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
|
|
|
const app = makeApp({ id: 'member-1' });
|
|
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.outcome).toBe('waitlisted');
|
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
|
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /groups/:groupId/waitlist (add to group waitlist)', () => {
|
|
it('creates a waitlist entry for the group', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
|
prisma.groupWaitlistEntry.create.mockResolvedValue({
|
|
id: 'wl-1',
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
phone: '+5491133445566',
|
|
email: 'martin@example.com',
|
|
notes: 'Viene con su hermano',
|
|
status: 'PENDING',
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
} as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/waitlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '+54 9 11 3344-5566',
|
|
email: 'martin@example.com',
|
|
notes: 'Viene con su hermano',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.fullName).toBe('Martín Gómez');
|
|
expect(body.phone).toBe('+5491133445566');
|
|
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
|
|
data: {
|
|
groupId: 'group-1',
|
|
fullName: 'Martín Gómez',
|
|
phone: '+5491133445566',
|
|
email: 'martin@example.com',
|
|
notes: 'Viene con su hermano',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects when the phone number already belongs to an attendee', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/waitlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
const body = await res.json();
|
|
expect(body.code).toBe('attendee_already_registered');
|
|
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects when the phone number is already on the waitlist', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue({ id: 'wl-existing' } as never);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/waitlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
const body = await res.json();
|
|
expect(body.code).toBe('already_waitlisted');
|
|
});
|
|
|
|
it('rejects user without access to the group', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
|
|
const app = makeApp({ id: 'other-user' });
|
|
const res = await app.request('/groups/group-1/waitlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: 'Martín',
|
|
lastName: 'Gómez',
|
|
phone: '1133445566',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {
|
|
it('imports multiple attendees, skipping duplicates and reporting counts', async () => {
|
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
|
// Simular que ya existe un alumno con teléfono '11111111'
|
|
prisma.attendee.findMany.mockResolvedValue([
|
|
{ phone: '11111111' },
|
|
] as never);
|
|
|
|
prisma.attendee.create.mockImplementation(async ({ data }: { data: any }) => ({
|
|
id: `att-${Math.random()}`,
|
|
...data,
|
|
guardianName: null,
|
|
guardianPhone: null,
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
}));
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees/bulk', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
attendees: [
|
|
{ firstName: 'Ana', lastName: 'García', phone: '22222222' },
|
|
{ firstName: 'Pedro', lastName: 'López', phone: '33333333' },
|
|
{ firstName: 'Duplicado', lastName: 'Existente', phone: '11111111' }, // Ya existe en BD
|
|
{ firstName: 'Duplicado', lastName: 'EnLote', phone: '22222222' }, // Duplicado dentro del lote
|
|
],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const data = await res.json();
|
|
expect(data.totalProcessed).toBe(4);
|
|
expect(data.createdCount).toBe(2);
|
|
expect(data.duplicatesCount).toBe(2);
|
|
expect(data.message).toBe('2 alumnos agregados con éxito, 2 duplicados omitidos.');
|
|
expect(prisma.attendee.create).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('GET /groups/:groupId/attendees', () => {
|
|
it('lists attendees filtered by groupId', async () => {
|
|
prisma.attendee.findMany.mockResolvedValue([
|
|
{
|
|
id: 'att-1',
|
|
groupId: 'group-1',
|
|
fullName: 'Ana García',
|
|
email: null,
|
|
phone: '22222222',
|
|
guardianName: null,
|
|
guardianPhone: null,
|
|
notes: null,
|
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
|
},
|
|
] as never);
|
|
prisma.attendee.count.mockResolvedValue(1);
|
|
|
|
const app = makeApp({ id: teacherId });
|
|
const res = await app.request('/groups/group-1/attendees');
|
|
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
expect(data.data.length).toBe(1);
|
|
expect(data.data[0].fullName).toBe('Ana García');
|
|
expect(prisma.attendee.findMany).toHaveBeenCalledWith({
|
|
where: { groupId: 'group-1' },
|
|
skip: 0,
|
|
take: 10,
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
});
|
|
});
|
|
});
|