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
382 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|