Initial attendee management

This commit is contained in:
Jose Selesan
2026-09-18 09:42:37 -03:00
parent fa30fe2cff
commit d7ebb5b234
111 changed files with 13784 additions and 36 deletions

View File

@@ -0,0 +1,240 @@
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(),
},
};
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.fullName).toBe('Martín Gómez');
expect(data.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();
});
});
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' },
});
});
});
});

View File

@@ -42,6 +42,7 @@ const group = {
price: null,
billingType: null,
dueDay: null,
inviteToken: null,
};
function makeApp(userValue: unknown) {

View File

@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findUnique: mock(),
},
attendee: {
findFirst: mock(),
create: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { invitationsRoutes } from '@/modules/invitations';
const app = new Hono();
app.route('/invitations', invitationsRoutes);
const mockGroup = {
id: 'group-1',
name: 'Taller de Cerámica',
description: 'Clases de cerámica los sábados',
days: ['SATURDAY'],
time: '10:00',
capacity: 15,
price: 500,
billingType: 'MONTHLY',
createdById: 'teacher-1',
owner: { name: 'Prof. Mario Rossi' },
};
describe('public invitations routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /invitations/:token', () => {
it('returns group details for a valid invite token', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await app.request('/invitations/valid-token-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
id: 'group-1',
name: 'Taller de Cerámica',
description: 'Clases de cerámica los sábados',
teacherName: 'Prof. Mario Rossi',
days: ['SATURDAY'],
time: '10:00',
capacity: 15,
price: 500,
billingType: 'MONTHLY',
});
expect(prisma.group.findUnique).toHaveBeenCalledWith({
where: { inviteToken: 'valid-token-123' },
include: { owner: { select: { name: true } } },
});
});
it('returns 404 when the invite token is invalid', async () => {
prisma.group.findUnique.mockResolvedValue(null);
const res = await app.request('/invitations/invalid-token');
expect(res.status).toBe(404);
const body = await res.json();
expect(body.title).toBe('Not Found');
});
});
describe('POST /invitations/:token/join', () => {
it('registers a new attendee successfully', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.create.mockResolvedValue({
id: 'att-1',
groupId: 'group-1',
fullName: 'Lucía Méndez',
email: 'lucia@test.com',
phone: '+5491122334455',
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 res = await app.request('/invitations/valid-token-123/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Lucía',
lastName: 'Méndez',
phone: '+54 9 11 2233-4455',
email: 'lucia@test.com',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.message).toBe('Inscripción realizada con éxito');
expect(body.attendee.fullName).toBe('Lucía Méndez');
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Lucía Méndez',
phone: '+5491122334455',
email: 'lucia@test.com',
},
});
});
it('rejects registration when phone already exists in the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue({ id: 'existing-att' } as never);
const res = await app.request('/invitations/valid-token-123/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Lucía',
lastName: 'Méndez',
phone: '1122334455',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_already_registered');
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('rejects invalid inputs (missing required name/phone)', async () => {
const res = await app.request('/invitations/valid-token-123/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: '',
phone: '12',
}),
});
expect(res.status).toBe(400);
});
});
});