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) => cb(db)); execute = mock(async (cb: (tx: unknown) => Promise) => 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); }); });