feat: add absence notification and analytics features

- Implemented AbsenceNotifyView for notifying absence with session details.
- Created AnalyticsView for displaying group analytics and managing students at risk.
- Added ClassAttendanceView for marking attendance in classes.
- Defined new schemas for analytics and attendance in shared package.
This commit is contained in:
Jose Selesan
2026-09-23 16:24:45 -03:00
parent a363e9e8fc
commit 2e184845e1
59 changed files with 3854 additions and 23 deletions

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'bun:test';
import {
classifyRisk,
lastAttendedAt,
monthlyAttendanceRate,
trailingAbsentStreak,
} from '@/modules/analytics/lib/risk';
const day = 24 * 60 * 60 * 1000;
const at = (offsetDays: number) => new Date(Date.now() - offsetDays * day);
function entry(status: 'PRESENT' | 'ABSENT' | 'EXCUSED', offsetDays: number) {
return { status, occurredAt: at(offsetDays) };
}
describe('trailingAbsentStreak', () => {
it('devuelve 0 sin registros', () => {
expect(trailingAbsentStreak([])).toBe(0);
});
it('cuenta ausencias consecutivas sin justificar desde la más reciente', () => {
expect(trailingAbsentStreak([entry('ABSENT', 0)])).toBe(1);
expect(trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('ABSENT', 14)])).toBe(3);
});
it('rompe la cadena con PRESENT o EXCUSED', () => {
expect(
trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('PRESENT', 14)]),
).toBe(2);
expect(trailingAbsentStreak([entry('EXCUSED', 0), entry('ABSENT', 7)])).toBe(0);
expect(trailingAbsentStreak([entry('PRESENT', 0)])).toBe(0);
});
});
describe('lastAttendedAt', () => {
it('devuelve la clase PRESENT más reciente', () => {
const result = lastAttendedAt([
entry('PRESENT', 0),
entry('ABSENT', 7),
entry('ABSENT', 14),
]);
expect(result).not.toBeNull();
expect(result?.getTime()).toBe(at(0).getTime());
});
it('devuelve null si nunca asistió', () => {
expect(lastAttendedAt([entry('ABSENT', 0), entry('EXCUSED', 7)])).toBeNull();
expect(lastAttendedAt([])).toBeNull();
});
});
describe('monthlyAttendanceRate', () => {
it('razón de PRESENT sobre el total de sesiones', () => {
expect(monthlyAttendanceRate([entry('PRESENT', 0), entry('PRESENT', 7), entry('ABSENT', 14)], 5)).toBe(40);
});
it('devuelve 0 sin sesiones o sin presentes', () => {
expect(monthlyAttendanceRate([], 3)).toBe(0);
expect(monthlyAttendanceRate([entry('ABSENT', 0)], 3)).toBe(0);
});
});
describe('classifyRisk', () => {
it('HIGH con 3 o más ausencias consecutivas', () => {
expect(classifyRisk(3, 80)).toBe('HIGH');
expect(classifyRisk(5, 20)).toBe('HIGH');
});
it('MEDIUM con 2 ausencias consecutivas o asistencia menor al 50%', () => {
expect(classifyRisk(2, 90)).toBe('MEDIUM');
expect(classifyRisk(1, 40)).toBe('MEDIUM');
expect(classifyRisk(2, 40)).toBe('MEDIUM');
});
it('sin riesgo si no cumple ningún umbral', () => {
expect(classifyRisk(1, 60)).toBeNull();
expect(classifyRisk(0, 60)).toBeNull();
});
});

View File

@@ -0,0 +1,325 @@
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<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => 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<string, unknown> = {}) {
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<string, unknown> = {}) {
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);
});
});
});

View File

@@ -27,6 +27,7 @@ const attendee = {
guardianName: 'Luis Pérez',
guardianPhone: null,
notes: null,
notifyToken: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};

View File

@@ -0,0 +1,507 @@
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: new Date('2026-09-23T19:00:00.000Z'),
group: {
id: 'group-1',
name: 'Funcional',
createdById: userId,
members: [],
capacity: 10,
},
...overrides,
};
}
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 = new Date('2026-09-23T19:00:00.000Z');
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 = new Date('2026-09-23T19:00:00.000Z');
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();
});
});
});

View File

@@ -38,6 +38,18 @@ describe('session auth', () => {
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false);
});
it('classifies absence notification endpoints as public (token-based)', () => {
expect(isPublicApiRequest('GET', '/api/v1/classes/absence?token=abc')).toBe(true);
expect(isPublicApiRequest('POST', '/api/v1/classes/notify-absence')).toBe(true);
// Las rutas autenticadas de clases siguen protegidas.
expect(isPublicApiRequest('GET', '/api/v1/classes/today')).toBe(false);
expect(isPublicApiRequest('GET', '/api/v1/classes/session-1/students')).toBe(false);
expect(isPublicApiRequest('POST', '/api/v1/classes/session-1/attendance')).toBe(false);
expect(
isPublicApiRequest('POST', '/api/v1/classes/session-1/notify-absence'),
).toBe(false);
});
it('allows public requests without a session', async () => {
const app = new Hono();
app.use('*', sessionAuthMiddleware);