Files
FRASA-CAS/apps/cobranza/src/services/repository.api.test.ts
Consult AS Technology 64b01246d6 feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday
Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
2026-09-11 06:52:37 +00:00

382 lines
13 KiB
TypeScript

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<string, string>()
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<Response>) {
return vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(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> = {}): 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> = {}): 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<string, unknown>
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<string, unknown>
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)
})
})