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 { MasterFormPage } from './master-form.page'; import { BASE_MASTER_SCREENS } from './master-screens'; import { applyMasterExtensions } from './master-extensions'; const customer = (over: Record = {}) => ({ 'odata.etag': 'W/"3"', CardCode: 'C100', CardName: 'ACME Ltda', CardType: 'cCustomer', FederalTaxID: '76111222-3', CardForeignName: null, GroupCode: 100, Currency: 'CLP', Phone1: '2 2345 6789', Phone2: null, Cellular: null, EmailAddress: 'ventas@acme.cl', Website: null, ContactPerson: null, PayTermsGrpCode: 1, PriceListNum: 1, CreditLimit: 5000, DiscountPercent: 0, Frozen: 'tNO', Notes: null, ...over, }); describe('MasterFormPage', () => { let fixture: ComponentFixture; let ctrl: HttpTestingController; let navigate: ReturnType; 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 labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); const buttons = () => [...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim()); async function open(path: string, key: string | null, doc?: Record) { const screen = BASE_MASTER_SCREENS.find((s) => s.path === path)!; const params = key === null ? {} : { key }; const route = { snapshot: { data: { screen, title: screen.singular }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)), }; await TestBed.configureTestingModule({ imports: [MasterFormPage], 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(MasterFormPage); await tick(); if (key !== null) ctrl.expectOne(`/api/sl/${screen.resource}/${encodeURIComponent(key)}`).flush(doc ?? {}); await tick(); } beforeEach(() => TestBed.resetTestingModule()); describe('un cliente existente', () => { beforeEach(() => open('/socios/clientes', 'C100', customer())); it('muestra el código y el nombre, el estado y las pestañas con sus campos', () => { expect(el().querySelector('.title')?.textContent).toContain('Cliente · C100'); expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Activo'); expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Observaciones']); expect(labels()).toEqual(expect.arrayContaining(['Código', 'Nombre', 'RUT', 'Teléfono 1', 'Correo electrónico', 'Condición de pago', 'Límite de crédito'])); const values = [...el().querySelectorAll('input')].map((i) => i.value); expect(values).toEqual(expect.arrayContaining(['C100', 'ACME Ltda', '76111222-3', 'ventas@acme.cl'])); }); it('el código no se puede cambiar', () => { const code = [...el().querySelectorAll('input')].find((i) => i.value === 'C100')!; expect(code.readOnly).toBe(true); }); 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', async () => { page().setValue('Phone1', '9 8765 4321'); page().setValue('CreditLimit', 8000); await tick(); expect(page().dirty()).toBe(true); (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/BusinessPartners/C100'); expect(req.request.headers.get('If-Match')).toBe('W/"3"'); expect(req.request.body).toEqual({ Phone1: '9 8765 4321', CreditLimit: 8000 }); req.flush(null); await tick(); ctrl.expectOne('/api/sl/BusinessPartners/C100').flush(customer({ Phone1: '9 8765 4321', CreditLimit: 8000 })); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Cliente actualizado'); }); it('un campo vaciado se envía como null para que SAP lo limpie', async () => { page().setValue('EmailAddress', ''); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ EmailAddress: null }); }); it('un 412 avisa del conflicto, no guarda y ofrece Recargar', async () => { page().setValue('CardName', 'Otro nombre'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'x' }, { status: 412, statusText: 'Precondition Failed' }); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Otro usuario modificó'); expect(buttons()).toContain('Recargar'); }); it('la navegación pide el vecino por clave de texto y abre su registro', async () => { const done = page().go('next'); const req = ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners' && r.params.has('$orderby')); expect(req.request.params.get('$filter')).toBe(`(CardType eq 'cCustomer') and (CardCode gt 'C100')`); expect(req.request.params.get('$orderby')).toBe('CardCode asc'); req.flush({ value: [{ CardCode: 'C200' }] }); await done; expect(navigate).toHaveBeenCalledWith(['..', 'C200'], expect.anything()); }); it('al llegar al extremo avisa y no navega', async () => { const done = page().go('next'); ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [] }); await done; await tick(); expect(navigate).not.toHaveBeenCalled(); expect(el().querySelector('.status')?.textContent).toContain('No hay más registros'); }); }); it('un cliente inactivo se muestra como tal', async () => { await open('/socios/clientes', 'C100', customer({ Frozen: 'tYES' })); expect(el().querySelector('.badge.tone-muted')?.textContent?.trim()).toBe('Inactivo'); }); it('una clave con espacios y puntos se codifica en la URL (artículos)', async () => { await open('/inventario/articulos', '3MG30080714 M 10.5', { ItemCode: '3MG30080714 M 10.5', ItemName: 'Cloud X 5', Frozen: 'tNO', InventoryItem: 'tYES', QuantityOnStock: 12, }); expect(el().querySelector('.title')?.textContent).toContain('Artículo · 3MG30080714 M 10.5'); }); it('las casillas de un artículo muestran y cambian tYES / tNO', async () => { await open('/inventario/articulos', 'A1', { 'odata.etag': 'W/"1"', ItemCode: 'A1', ItemName: 'Pieza', Frozen: 'tNO', InventoryItem: 'tYES', SalesItem: 'tNO', PurchaseItem: 'tYES' }); const boxes = [...el().querySelectorAll('input[type=checkbox]')]; expect(boxes.map((b) => b.checked)).toEqual(expect.arrayContaining([true, false])); page().setValue('SalesItem', 'tYES'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ SalesItem: 'tYES' }); }); describe('un cliente nuevo', () => { beforeEach(() => open('/socios/clientes', null)); it('arranca vacío, con el código editable y el botón Crear', () => { expect(el().querySelector('.badge.tone-info')?.textContent?.trim()).toBe('Nuevo'); expect(buttons()).toContain('Crear'); const code = el().querySelector('input')!; expect(code.readOnly).toBe(false); }); it('no envía nada sin código o sin nombre', async () => { (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(el().querySelector('.status')?.textContent).toContain('Indique el código'); page().setValue('CardCode', 'C300'); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Indique el nombre'); }); it('crea el cliente con su tipo, sin campos vacíos, y abre el creado', async () => { page().setValue('CardCode', 'C300'); page().setValue('CardName', 'Nuevo SpA'); page().setValue('GroupCode', 100); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.url).toBe('/api/sl/BusinessPartners'); expect(req.request.body).toEqual({ CardCode: 'C300', CardName: 'Nuevo SpA', GroupCode: 100, Frozen: 'tNO', CreditLimit: 0, DiscountPercent: 0, CardType: 'cCustomer', }); req.flush({ CardCode: 'C300' }); await tick(); expect(navigate).toHaveBeenCalledWith(['..', 'C300'], expect.objectContaining({ state: { created: 'C300' } })); }); it('un proveedor se crea con el tipo de proveedor', async () => { TestBed.resetTestingModule(); await open('/socios/proveedores', null); page().setValue('CardCode', 'P1'); page().setValue('CardName', 'Proveedor Uno'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); expect((ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record)['CardType']).toBe('cSupplier'); }); it('muestra el error de SAP si rechaza el registro', async () => { page().setValue('CardCode', 'C300'); page().setValue('CardName', 'Nuevo SpA'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' }); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Código duplicado'); expect(navigate).not.toHaveBeenCalled(); }); }); it('un registro que no existe lo avisa', async () => { const screen = BASE_MASTER_SCREENS.find((s) => s.path === '/socios/clientes')!; const route = { snapshot: { data: { screen, title: 'Cliente' }, paramMap: convertToParamMap({ key: 'NOPE' }) }, paramMap: of(convertToParamMap({ key: 'NOPE' })) }; await TestBed.configureTestingModule({ imports: [MasterFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); ctrl = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(MasterFormPage); await tick(); ctrl.expectOne('/api/sl/BusinessPartners/NOPE').flush({}, { status: 404, statusText: 'Not Found' }); await tick(); expect(el().querySelector('.status')?.textContent).toContain('El registro no existe'); }); it('un campo oculto no aparece y uno de solo lectura se ve pero no se escribe', async () => { const screen = applyMasterExtensions(BASE_MASTER_SCREENS, { '/socios/clientes': { hideFields: ['Website'], readOnlyFields: ['EmailAddress'] }, }).find((s) => s.path === '/socios/clientes')!; const route = { snapshot: { data: { screen, title: 'Cliente' }, paramMap: convertToParamMap({ key: 'C100' }) }, paramMap: of(convertToParamMap({ key: 'C100' })) }; await TestBed.configureTestingModule({ imports: [MasterFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); ctrl = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(MasterFormPage); await tick(); ctrl.expectOne('/api/sl/BusinessPartners/C100').flush(customer()); await tick(); expect(labels()).not.toContain('Sitio web'); const email = [...el().querySelectorAll('input')].find((i) => i.value === 'ventas@acme.cl')!; expect(email.readOnly).toBe(true); const phone = [...el().querySelectorAll('input')].find((i) => i.value === '2 2345 6789')!; expect(phone.readOnly).toBe(false); }); });