- 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.
277 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
}); |