import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; import { Hono } from 'hono'; const db = { group: { findMany: mock(), }, classSession: { upsert: mock(), findMany: mock(), findUnique: mock(), }, attendee: { findMany: mock(), findUnique: mock(), }, payment: { findMany: mock(), }, attendance: { findMany: mock(), upsert: mock(), }, slotRelease: { findMany: mock(), upsert: 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 { classesRoutes } from '@/modules/classes'; const userId = 'user-1'; const UTC_WEEKDAYS = [ 'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', ] as const; const todayUtc = UTC_WEEKDAYS[new Date().getUTCDay()]; function makeApp(userValue: unknown) { const app = new Hono(); app.use('*', async (c, next) => { c.set('user', userValue as never); await next(); }); app.route('/classes', classesRoutes); return app; } function makeSession(overrides: Record = {}) { return { id: 'session-1', groupId: 'group-1', startsAt: todayAtUtc(), group: { id: 'group-1', name: 'Funcional', createdById: userId, members: [], capacity: 10, }, ...overrides, }; } // Fecha "hoy" a las 19:00 UTC, del día real del runner. function todayAtUtc() { const date = new Date(); date.setUTCHours(19, 0, 0, 0); return date; } describe('classes routes', () => { beforeEach(() => { vi.clearAllMocks(); delete process.env.ABSENCE_RELEASE_HOURS; }); describe('GET /classes/today', () => { it('materializa y lista las clases de hoy del profesor', async () => { const startsAt = todayAtUtc(); prisma.group.findMany.mockResolvedValue([ { id: 'group-1', name: 'Funcional', days: [todayUtc], time: '19:00', capacity: 10, _count: { attendees: 8 }, }, ]); prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' }); prisma.classSession.findMany.mockResolvedValue([ { id: 'session-1', groupId: 'group-1', startsAt, group: { id: 'group-1', name: 'Funcional', capacity: 10 } }, ]); prisma.attendance.findMany.mockResolvedValue([{ classSessionId: 'session-1' }]); const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ data: [ { sessionId: 'session-1', groupId: 'group-1', groupName: 'Funcional', startsAt: startsAt.toISOString(), enrolledCount: 8, capacity: 10, availableSlots: 2, hasAttendance: true, }, ], }); expect(prisma.classSession.upsert).toHaveBeenCalledTimes(1); expect(prisma.classSession.upsert).toHaveBeenCalledWith({ where: { groupId_startsAt: { groupId: 'group-1', startsAt }, }, create: { groupId: 'group-1', startsAt }, update: {}, select: { id: true }, }); }); it('devuelve lista vacía cuando ningún grupo tiene clase hoy', async () => { prisma.group.findMany.mockResolvedValue([]); const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ data: [] }); expect(prisma.classSession.upsert).not.toHaveBeenCalled(); }); it('rechaza requests sin sesión', async () => { const res = await makeApp(null).request('/classes/today'); expect(res.status).toBe(401); expect(await res.json()).toMatchObject({ code: 'unauthorized' }); }); }); describe('GET /classes/:sessionId/students', () => { it('incluye estado de pago dinámico, asistencia y ausencia avisada', async () => { prisma.classSession.findUnique.mockResolvedValue(makeSession()); prisma.attendee.findMany.mockResolvedValue([ { id: 'attendee-1', fullName: 'Ana Pérez' }, { id: 'attendee-2', fullName: 'Bruno Díaz' }, ]); prisma.payment.findMany.mockResolvedValue([ { attendeeId: 'attendee-1', status: 'OVERDUE', dueDate: new Date('2026-09-01T00:00:00.000Z') }, { attendeeId: 'attendee-2', status: 'PAID', dueDate: new Date('2026-09-01T00:00:00.000Z') }, ]); prisma.attendance.findMany.mockResolvedValue([ { attendeeId: 'attendee-2', status: 'EXCUSED' }, ]); prisma.slotRelease.findMany.mockResolvedValue([]); const res = await makeApp({ id: userId }).request( '/classes/session-1/students?timeZone=UTC', ); expect(res.status).toBe(200); const body = await res.json(); expect(body).toMatchObject({ sessionId: 'session-1', groupId: 'group-1', groupName: 'Funcional', students: [ { attendeeId: 'attendee-1', fullName: 'Ana Pérez', paymentStatus: 'PENDING', attendanceStatus: null, notifiedAbsence: false, }, { attendeeId: 'attendee-2', fullName: 'Bruno Díaz', paymentStatus: 'UP_TO_DATE', attendanceStatus: 'EXCUSED', notifiedAbsence: true, }, ], }); }); it('prohíbe el acceso a quien no es owner ni miembro del grupo', async () => { prisma.classSession.findUnique.mockResolvedValue( makeSession({ group: { id: 'group-1', name: 'Funcional', createdById: 'someone-else', members: [], }, }), ); const res = await makeApp({ id: userId }).request('/classes/session-1/students'); expect(res.status).toBe(403); expect(await res.json()).toMatchObject({ code: 'group_access_denied' }); }); it('devuelve 404 si la sesión no existe', async () => { prisma.classSession.findUnique.mockResolvedValue(null); const res = await makeApp({ id: userId }).request('/classes/nope/students'); expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ code: 'not_found' }); }); }); describe('POST /classes/:sessionId/attendance', () => { const validBody = { classSessionId: 'session-1', records: [ { attendeeId: 'attendee-1', status: 'PRESENT' }, { attendeeId: 'attendee-2', status: 'ABSENT' }, ], }; it('guarda la asistencia masiva con upsert', async () => { prisma.classSession.findUnique.mockResolvedValue(makeSession()); prisma.attendee.findMany.mockResolvedValue([ { id: 'attendee-1' }, { id: 'attendee-2' }, ]); prisma.attendance.upsert.mockResolvedValue({}); const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(validBody), }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ classSessionId: 'session-1', marked: 2 }); expect(prisma.attendance.upsert).toHaveBeenCalledTimes(2); expect(prisma.attendance.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { attendeeId_classSessionId: { attendeeId: 'attendee-1', classSessionId: 'session-1', }, }, }), ); }); it('rechaza cuando el classSessionId del body no coincide con la ruta', async () => { const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...validBody, classSessionId: 'other-session' }), }); expect(res.status).toBe(400); expect(await res.json()).toMatchObject({ status: 400 }); }); it('rechaza alumnos que no pertenecen al grupo', async () => { prisma.classSession.findUnique.mockResolvedValue(makeSession()); prisma.attendee.findMany.mockResolvedValue([{ id: 'attendee-1' }]); const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(validBody), }); expect(res.status).toBe(400); expect(prisma.attendance.upsert).not.toHaveBeenCalled(); }); it('rechaza registros con status fuera del contrato', async () => { const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ classSessionId: 'session-1', records: [{ attendeeId: 'attendee-1', status: 'EXCUSED' }], }), }); expect(res.status).toBe(400); }); it('rechaza requests sin sesión', async () => { const res = await makeApp(null).request('/classes/session-1/attendance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(validBody), }); expect(res.status).toBe(401); }); }); describe('POST /classes/:sessionId/notify-absence (profesor)', () => { const notifyBody = { attendeeId: 'attendee-1', classSessionId: 'session-1', }; it('libera el cupo cuando la anticipación alcanza las 12 horas', async () => { const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000); prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt })); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', groupId: 'group-1', }); prisma.attendance.upsert.mockResolvedValue({}); prisma.slotRelease.upsert.mockResolvedValue({}); const res = await makeApp({ id: userId }).request( '/classes/session-1/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(notifyBody), }, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ classSessionId: 'session-1', notified: true, slotReleased: true, }); expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1); expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1); }); it('marca EXCUSED sin liberar cupo si avisa con menos de 12 horas', async () => { const startsAt = new Date(Date.now() + 60 * 60 * 1000); prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt })); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', groupId: 'group-1', }); prisma.attendance.upsert.mockResolvedValue({}); prisma.slotRelease.upsert.mockResolvedValue({}); const res = await makeApp({ id: userId }).request( '/classes/session-1/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(notifyBody), }, ); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ slotReleased: false }); expect(prisma.slotRelease.upsert).not.toHaveBeenCalled(); expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1); }); it('respeta el override configurable ABSENCE_RELEASE_HOURS', async () => { process.env.ABSENCE_RELEASE_HOURS = '4'; const startsAt = new Date(Date.now() + 6 * 60 * 60 * 1000); prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt })); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', groupId: 'group-1', }); prisma.attendance.upsert.mockResolvedValue({}); prisma.slotRelease.upsert.mockResolvedValue({}); const res = await makeApp({ id: userId }).request( '/classes/session-1/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(notifyBody), }, ); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ slotReleased: true }); expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1); delete process.env.ABSENCE_RELEASE_HOURS; }); it('rechaza un alumno de otro grupo', async () => { prisma.classSession.findUnique.mockResolvedValue(makeSession()); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', groupId: 'another-group', }); const res = await makeApp({ id: userId }).request( '/classes/session-1/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(notifyBody), }, ); expect(res.status).toBe(400); expect(prisma.attendance.upsert).not.toHaveBeenCalled(); }); }); describe('rutas públicas con token', () => { it('GET /classes/absence devuelve 404 con token inválido', async () => { prisma.attendee.findUnique.mockResolvedValue(null); const res = await makeApp(null).request('/classes/absence?token=bad-token'); expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ code: 'not_found' }); }); it('GET /classes/absence devuelve las clases de hoy del alumno', async () => { const mxWeekday = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Mexico_City', weekday: 'long', }) .format(new Date()) .toUpperCase(); const startsAt = todayAtUtc(); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', fullName: 'Ana Pérez', group: { id: 'group-1', name: 'Funcional', days: [mxWeekday], time: '19:00', }, }); prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' }); prisma.classSession.findMany.mockResolvedValue([ { id: 'session-1', groupId: 'group-1', startsAt }, ]); prisma.attendance.findMany.mockResolvedValue([]); prisma.slotRelease.findMany.mockResolvedValue([]); const res = await makeApp(null).request('/classes/absence?token=valid-token'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendeeId: 'attendee-1', fullName: 'Ana Pérez', groupName: 'Funcinal'.replace('Funcinal', 'Funcional'), sessions: [ { sessionId: 'session-1', groupName: 'Funcional', startsAt: startsAt.toISOString(), notified: false, attendanceStatus: null, }, ], }); }); it('POST /classes/notify-absence público registra el aviso con token', async () => { const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', groupId: 'group-1', }); prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt })); prisma.attendance.upsert.mockResolvedValue({}); prisma.slotRelease.upsert.mockResolvedValue({}); const res = await makeApp(null).request('/classes/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'valid-token', classSessionId: 'session-1' }), }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ notified: true, slotReleased: true }); expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1); }); it('POST /classes/notify-absence público devuelve 404 con token inválido', async () => { prisma.attendee.findUnique.mockResolvedValue(null); const res = await makeApp(null).request('/classes/notify-absence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'bad', classSessionId: 'session-1' }), }); expect(res.status).toBe(404); expect(prisma.attendance.upsert).not.toHaveBeenCalled(); }); }); });