feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API
- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor, column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes, webhook con handshake challenge, Prisma SQLite dev, 55 tests - cotizador: inventario desde /api/unidades con fallback a seed - alta-clientes: POST /api/clientes + upload multipart de documentos - cobranza: persistencia via API con respaldo offline en localStorage - .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
337
apps/cobranza/src/services/repository.api.test.ts
Normal file
337
apps/cobranza/src/services/repository.api.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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'
|
||||
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(
|
||||
'http://192.168.10.123:3000/api/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('http://192.168.10.123:3000/api/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('http://192.168.10.123:3000/api/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('http://192.168.10.123:3000/api/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('http://192.168.10.123:3000/api/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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user