- 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.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}); |