import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; import { Hono } from 'hono'; const db = { group: { findUnique: mock(), }, classSession: { findMany: mock(), }, attendee: { findMany: mock(), findFirst: mock(), findUnique: mock(), update: mock(), }, attendance: { findMany: mock(), }, slotRelease: { 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 { analyticsRoutes, studentStatusRoutes } from '@/modules/analytics'; const userId = 'user-1'; const DAY_MS = 24 * 60 * 60 * 1000; const now = Date.now(); const at = (offsetDays: number) => new Date(now - offsetDays * DAY_MS).toISOString(); function makeApp(userValue: unknown) { const app = new Hono(); app.use('*', async (c, next) => { c.set('user', userValue as never); await next(); }); app.route('/groups', analyticsRoutes); app.route('/students', studentStatusRoutes); return app; } function makeGroup(overrides: Record = {}) { return { id: 'group-1', name: 'Funcional', createdById: userId, members: [], ...overrides, }; } describe('analytics routes', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('GET /groups/:groupId/analytics', () => { it('calcula métricas de asistencia de los últimos 30 días', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.classSession.findMany.mockResolvedValue([{ id: 's-1' }, { id: 's-2' }]); prisma.attendance.findMany.mockResolvedValue([ { status: 'PRESENT' }, { status: 'PRESENT' }, { status: 'ABSENT' }, { status: 'EXCUSED' }, ]); prisma.slotRelease.count.mockResolvedValue(2); const res = await makeApp({ id: userId }).request('/groups/group-1/analytics'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendanceRate: 50, totalPresent: 2, totalClasses: 2, recoveredSlots: 2, }); }); it('devuelve 0 en todas las métricas sin clases en el período', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.classSession.findMany.mockResolvedValue([]); prisma.attendance.findMany.mockResolvedValue([]); prisma.slotRelease.count.mockResolvedValue(0); const res = await makeApp({ id: userId }).request('/groups/group-1/analytics'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendanceRate: 0, totalPresent: 0, totalClasses: 0, recoveredSlots: 0, }); }); it('prohíbe el acceso a quien no es owner ni miembro', async () => { prisma.group.findUnique.mockResolvedValue( makeGroup({ createdById: 'someone-else' }), ); const res = await makeApp({ id: userId }).request('/groups/group-1/analytics'); expect(res.status).toBe(403); expect(await res.json()).toMatchObject({ code: 'group_access_denied' }); }); it('devuelve 404 si el grupo no existe', async () => { prisma.group.findUnique.mockResolvedValue(null); const res = await makeApp({ id: userId }).request('/groups/group-1/analytics'); expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ code: 'not_found' }); }); it('rechaza requests sin sesión', async () => { const res = await makeApp(null).request('/groups/group-1/analytics'); expect(res.status).toBe(401); }); }); describe('GET /groups/:groupId/students-at-risk', () => { const sessions = [at(0), at(7), at(14), at(21)]; it('clasifica HIGH y MEDIUM según el historial de asistencia', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt }))); prisma.attendee.findMany.mockResolvedValue([ { id: 'a-1', fullName: 'Ana Pérez', phone: '+521111111111' }, { id: 'a-2', fullName: 'Bruno Díaz', phone: null }, ]); prisma.attendance.findMany.mockResolvedValue([ { attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } }, { attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } }, { attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(14)) } }, { attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } }, { attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } }, { attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } }, { attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(14)) } }, { attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } }, ]); const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ groupName: 'Funcional', data: [ { attendeeId: 'a-1', fullName: 'Ana Pérez', phone: '+521111111111', riskLevel: 'HIGH', consecutiveAbsences: 3, lastAttendedAt: at(21), monthlyAttendanceRate: 25, }, { attendeeId: 'a-2', fullName: 'Bruno Díaz', phone: null, riskLevel: 'MEDIUM', consecutiveAbsences: 2, lastAttendedAt: at(14), monthlyAttendanceRate: 50, }, ], }); }); it('devuelve lista vacía cuando nadie está en riesgo', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt }))); prisma.attendee.findMany.mockResolvedValue([ { id: 'a-1', fullName: 'Ana Pérez', phone: null }, ]); prisma.attendance.findMany.mockResolvedValue([ { attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(0)) } }, { attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(7)) } }, ]); const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ groupName: 'Funcional', data: [] }); }); }); describe('GET /groups/:groupId/attendees/:attendeeId/history', () => { it('devuelve el historial de presentismo con su porcentaje', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.attendee.findFirst.mockResolvedValue({ id: 'a-1', fullName: 'Ana Pérez' }); prisma.classSession.findMany.mockResolvedValue([ { id: 's-2', startsAt: new Date(at(7)) }, { id: 's-1', startsAt: new Date(at(0)) }, ]); prisma.attendance.findMany.mockResolvedValue([ { classSessionId: 's-1', status: 'PRESENT' }, { classSessionId: 's-2', status: 'ABSENT' }, ]); const res = await makeApp({ id: userId }).request( '/groups/group-1/attendees/a-1/history', ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendeeId: 'a-1', fullName: 'Ana Pérez', groupId: 'group-1', groupName: 'Funcional', attendanceRate: 50, sessions: [ { classSessionId: 's-1', startsAt: at(0), status: 'PRESENT' }, { classSessionId: 's-2', startsAt: at(7), status: 'ABSENT' }, ], }); }); it('devuelve 404 si el alumno no pertenece al grupo', async () => { prisma.group.findUnique.mockResolvedValue(makeGroup()); prisma.attendee.findFirst.mockResolvedValue(null); const res = await makeApp({ id: userId }).request( '/groups/group-1/attendees/nope/history', ); expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ code: 'not_found' }); }); }); describe('POST /students/:studentId/status', () => { function makeAttendee(status: string, overrides: Record = {}) { return { id: 'a-1', status, group: { createdById: userId, members: [] }, ...overrides, }; } it('cambia el estado del alumno', async () => { prisma.attendee.findUnique.mockResolvedValue(makeAttendee('ACTIVE')); prisma.attendee.update.mockResolvedValue({ id: 'a-1', status: 'DROPPED' }); const res = await makeApp({ id: userId }).request('/students/a-1/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'DROPPED' }), }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'DROPPED' }); expect(prisma.attendee.update).toHaveBeenCalledWith({ where: { id: 'a-1' }, data: { status: 'DROPPED' }, select: { id: true, status: true }, }); }); it('es idempotente si el estado no cambia', async () => { prisma.attendee.findUnique.mockResolvedValue(makeAttendee('PAUSED')); const res = await makeApp({ id: userId }).request('/students/a-1/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'PAUSED' }), }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'PAUSED' }); expect(prisma.attendee.update).not.toHaveBeenCalled(); }); it('prohíbe el acceso a quien no es owner ni miembro', async () => { prisma.attendee.findUnique.mockResolvedValue( makeAttendee('ACTIVE', { group: { createdById: 'someone-else', members: [] } }), ); const res = await makeApp({ id: userId }).request('/students/a-1/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'PAUSED' }), }); expect(res.status).toBe(403); expect(await res.json()).toMatchObject({ code: 'group_access_denied' }); }); it('rechaza estados fuera del contrato', async () => { const res = await makeApp({ id: userId }).request('/students/a-1/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'HOLD' }), }); expect(res.status).toBe(400); expect(prisma.attendee.update).not.toHaveBeenCalled(); }); it('rechaza requests sin sesión', async () => { const res = await makeApp(null).request('/students/a-1/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'ACTIVE' }), }); expect(res.status).toBe(401); }); }); });