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:
79
apps/backend/test/analytics-risk.test.ts
Normal file
79
apps/backend/test/analytics-risk.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
325
apps/backend/test/analytics.test.ts
Normal file
325
apps/backend/test/analytics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
507
apps/backend/test/classes.test.ts
Normal file
507
apps/backend/test/classes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user