import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; import { Hono } from 'hono'; const db = { user: { findUnique: mock(), update: mock(), }, merchantAccount: { findUnique: mock(), upsert: mock(), }, group: { create: mock(), }, groupMember: { create: 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 { onboardingRoutes } from '@/modules/onboarding'; const userId = 'user-1'; function makeApp(userValue: unknown) { const app = new Hono(); app.use('*', async (c, next) => { c.set('user', userValue as never); await next(); }); app.route('/onboarding', onboardingRoutes); return app; } const group = { id: 'group-1', name: 'Clase de Yoga', description: null, createdById: userId, createdAt: new Date('2026-08-01T10:00:00.000Z'), updatedAt: new Date('2026-08-01T10:00:00.000Z'), days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], time: '09:00', capacity: 20, price: 150.5, billingType: 'MONTHLY', dueDay: 5, }; const firstGroupPayload = { name: group.name, days: group.days, time: group.time, capacity: group.capacity, price: group.price, billingType: group.billingType, dueDay: group.dueDay, }; describe('onboarding routes', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('GET /onboarding/status', () => { it('returns NOT_STARTED for a user that has not started', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); prisma.merchantAccount.findUnique.mockResolvedValue(null); const res = await makeApp({ id: userId }).request('/onboarding/status'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ step: 'NOT_STARTED', paymentConnected: false, completed: false, }); }); it('returns PAYMENT_CONNECTED when the merchant account exists', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); const res = await makeApp({ id: userId }).request('/onboarding/status'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ step: 'PAYMENT_CONNECTED', paymentConnected: true, completed: false, }); }); it('returns COMPLETED when onboarding is already done', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true }); const res = await makeApp({ id: userId }).request('/onboarding/status'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ step: 'COMPLETED', paymentConnected: true, completed: true, }); }); it('rejects without a session', async () => { const res = await makeApp(null).request('/onboarding/status'); expect(res.status).toBe(401); expect(await res.json()).toMatchObject({ code: 'unauthorized' }); }); }); describe('POST /onboarding/payment-setup', () => { it('connects a merchant account in sandbox mode', async () => { prisma.merchantAccount.upsert.mockResolvedValue({ id: 'ma-1', userId }); const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', { method: 'POST', body: JSON.stringify({ provider: 'MERCADO_PAGO', sandbox: true }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ provider: 'MERCADO_PAGO', sandbox: true, connected: true, }); expect(prisma.merchantAccount.upsert).toHaveBeenCalledWith({ where: { userId }, create: { userId, provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true }, update: { provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true }, }); }); it('rejects an unknown provider', async () => { const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', { method: 'POST', body: JSON.stringify({ provider: 'PAYPAL' }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(400); }); it('rejects without a session', async () => { const res = await makeApp(null).request('/onboarding/payment-setup', { method: 'POST', body: JSON.stringify({ provider: 'STRIPE', sandbox: true }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(401); }); }); describe('POST /onboarding/first-group', () => { it('creates the first group and completes onboarding', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); prisma.group.create.mockResolvedValue(group); const res = await makeApp({ id: userId }).request('/onboarding/first-group', { method: 'POST', body: JSON.stringify(firstGroupPayload), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(201); expect(await res.json()).toEqual({ group: { id: 'group-1', name: 'Clase de Yoga', description: null, createdById: userId, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], time: '09:00', capacity: 20, price: 150.5, billingType: 'MONTHLY', dueDay: 5, }, onboardingCompleted: true, }); expect(prisma.group.create).toHaveBeenCalledWith({ data: { name: 'Clase de Yoga', createdById: userId, days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'], time: '09:00', capacity: 20, price: 150.5, billingType: 'MONTHLY', dueDay: 5, }, }); expect(prisma.groupMember.create).toHaveBeenCalledWith({ data: { groupId: 'group-1', userId, role: 'OWNER' }, }); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: userId }, data: { onboardingCompleted: true }, }); }); it('rejects when the payment has not been set up', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); prisma.merchantAccount.findUnique.mockResolvedValue(null); const res = await makeApp({ id: userId }).request('/onboarding/first-group', { method: 'POST', body: JSON.stringify(firstGroupPayload), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(409); expect(await res.json()).toMatchObject({ code: 'payment_not_setup' }); expect(prisma.group.create).not.toHaveBeenCalled(); }); it('rejects when onboarding is already completed', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true }); const res = await makeApp({ id: userId }).request('/onboarding/first-group', { method: 'POST', body: JSON.stringify(firstGroupPayload), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(409); expect(await res.json()).toMatchObject({ code: 'onboarding_already_completed' }); expect(prisma.group.create).not.toHaveBeenCalled(); }); it('rejects invalid payloads', async () => { prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false }); prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId }); const res = await makeApp({ id: userId }).request('/onboarding/first-group', { method: 'POST', body: JSON.stringify({ name: 'Ab', days: [], time: '25:99', capacity: 0, price: -1, billingType: 'WEEKLY', dueDay: 31, }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(400); expect(prisma.group.create).not.toHaveBeenCalled(); }); it('rejects without a session', async () => { const res = await makeApp(null).request('/onboarding/first-group', { method: 'POST', body: JSON.stringify(firstGroupPayload), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(401); }); }); });