Files
basic-erp/apps/web/src/app/features/documents/document-form.page.spec.ts
T

221 lines
10 KiB
TypeScript
Raw Normal View History

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!;
const order = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"77"',
DocEntry: 41,
DocNum: 1041,
CardCode: 'C1',
CardName: 'ACME',
NumAtCard: '',
DocDate: '2026-09-11T00:00:00Z',
DocDueDate: '2026-09-20T00:00:00Z',
DiscountPercent: 0,
Comments: 'hola',
DocumentStatus: 'bost_Open',
Cancelled: 'tNO',
DocumentLines: [
{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 2, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 200, WarehouseCode: '01' },
],
...over,
});
describe('DocumentFormPage', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const buttonText = () => [...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim());
async function open(docEntry: string | null, doc?: Record<string, unknown>) {
const params = docEntry ? { docEntry } : {};
const route = {
snapshot: { data: { screen, title: 'Órdenes de venta' }, paramMap: convertToParamMap(params) },
paramMap: of(convertToParamMap(params)),
};
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne('/api/sl/SalesTaxCodes?$select=Code,Rate&$top=500').flush({ value: [{ Code: 'IVA', Rate: 19 }] });
if (docEntry) ctrl.expectOne(`/api/sl/Orders/${docEntry}`).flush(doc ?? {});
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
describe('documento abierto', () => {
beforeEach(() => open('41', order()));
it('muestra la cabecera, las líneas y los totales con la tasa del impuesto', () => {
const inputs = [...el().querySelectorAll('input')].map((i) => i.value);
expect(inputs).toContain('C1');
expect(inputs).toContain('ACME');
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
expect(el().querySelector('h1')?.textContent).toContain('1041');
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1);
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, total: 238 });
expect(buttonText()).toContain('Actualizar');
});
it('las fechas de SAP (con hora) se muestran y se comparan como AAAA-MM-DD', () => {
expect(page().header().DocDate).toBe('2026-09-11');
expect(page().dirty()).toBe(false);
});
it('sin cambios, Actualizar no envía nada', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'PATCH');
expect(el().querySelector('.status')?.textContent).toContain('No hay cambios');
});
it('envía solo lo que cambió, con If-Match, y recarga el documento', async () => {
page().setHeader('Comments', 'nuevo comentario');
await tick();
expect(el().querySelector('.status')?.textContent).toContain('cambios sin guardar');
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/Orders/41');
expect(req.request.body).toEqual({ Comments: 'nuevo comentario' });
expect(req.request.headers.get('If-Match')).toBe('W/"77"');
expect(req.request.headers.has('B1S-ReplaceCollectionsOnPatch')).toBe(false);
req.flush(null, { status: 204, statusText: 'No Content' });
await tick();
ctrl.expectOne('/api/sl/Orders/41').flush(order({ Comments: 'nuevo comentario', 'odata.etag': 'W/"78"' }));
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Documento actualizado');
});
it('si cambian las líneas las envía todas con ReplaceCollectionsOnPatch', async () => {
const lines = page().lines();
page().setLines([{ ...lines[0], Quantity: 5, LineTotal: 500 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
expect(req.request.body.DocumentLines[0]).toMatchObject({ ItemCode: 'A1', Quantity: 5 });
expect(req.request.body.DocumentLines[0].LineTotal).toBeUndefined();
req.flush(null, { status: 204, statusText: 'No Content' });
});
it('un 412 avisa del conflicto, no guarda y ofrece Recargar', async () => {
page().setHeader('Comments', 'x');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'etag' }, { status: 412, statusText: 'Precondition Failed' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Otro usuario modificó');
expect(buttonText()).toContain('Recargar');
(el().querySelectorAll('.actions .btn')[1] as HTMLElement).click();
await tick();
ctrl.expectOne('/api/sl/Orders/41').flush(order({ Comments: 'del otro usuario' }));
await tick();
expect(page().header().Comments).toBe('del otro usuario');
expect(buttonText()).not.toContain('Recargar');
});
it('la navegación pide el documento vecino y abre su DocEntry', async () => {
const done = page().go('next');
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/Orders' && r.params.get('$filter') === 'DocEntry gt 41');
expect(req.request.params.get('$orderby')).toBe('DocEntry asc');
expect(req.request.params.get('$top')).toBe('1');
req.flush({ value: [{ DocEntry: 42 }] });
await done;
expect(navigate).toHaveBeenCalledWith(['..', 42], expect.anything());
});
it('al llegar al extremo avisa y no navega', async () => {
const done = page().go('next');
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/Orders').flush({ value: [] });
await done;
await tick();
expect(navigate).not.toHaveBeenCalled();
expect(el().querySelector('.status')?.textContent).toContain('No hay más documentos');
});
});
describe('documento cerrado', () => {
beforeEach(() => open('41', order({ DocumentStatus: 'bost_Close' })));
it('queda en solo lectura: sin Actualizar y con los campos bloqueados', () => {
expect(buttonText()).toEqual(['Cerrar']);
expect(el().querySelector('.status')?.textContent).toContain('solo lectura');
const comments = el().querySelector('textarea') as HTMLTextAreaElement;
expect(comments.readOnly).toBe(true);
});
});
describe('documento nuevo', () => {
beforeEach(() => open(null));
it('arranca vacío con fecha de hoy y botón Crear', () => {
expect(el().querySelector('.badge.tone-info')?.textContent?.trim()).toBe('Nueva');
expect(buttonText()).toContain('Crear');
expect(page().header().DocDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('no envía nada si falta el cliente o las líneas', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
expect(el().querySelector('.status')?.textContent).toContain('Indique el cliente');
});
it('crea el documento sin campos vacíos y abre el creado', async () => {
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, DiscountPercent: 0, TaxCode: '', ItemDescription: '', LineTotal: 50 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
expect(req.request.url).toBe('/api/sl/Orders');
expect(req.request.body).toEqual({
CardCode: 'C1',
DocDate: page().header().DocDate,
DocDueDate: page().header().DocDueDate,
DiscountPercent: 0,
DocumentLines: [{ ItemCode: 'A1', Quantity: 1, Price: 50, DiscountPercent: 0 }],
});
req.flush({ DocEntry: 99, DocNum: 1099 });
await tick();
expect(navigate).toHaveBeenCalledWith(['..', 99], expect.objectContaining({ state: { created: '1099' } }));
});
it('muestra el error de SAP si rechaza el documento', async () => {
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Código de cliente inválido');
expect(navigate).not.toHaveBeenCalled();
});
});
});