// @vitest-environment happy-dom
// FAC-12 (#1022): alta guiada por tipo de documento.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
vi.mock('react-i18next', () => {
// Stable references: a fresh `t` per render would retrigger effects that depend on it.
const i18n = { language: 'es' }
const t = (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k
const value = { t, i18n }
return { useTranslation: () => value }
})
import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from '../document-form-dialog'
import { DynamicCRUDPage } from '../dynamic-crud-page'
import { useMetadataCache } from '../metadata-cache'
import { ApiProvider, type ApiClient } from '../api-context'
import { OrgRuntimeProvider } from '../org-runtime-provider'
import type { DocumentFormsManifest } from '../types'
afterEach(cleanup)
const forms: DocumentFormsManifest = {
type_field: 'type',
types: [
{
key: 'invoice',
label: 'Factura',
description: 'Ingreso (I)',
value: 'I',
fields: [
{ key: 'customer_name', label: 'Cliente', type: 'text', required: true },
{ key: 'payment_method', label: 'Método de pago', type: 'text' },
],
lines: { columns: ['tax'] },
},
{
key: 'credit_note',
label: 'Nota de crédito',
value: 'E',
fields: [
{ key: 'customer_name', label: 'Cliente', type: 'text', required: true },
{ key: 'origin_invoice', label: 'Factura origen', type: 'text', required: true },
],
},
],
}
function api(post: ApiClient['post']): ApiClient {
return { get: vi.fn().mockResolvedValue({ data: {} }), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
}
function setup(manifest: DocumentFormsManifest, post: ApiClient['post'] = vi.fn().mockResolvedValue({ data: { success: true, data: { id: '1' } } })) {
const onOpenChange = vi.fn()
const onSaved = vi.fn()
render(
,
)
return { post: post as ReturnType, onOpenChange, onSaved }
}
describe('DocumentFormDialog', () => {
it('con dos tipos muestra tarjetas; elegir «Nota de crédito» muestra sus campos y no los de factura', () => {
setup(forms)
expect(screen.getByText('Factura')).toBeTruthy()
expect(screen.getByText('Nota de crédito')).toBeTruthy()
expect(screen.queryByText('Factura origen')).toBeNull()
fireEvent.click(screen.getByText('Nota de crédito'))
expect(screen.getByText('Factura origen')).toBeTruthy()
expect(screen.queryByText('Método de pago')).toBeNull()
})
it('un solo tipo se salta el selector', () => {
setup({ ...forms, types: [forms.types[1]] })
expect(screen.queryByRole('radiogroup')).toBeNull()
expect(screen.getByText('Factura origen')).toBeTruthy()
})
it('envía el valor del tipo y los campos al endpoint del modelo', async () => {
const { post, onSaved, onOpenChange } = setup(forms)
fireEvent.click(screen.getByText('Nota de crédito'))
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'ACME' } })
fireEvent.change(inputs[1], { target: { value: 'FAC-1' } })
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
await waitFor(() => expect(post).toHaveBeenCalled())
expect(post.mock.calls[0][0]).toBe('/dynamic/fiscal_documents')
expect(post.mock.calls[0][1]).toMatchObject({ type: 'E', customer_name: 'ACME', origin_invoice: 'FAC-1' })
await waitFor(() => expect(onSaved).toHaveBeenCalled())
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('el paso de renglones usa el editor del SDK y los serializa', async () => {
const { post } = setup({ ...forms, types: [forms.types[0]] })
fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
// Sin renglones no guarda: banner en el diálogo.
await screen.findByRole('button', { name: 'Renglón libre' })
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
await screen.findByRole('alert')
expect(post).not.toHaveBeenCalled()
// Con un renglón válido, viajan serializados en `lines`.
fireEvent.click(screen.getByRole('button', { name: 'Renglón libre' }))
fireEvent.change(screen.getByLabelText('Descripción'), { target: { value: 'Servicio' } })
fireEvent.change(screen.getByLabelText('Precio'), { target: { value: '100' } })
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
await waitFor(() => expect(post).toHaveBeenCalled())
const body = post.mock.calls[0][1] as any
expect(body.type).toBe('I')
expect(body.lines).toEqual([expect.objectContaining({ description: 'Servicio', quantity: 1, unit_price: 100 })])
})
it('un 422 se muestra dentro del diálogo y no lo cierra', async () => {
const post = vi.fn().mockRejectedValue({
response: { status: 422, data: { success: false, message: 'validation failed', errors: { origin_invoice: ['La factura origen no existe'] } } },
})
const { onOpenChange, onSaved } = setup({ ...forms, types: [forms.types[1]] }, post)
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'ACME' } })
fireEvent.change(inputs[1], { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
const banner = await screen.findByRole('alert')
expect(banner.textContent).toContain('La factura origen no existe')
expect(onOpenChange).not.toHaveBeenCalledWith(false)
expect(onSaved).not.toHaveBeenCalled()
})
})
// Retest Pitsline 05/10: Facturas → Crear guardaba la factura en $0 porque el
// host (DynamicCRUDPage / ops) no pasa `searchProducts` y el paso de renglones
// solo ofrecía «Renglón libre» sin precio.
describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
const invoiceOnly: DocumentFormsManifest = { ...forms, types: [forms.types[0]] }
const catalogRow = {
id: 'prod-1',
name: 'Llanta 205/55R16',
sku: 'LL-205',
unit_price: '1500.00',
cost_price: '900',
unit_of_measure: 'pza',
product_type: 'product',
fiscal_data: { mx_clave_prod_serv: '25172504', mx_clave_unidad: 'H87' },
}
function mount(opts: { searchProducts?: any; taxRate?: number } = {}) {
const get = vi.fn().mockResolvedValue({ data: { success: true, data: [catalogRow] } })
const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })
const client = { get, post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
render(
,
)
return { get, post }
}
async function pickFirstProduct(query: string, name: RegExp) {
fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: query } })
// RecordPicker: la opción del producto es role="option" clicable (sin