import { afterEach, describe, expect, it, vi } from 'vitest' import { CLAVE_STORAGE, agregarNotaAsync, cancelarPlanAsync, crearPlan, crearPlanAsync, listarPlanes, obtenerPlanPorId, registrarAbonoAsync, type NuevoPlanInput, type PlanApi, type StorageLike, } from './repository' import { ErrorApi } from './api' // Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local const API_URL = import.meta.env.VITE_API_URL || '/api' import type { PlanPago } from '../types' /** Stub sencillo de localStorage para correr en entorno node */ function crearStorageStub(): StorageLike { const mapa = new Map() return { getItem: (k: string) => mapa.get(k) ?? null, setItem: (k: string, v: string) => { mapa.set(k, v) }, } } function respuestaJson(valor: unknown, status = 200): Response { return new Response(JSON.stringify(valor), { status, headers: { 'Content-Type': 'application/json' }, }) } /** fetch mockeado con la firma tipada (url, init) para inspeccionar las llamadas */ function fetchQue(impl: () => Promise) { return vi.fn<(url: string, init?: RequestInit) => Promise>(impl) } /** fetch que rechaza como cuando no hay red / el API está caído */ function fetchSinRed() { return fetchQue(async () => { throw new TypeError('fetch failed') }) } const INPUT_PLAN: NuevoPlanInput = { clienteNombre: 'Cliente Prueba', unidadCodigo: 'U-100', proyecto: 'SENDA', ventaRef: 'VTA-1', fechaInicio: '2026-09-01', engancheTotal: 60000, numPagos: 6, } /** Plan con el shape del API Gateway (nulls de Prisma); los calculados extra se agregan inline */ function planApi(overrides: Partial = {}): PlanApi { return { id: 'api-1', ventaRef: null, clienteNombre: 'Cliente API', clienteTelefono: null, unidadCodigo: 'U-100', proyecto: 'SENDA', fechaInicio: '2026-09-01', engancheTotal: 60000, numPagos: 6, mensualidad: 10000, tasaMoraMensual: null, status: 'ACTIVO', pagos: [], notas: [], createdAt: '2026-08-01T00:00:00.000Z', ...overrides, } } function planLocal(overrides: Partial = {}): PlanPago { return { id: 'local-1', ventaRef: '', clienteNombre: 'Cliente Local', unidadCodigo: 'U-1', proyecto: 'SENDA', fechaInicio: '2026-09-01', engancheTotal: 10000, numPagos: 2, mensualidad: 5000, status: 'ACTIVO', pagos: [], notas: [], createdAt: '2026-08-01T00:00:00.000Z', ...overrides, } } afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) describe('repository async (API primero, fallback localStorage)', () => { it('listarPlanes devuelve los planes del API normalizados y sin los calculados del servidor', async () => { const fetchMock = fetchQue(async () => // El API manda campos calculados (saldo, avance…); el frontend los deriva por su cuenta respuestaJson([{ ...planApi(), saldo: 60000, avance: 0 }]), ) vi.stubGlobal('fetch', fetchMock) const r = await listarPlanes(crearStorageStub()) expect(r.origen).toBe('api') expect(r.planesSoloLocales).toBe(false) expect(r.planes).toHaveLength(1) const plan = r.planes[0] expect(plan.ventaRef).toBe('') // null → '' expect(plan.clienteTelefono).toBeUndefined() // null → undefined expect(plan.tasaMoraMensual).toBeUndefined() expect(plan).not.toHaveProperty('saldo') expect(fetchMock).toHaveBeenCalledWith( `${API_URL}/planes`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), ) }) it('listarPlanes cae a localStorage (con seed) cuando fetch rechaza por red', async () => { vi.stubGlobal('fetch', fetchSinRed()) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() const r = await listarPlanes(storage) expect(r.origen).toBe('local') expect(r.planesSoloLocales).toBe(false) expect(r.planes).toHaveLength(4) // datos de ejemplo sembrados expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull() expect(warn).toHaveBeenCalled() }) it('listarPlanes NO cae a localStorage ante un error HTTP del API', async () => { vi.stubGlobal( 'fetch', fetchQue(async () => respuestaJson({ error: 'Error interno del servidor' }, 500)), ) await expect(listarPlanes(crearStorageStub())).rejects.toBeInstanceOf(ErrorApi) }) it('conserva los planes locales cuando el API responde vacío (sin migrar automáticamente)', async () => { vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([]))) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()])) const r = await listarPlanes(storage) expect(r.origen).toBe('local') expect(r.planesSoloLocales).toBe(true) expect(r.planes.map((p) => p.id)).toEqual(['local-1']) expect(warn).toHaveBeenCalled() }) it('API vacío sin planes locales → lista vacía con origen api', async () => { vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([]))) const r = await listarPlanes(crearStorageStub()) expect(r).toEqual({ planes: [], origen: 'api', planesSoloLocales: false }) }) it('obtenerPlanPorId devuelve undefined ante un 404 del API (sin fallback)', async () => { vi.stubGlobal( 'fetch', fetchQue(async () => respuestaJson({ error: 'Plan de pago no encontrado' }, 404)), ) const r = await obtenerPlanPorId('no-existe', crearStorageStub()) expect(r.plan).toBeUndefined() expect(r.origen).toBe('api') }) it('obtenerPlanPorId cae a localStorage cuando falla la red', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()])) const r = await obtenerPlanPorId('local-1', storage) expect(r.origen).toBe('local') expect(r.plan?.clienteNombre).toBe('Cliente Local') }) it('crearPlanAsync hace POST al API y no escribe en localStorage', async () => { const fetchMock = fetchQue(async () => respuestaJson(planApi({ id: 'api-nuevo' }), 201)) vi.stubGlobal('fetch', fetchMock) const storage = crearStorageStub() const r = await crearPlanAsync(INPUT_PLAN, storage) expect(r.origen).toBe('api') expect(r.plan.id).toBe('api-nuevo') expect(storage.getItem(CLAVE_STORAGE)).toBeNull() const [url, init] = fetchMock.mock.calls[0] expect(url).toBe(`${API_URL}/planes`) expect(init?.method).toBe('POST') const body = JSON.parse(String(init?.body)) as Record expect(body.clienteNombre).toBe('Cliente Prueba') expect(body.engancheTotal).toBe(60000) expect(body.numPagos).toBe(6) }) it('crearPlanAsync guarda el plan en localStorage cuando falla la red', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() const r = await crearPlanAsync(INPUT_PLAN, storage) expect(r.origen).toBe('local') expect(r.plan.mensualidad).toBe(10000) const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[] expect(guardados).toHaveLength(5) // 4 del seed + 1 nuevo }) it('registrarAbonoAsync envía pagoId/fechaPago/monto/referencia al API', async () => { const fetchMock = fetchQue(async () => respuestaJson(planApi(), 201)) vi.stubGlobal('fetch', fetchMock) const r = await registrarAbonoAsync( 'api-1', { fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' }, 'pago-1', crearStorageStub(), ) expect(r.origen).toBe('api') const [url, init] = fetchMock.mock.calls[0] expect(url).toBe(`${API_URL}/planes/api-1/abonos`) const body = JSON.parse(String(init?.body)) as Record expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' }) }) it('registrarAbonoAsync propaga el ErrorApi de validación sin caer a local', async () => { vi.stubGlobal( 'fetch', fetchQue(async () => respuestaJson({ error: 'El abono excede el restante' }, 400)), ) const storage = crearStorageStub() await expect( registrarAbonoAsync('api-1', { fechaPago: '2026-09-02', monto: 999999, referencia: 'X1' }, 'pago-1', storage), ).rejects.toBeInstanceOf(ErrorApi) expect(storage.getItem(CLAVE_STORAGE)).toBeNull() }) it('registrarAbonoAsync aplica el abono en localStorage cuando falla la red', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() const plan = crearPlan({ ...INPUT_PLAN, engancheTotal: 20000, numPagos: 2 }, storage) const r = await registrarAbonoAsync( plan.id, { fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' }, plan.pagos[0].id, storage, ) expect(r.origen).toBe('local') expect(r.plan.pagos[0].montoPagado).toBe(10000) }) it('agregarNotaAsync anexa al plan la nota devuelta por el API', async () => { const fetchMock = fetchQue(async () => respuestaJson({ id: 'nota-1', planId: 'api-1', fecha: '2026-08-21T12:00:00.000Z', texto: 'Se habló con el cliente' }, 201), ) vi.stubGlobal('fetch', fetchMock) const plan = { ...planApi(), ventaRef: '', clienteTelefono: undefined, tasaMoraMensual: undefined } const r = await agregarNotaAsync(plan, 'Se habló con el cliente', crearStorageStub()) expect(r.origen).toBe('api') expect(r.plan.notas).toHaveLength(1) expect(r.plan.notas[0]).toEqual({ id: 'nota-1', fecha: '2026-08-21T12:00:00.000Z', texto: 'Se habló con el cliente', }) const [url] = fetchMock.mock.calls[0] expect(url).toBe(`${API_URL}/planes/api-1/notas`) }) it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() const plan = planLocal() storage.setItem(CLAVE_STORAGE, JSON.stringify([plan])) const r = await agregarNotaAsync(plan, 'Se habló con el cliente', storage) expect(r.origen).toBe('local') expect(r.plan.notas).toHaveLength(1) await expect(agregarNotaAsync(plan, 'ok', storage)).rejects.toThrow(/al menos 3/) }) it('cancelarPlanAsync cancela vía API', async () => { const fetchMock = fetchQue(async () => respuestaJson(planApi({ status: 'CANCELADO' }))) vi.stubGlobal('fetch', fetchMock) const r = await cancelarPlanAsync('api-1', crearStorageStub()) expect(r.origen).toBe('api') expect(r.plan.status).toBe('CANCELADO') const [url, init] = fetchMock.mock.calls[0] expect(url).toBe(`${API_URL}/planes/api-1/cancelar`) expect(init?.method).toBe('POST') }) it('cancelarPlanAsync marca CANCELADO en localStorage cuando falla la red', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = crearStorageStub() storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()])) const r = await cancelarPlanAsync('local-1', storage) expect(r.origen).toBe('local') expect(r.plan.status).toBe('CANCELADO') const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[] expect(guardados[0].status).toBe('CANCELADO') }) }) describe('mis cobranzas (toggle Mis planes / Todos)', () => { it('rutaListadoPlanes agrega ?mios=1 solo cuando se piden los míos', async () => { const { rutaListadoPlanes } = await import('./repository') expect(rutaListadoPlanes(true)).toBe('/planes?mios=1') expect(rutaListadoPlanes(false)).toBe('/planes') }) it('miosPorDefecto: vendedor arranca en "Mis planes"; el resto en "Todos"', async () => { const { miosPorDefecto } = await import('./repository') expect(miosPorDefecto('vendedor')).toBe(true) expect(miosPorDefecto('admin')).toBe(false) expect(miosPorDefecto('coordinador')).toBe(false) expect(miosPorDefecto('corporativo')).toBe(false) expect(miosPorDefecto(null)).toBe(false) expect(miosPorDefecto(undefined)).toBe(false) }) it('listarPlanes con mios=true llama al API con ?mios=1', async () => { const fetchMock = fetchQue(async () => respuestaJson([planApi()])) vi.stubGlobal('fetch', fetchMock) const r = await listarPlanes(crearStorageStub(), true) expect(r.origen).toBe('api') expect(fetchMock).toHaveBeenCalledWith( `${API_URL}/planes?mios=1`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), ) }) it('listarPlanes con mios=true cae a localStorage igual que sin el filtro', async () => { vi.stubGlobal('fetch', fetchSinRed()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const r = await listarPlanes(crearStorageStub(), true) expect(r.origen).toBe('local') // el respaldo local no tiene vendedorId expect(r.planes.length).toBeGreaterThan(0) }) })