515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
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<unknown>) => cb(db));
|
|
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => 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<string, unknown> = {}) {
|
|
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();
|
|
});
|
|
});
|
|
});
|