Files
gruperly/apps/backend/test/home.test.ts
Jose Selesan f1e0fc5aae feat(home): implement home summary feature with routes and use case
- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
2026-09-23 10:35:24 -03:00

277 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
payment: {
aggregate: mock(),
findMany: 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 { homeRoutes } from '@/modules/home';
import { GetHomeSummary } from '@/modules/home/features/get-summary/use-case';
const TZ = 'America/Mexico_City';
const userId = 'user-1';
const groupA = {
id: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
capacity: 8,
_count: { attendees: 3 },
};
const groupB = {
id: 'g2',
name: 'Cuadrilla B',
days: [],
time: null,
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 2 },
};
const madeUpAmount = (value: string) => ({ toString: () => value });
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/home', homeRoutes);
return app;
}
describe('GetHomeSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('agrega stats, próxima clase y próximos cobros del usuario', async () => {
prisma.group.findMany.mockResolvedValue([groupB, groupA]);
prisma.payment.aggregate.mockResolvedValue({
_count: 4,
_sum: { amount: madeUpAmount('2100') },
});
prisma.payment.findMany.mockResolvedValue([
{
id: 'p1',
amount: madeUpAmount('500'),
currency: 'MXN',
dueDate: new Date('2026-09-25T12:00:00.000Z'),
status: 'PENDING',
group: { name: 'Cuadrilla A' },
attendee: { fullName: 'Ana García' },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 2, attendees: 5, pendingPayments: 4, pendingAmount: 2100 },
nextClass: {
groupId: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: false,
},
upcomingPayments: [
{
id: 'p1',
amount: 500,
currency: 'MXN',
dueDate: '2026-09-25T12:00:00.000Z',
status: 'PENDING',
groupName: 'Cuadrilla A',
attendeeName: 'Ana García',
},
],
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({ where, select: expect.any(Object) });
expect(prisma.payment.aggregate).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' }],
take: 5,
include: { group: { select: { name: true } }, attendee: { select: { fullName: true } } },
});
});
it('marca como en curso la clase que ya arrancó', async () => {
prisma.group.findMany.mockResolvedValue([groupA]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-24T01:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: true,
});
});
it('elige el grupo con la próxima clase más cercana', async () => {
prisma.group.findMany.mockResolvedValue([
groupA,
{
id: 'g3',
name: 'Cuadrilla C',
days: ['THURSDAY'],
time: '09:00',
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 0 },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass?.groupId).toBe('g1');
expect(result.value.nextClass?.occurrenceAt).toBe('2026-09-24T00:00:00.000Z');
});
it('devuelve nextClass null si ningún grupo tiene horario', async () => {
prisma.group.findMany.mockResolvedValue([groupB]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toBeNull();
expect(prisma.payment.aggregate).toHaveBeenCalled();
expect(prisma.payment.findMany).toHaveBeenCalled();
});
it('computa la próxima clase en la zona horaria del cliente', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires (UTC-3). La clase de las 10:00 ya arrancó.
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId, { timeZone: 'America/Argentina/Buenos_Aires' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-23T13:00:00.000Z',
isNow: true,
});
});
it('usa la zona por defecto si no se envía una', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires, pero la zona por defecto es CDMX → las 07:23 CDMX (aún no empieza).
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
occurrenceAt: '2026-09-23T16:00:00.000Z',
isNow: false,
});
});
it('devuelve ceros y listas vacías cuando el usuario no tiene grupos', async () => {
prisma.group.findMany.mockResolvedValue([]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
expect(prisma.payment.aggregate).not.toHaveBeenCalled();
expect(prisma.payment.findMany).not.toHaveBeenCalled();
});
});
describe('home routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('devuelve el resumen del home autenticado', async () => {
prisma.group.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request('/home');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
});
it('rechaza el home sin sesión', async () => {
const res = await makeApp(null).request('/home');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rechaza una zona horaria inválida', async () => {
const res = await makeApp({ id: userId }).request('/home?timeZone=No/Es_Una_Zona');
expect(res.status).toBe(400);
});
});