From ae30c373e8959c7ab695b8bcadc8a7d788480b86 Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:01:32 +0200 Subject: [PATCH 1/3] fix(maintenance): harden scope changes and expand workflow coverage --- .../customer/testing/customer.store.spec.ts | 113 ++- .../testing/customer-label.component.spec.ts | 100 +++ .../testing/customer-form.component.spec.ts | 127 ++- .../testing/customers-page.component.spec.ts | 449 +++++++++- .../customer-editor-sheet.component.spec.ts | 148 ++++ .../intervention-workspace.store.ts | 13 +- .../interventions-page.component.spec.ts | 188 ++++- .../features/maintenance-exports/FEATURE.md | 2 + .../maintenance-export.store.ts | 14 +- .../testing/maintenance-export.store.spec.ts | 580 +++++++++++++ .../maintenance-exports-page.component.ts | 6 +- ...maintenance-exports-page.component.spec.ts | 581 ++++++++++++- .../features/maintenance-schedules/FEATURE.md | 17 +- .../maintenance-plans.store.ts | 764 +++++++++++------- .../models/state.interface.ts | 26 + .../testing/maintenance-plans.store.spec.ts | 461 ++++++++++- .../maintenance-plan-list.component.spec.ts | 284 +++++-- .../maintenance-plan-form.component.spec.ts | 529 +++++++++--- .../maintenance-plans-page.component.spec.ts | 522 +++++++++++- .../features/procurement/FEATURE.md | 2 + .../state/procurement/procurement.store.ts | 18 +- .../testing/procurement.store.spec.ts | 365 +++++++++ .../purchase-order-form.component.spec.ts | 129 +++ .../testing/supplier-form.component.spec.ts | 29 + .../procurement-page.component.spec.ts | 454 ++++++++++- 25 files changed, 5371 insertions(+), 550 deletions(-) create mode 100644 src/app/features/organization/features/customers/ui/components/customer-label/testing/customer-label.component.spec.ts create mode 100644 src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/testing/customer-editor-sheet.component.spec.ts diff --git a/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts b/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts index 4bc299de9..abdfac492 100644 --- a/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts +++ b/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts @@ -1,9 +1,11 @@ import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; import { of, Subject, throwError } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; import { CustomerService } from '@features/organization/features/customers/data-access'; import type { CustomerOutput } from '@features/organization/features/customers/models'; import { CustomerStore, type CustomerStoreType } from '../customer.store'; +import { customerStoreEvents } from '../events/events'; describe('CustomerStore', () => { const customer: CustomerOutput = { @@ -46,6 +48,7 @@ describe('CustomerStore', () => { }); store = TestBed.inject(CustomerStore); }); + afterEach(() => vi.restoreAllMocks()); it('cancels obsolete search pages and computes the server page count', () => { const old = new Subject>(); service.list.mockReturnValueOnce(old); @@ -97,8 +100,116 @@ describe('CustomerStore', () => { store.read({ organizationId: 'org', customerId: 'old' }); store.read({ organizationId: 'org', customerId: 'customer' }); expect(old.observed).toBe(false); - expect(store.readCallState().data?.archivedAt).toBeTruthy(); + expect(store.readCallState().data?.archivedAt).toBe('2026-10-06T11:00:00Z'); store.read(null); expect(store.readCallState().data).toBeNull(); }); + + it.each(['update', 'archive', 'restore'] as const)( + 'accepts one %s command and publishes immutable identity after success', + (kind) => { + const accepted = new Subject(); + service[kind].mockReturnValue(accepted); + const dispatcher = vi.spyOn(TestBed.inject(Dispatcher), 'dispatch'); + store.load({ organizationId: 'org' }); + store.save( + kind === 'update' + ? { kind, organizationId: 'org', customer, input: { name: 'Updated Hospital' } } + : { kind, organizationId: 'org', customer }, + ); + expect(store.writeCallState().status).toBe('pending'); + if (kind === 'update') + expect(service.update).toHaveBeenCalledExactlyOnceWith('org', customer, { + name: 'Updated Hospital', + }); + else expect(service[kind]).toHaveBeenCalledExactlyOnceWith('org', customer); + store.clearWrite(); + expect(store.writeCallState().status).toBe('pending'); + expect(dispatcher).not.toHaveBeenCalled(); + const updated = { ...customer, revision: 2 }; + accepted.next(updated); + accepted.complete(); + expect(store.writeCallState().status).toBe('success'); + expect(store.writeCallState().data).toEqual(updated); + expect(dispatcher).toHaveBeenCalledExactlyOnceWith( + customerStoreEvents.saved({ organizationId: 'org', customerId: customer.id }), + ); + store.clearWrite(); + expect(store.writeCallState().status).toBe('idle'); + expect(store.writeCallState().data).toBeNull(); + }, + ); + + it('normalizes a rejected write and permits retry without emitting a saved event for rejection', () => { + store.load({ organizationId: 'org' }); + const dispatcher = vi.spyOn(TestBed.inject(Dispatcher), 'dispatch'); + service.update.mockReturnValueOnce( + throwError(() => ({ + status: 412, + title: 'Revision conflict', + detail: 'Refresh the revision.', + type: 'about:blank', + })), + ); + store.save({ kind: 'update', organizationId: 'org', customer, input: { name: 'My draft' } }); + expect(store.writeCallState().status).toBe('error'); + expect(store.writeCallState().error).toMatchObject({ + code: 412, + message: 'Refresh the revision.', + }); + expect(dispatcher).not.toHaveBeenCalled(); + const refreshed = { ...customer, revision: 2 }; + store.save({ + kind: 'update', + organizationId: 'org', + customer: refreshed, + input: { name: 'My draft' }, + }); + expect(service.update).toHaveBeenLastCalledWith('org', refreshed, { name: 'My draft' }); + expect(store.writeCallState().status).toBe('success'); + }); + + it('publishes the originating organization after an accepted write succeeds in another scope', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + const dispatcher = vi.spyOn(TestBed.inject(Dispatcher), 'dispatch'); + store.load({ organizationId: 'org' }); + store.save({ kind: 'create', organizationId: 'org', input: { name: 'Hospital' } }); + store.load(null); + expect(store.writeCallState().status).toBe('pending'); + expect(accepted.observed).toBe(true); + store.load({ organizationId: 'other' }); + accepted.next(customer); + accepted.complete(); + expect(store.writeCallState().status).toBe('idle'); + expect(store.writeCallState().data).toBeNull(); + expect(dispatcher).toHaveBeenCalledExactlyOnceWith( + customerStoreEvents.saved({ organizationId: 'org', customerId: customer.id }), + ); + }); + + it('normalizes retained-record failure and resets scoped collection state on dismissal', () => { + service.get.mockReturnValue( + throwError(() => ({ + status: 404, + title: 'Customer not found', + detail: 'This customer is unavailable.', + type: 'about:blank', + })), + ); + store.read({ organizationId: 'org', customerId: 'missing' }); + expect(store.readCallState().status).toBe('error'); + expect(store.readCallState().error).toMatchObject({ + code: 404, + message: 'This customer is unavailable.', + }); + store.load({ organizationId: 'org' }); + store.load(null); + expect(store.query()).toBeNull(); + expect(store.customerEntities()).toEqual([]); + expect(store.total()).toBe(0); + expect(store.pageCount()).toBe(1); + expect(store.listCallState().status).toBe('idle'); + expect(store.readCallState().status).toBe('idle'); + }); }); diff --git a/src/app/features/organization/features/customers/ui/components/customer-label/testing/customer-label.component.spec.ts b/src/app/features/organization/features/customers/ui/components/customer-label/testing/customer-label.component.spec.ts new file mode 100644 index 000000000..982cdcc56 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-label/testing/customer-label.component.spec.ts @@ -0,0 +1,100 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import type { CustomerOutput } from '@features/organization/features/customers/models'; +import { CustomerStore } from '@features/organization/features/customers/state'; +import { CustomerLabel } from '../customer-label.component'; + +describe('CustomerLabel', () => { + const customer: CustomerOutput = { + '@id': '/api/organizations/org/customers/customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Hospital', + contacts: [], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + let fixture: ComponentFixture; + let store: { + read: ReturnType; + readCallState: ReturnType>>; + }; + + beforeEach(() => { + store = { read: vi.fn(), readCallState: signal>(idleCallState()) }; + TestBed.configureTestingModule({ imports: [CustomerLabel] }).overrideComponent(CustomerLabel, { + set: { providers: [{ provide: CustomerStore, useValue: store }] }, + }); + }); + const render = async (customerId: string | null): Promise => { + fixture = TestBed.createComponent(CustomerLabel); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('customerId', customerId); + await fixture.whenStable(); + }; + + it('renders optional unassigned ownership and clears a previous retained-label read', async () => { + await render(null); + expect(store.read).toHaveBeenCalledExactlyOnceWith(null); + expect(fixture.nativeElement.textContent.trim()).toBe('No customer'); + fixture.componentRef.setInput('customerId', 'customer'); + await fixture.whenStable(); + expect(store.read).toHaveBeenLastCalledWith({ organizationId: 'org', customerId: 'customer' }); + fixture.componentRef.setInput('customerId', null); + await fixture.whenStable(); + expect(store.read).toHaveBeenLastCalledWith(null); + expect(fixture.nativeElement.textContent.trim()).toBe('No customer'); + }); + + it('loads the retained identity independently and keeps historical archived ownership readable', async () => { + await render('customer'); + expect(store.read).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + customerId: 'customer', + }); + store.readCallState.set(pendingCallState()); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('[aria-label="Loading customer"]')).not.toBeNull(); + store.readCallState.set(successCallState(customer)); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent.trim()).toBe('Hospital'); + store.readCallState.set(successCallState({ ...customer, archivedAt: '2026-10-06T11:00:00Z' })); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('Hospital'); + expect(fixture.nativeElement.textContent).toContain('(archived)'); + }); + + it('retries a failed label using the current organization and retained customer identity', async () => { + await render('customer'); + fixture.componentRef.setInput('organizationId', 'other'); + await fixture.whenStable(); + store.readCallState.set(errorCallState(toStoreError(new Error('Customer unavailable')))); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toBe( + 'Customer unavailable', + ); + (fixture.nativeElement.querySelector('button') as HTMLButtonElement).click(); + expect(store.read).toHaveBeenLastCalledWith({ + organizationId: 'other', + customerId: 'customer', + }); + }); + + it('does not issue secondary authenticated retained-label reads during SSR', async () => { + TestBed.overrideProvider(PLATFORM_ID, { useValue: 'server' }); + await render('customer'); + fixture.componentRef.setInput('organizationId', 'other'); + await fixture.whenStable(); + expect(store.read).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts b/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts index d62db16ac..9e42e474e 100644 --- a/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts +++ b/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts @@ -1,5 +1,8 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; -import type { CustomerInput } from '@features/organization/features/customers/models'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; import { CustomerForm } from '../customer-form.component'; describe('CustomerForm', () => { @@ -101,4 +104,126 @@ describe('CustomerForm', () => { expect(emitted).toHaveLength(0); expect(fixture.nativeElement.textContent).toContain('Use at most 160 characters.'); }); + + it('normalizes optional references and contacts and emits dirty-state changes after removal', async () => { + const emitted: CustomerInput[] = []; + const dirtiness: boolean[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + fixture.componentInstance.dirtyChanged.subscribe((dirty) => dirtiness.push(dirty)); + const customer: CustomerOutput = { + '@id': '/api/organizations/org/customers/customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Original', + code: 'HOSP', + email: 'original@example.test', + phone: '+33 1 23 45 67 89', + contacts: [ + { name: 'Remove me', role: null, email: null, phone: null }, + { + name: 'Safety manager', + role: 'Safety', + email: 'safety@example.test', + phone: '+33 6 12 34 56 78', + }, + ], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + fixture.componentRef.setInput('customer', customer); + await fixture.whenStable(); + expect((fixture.nativeElement.querySelector('#customer-phone') as HTMLInputElement).value).toBe( + customer.phone, + ); + const remove = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((button) => button.textContent?.includes('Remove contact')); + if (!remove) throw new Error('Missing contact removal action'); + remove.click(); + await fixture.whenStable(); + await fill('#customer-name', ' Hospital '); + await fill('#customer-code', ' HOSP-1 '); + await fill('#customer-phone', ' +33 1 23 45 67 89 '); + await fill('#customer-email', 'contact@example.test'); + await fill('#contact-name-0', ' Safety manager '); + await fill('#contact-role-0', ' Safety '); + await fill('#contact-phone-0', ' +33 6 12 34 56 78 '); + await submit(); + expect(emitted).toEqual([ + { + name: 'Hospital', + code: 'HOSP-1', + email: 'contact@example.test', + phone: '+33 1 23 45 67 89', + contacts: [ + { + name: 'Safety manager', + role: 'Safety', + email: 'safety@example.test', + phone: '+33 6 12 34 56 78', + }, + ], + }, + ]); + expect(dirtiness).toContain(true); + }); + + it('locks all form actions during an accepted write and preserves entered values for retry', async () => { + const emitted: CustomerInput[] = []; + const cancelled = vi.fn(); + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + fixture.componentInstance.cancelled.subscribe(cancelled); + await fill('#customer-name', 'My draft'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect( + (fixture.nativeElement.querySelector('#customer-name') as HTMLInputElement).disabled, + ).toBe(true); + const buttons = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ); + expect(buttons.every((button) => button.disabled)).toBe(true); + await submit(); + expect(emitted).toEqual([]); + fixture.componentRef.setInput('pending', false); + await fixture.whenStable(); + expect((fixture.nativeElement.querySelector('#customer-name') as HTMLInputElement).value).toBe( + 'My draft', + ); + await submit(); + expect(emitted).toEqual([ + { name: 'My draft', code: null, email: null, phone: null, contacts: [] }, + ]); + const cancel = buttons.find((button) => button.textContent?.trim() === 'Cancel'); + if (!cancel) throw new Error('Missing cancellation action'); + cancel.click(); + expect(cancelled).toHaveBeenCalledOnce(); + }); + + it('rejects whitespace-only customer and contact names and malformed contact email', async () => { + const emitted: CustomerInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await fill('#customer-name', ' '); + await submit(); + expect(emitted).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('Enter the customer name.'); + await fill('#customer-name', 'Hospital'); + const add = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((button) => button.textContent?.includes('Add contact')); + if (!add) throw new Error('Missing contact creation action'); + add.click(); + await fixture.whenStable(); + await fill('#contact-name-0', ' '); + await submit(); + expect(emitted).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('Enter the contact name.'); + await fill('#contact-name-0', 'Safety manager'); + await fill('#contact-email-0', 'invalid'); + await submit(); + expect(emitted).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('Enter a valid email address.'); + }); }); diff --git a/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts b/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts index 0d67f0785..c468b2c45 100644 --- a/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts +++ b/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts @@ -1,10 +1,39 @@ -import { PLATFORM_ID, signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + input as inputSignal, + output, + signal, +} from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { Dispatcher } from '@ngrx/signals/events'; import { of } from 'rxjs'; import { ConnectivityService } from '@core/connectivity'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; import { THEME_PORT } from '@core/theme'; import { OrganizationPermissionService } from '@features/organization/access'; import { CustomerService } from '@features/organization/features/customers/data-access'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { + CustomerStore, + customerStoreEvents, +} from '@features/organization/features/customers/state'; +import { CustomerEditorSheet } from '@features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { CollectionPagination } from '@shared/collection-pagination'; import { CustomersPage } from '../customers-page.component'; describe('CustomersPage', () => { @@ -44,22 +73,418 @@ describe('CustomersPage', () => { ); expect(fixture.nativeElement.textContent).toContain('No customers in this view'); }); - it('does not read customer data during SSR or without read permission', async () => { - const service = { list: vi.fn() }; + it.each([ + { platform: 'server', permissions: true }, + { platform: 'browser', permissions: false }, + ])( + 'does not read customer data on $platform with permissions=$permissions', + async ({ platform, permissions }) => { + const service = { list: vi.fn() }; + TestBed.configureTestingModule({ + imports: [CustomersPage], + providers: [ + { provide: PLATFORM_ID, useValue: platform }, + { provide: CustomerService, useValue: service }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: () => permissions }, + }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + ], + }); + const fixture = TestBed.createComponent(CustomersPage); + fixture.componentRef.setInput('organizationId', 'org'); + await fixture.whenStable(); + expect(service.list).not.toHaveBeenCalled(); + if (!permissions) expect(fixture.nativeElement.textContent).not.toContain('New customer'); + }, + ); +}); + +@Component({ + selector: 'app-customer-editor-sheet', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class CustomerEditorSheetStub { + readonly visible = inputSignal(false); + readonly customer = inputSignal(null); + readonly pending = inputSignal(false); + readonly error = inputSignal(null); + readonly conflict = inputSignal(false); + readonly refreshRequested = output(); + readonly dismissed = output(); + readonly submitted = output(); +} + +describe('CustomersPage', () => { + const organizationId = '00000000-0000-4000-8000-000000000001'; + const otherOrganizationId = '00000000-0000-4000-8000-000000000002'; + const customer: CustomerOutput = { + '@id': '/api/organizations/' + organizationId + '/customers/customer-1', + '@type': 'Customer', + id: 'customer-1', + organizationId, + name: 'Hospital', + contacts: [{ name: 'Safety manager', role: 'Safety', email: null, phone: null }], + revision: 3, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + let fixture: ComponentFixture; + let store: { + load: ReturnType; + read: ReturnType; + save: ReturnType; + clearWrite: ReturnType; + customerEntities: ReturnType>; + listCallState: ReturnType>; + readCallState: ReturnType>>; + writeCallState: ReturnType>>; + pageCount: ReturnType>; + total: ReturnType>; + }; + const online = signal(true); + const grants = signal([]); + + beforeEach(() => { + online.set(true); + grants.set([ORGANIZATION_PERMISSION.CUSTOMERS_READ, ORGANIZATION_PERMISSION.CUSTOMERS_MANAGE]); + store = { + load: vi.fn(), + read: vi.fn(), + save: vi.fn(), + clearWrite: vi.fn(), + customerEntities: signal([customer]), + listCallState: signal(successCallState(null)), + readCallState: signal>(idleCallState()), + writeCallState: signal>(idleCallState()), + pageCount: signal(3), + total: signal(41), + }; TestBed.configureTestingModule({ imports: [CustomersPage], providers: [ - { provide: PLATFORM_ID, useValue: 'server' }, - { provide: CustomerService, useValue: service }, + provideRouter([]), { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, - { provide: OrganizationPermissionService, useValue: { hasPermission: () => false } }, - { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { provide: ConnectivityService, useValue: { isOnline: online } }, ], + }).overrideComponent(CustomersPage, { + remove: { imports: [CustomerEditorSheet], providers: [CustomerStore] }, + add: { + imports: [CustomerEditorSheetStub], + providers: [{ provide: CustomerStore, useValue: store }], + }, }); - const fixture = TestBed.createComponent(CustomersPage); - fixture.componentRef.setInput('organizationId', 'org'); + }); + + const render = async (): Promise => { + fixture = TestBed.createComponent(CustomersPage); + fixture.componentRef.setInput('organizationId', organizationId); + await fixture.whenStable(); + }; + const button = (text: string): HTMLButtonElement => { + const found = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((candidate) => candidate.textContent?.trim() === text); + if (!found) throw new Error('Missing action: ' + text); + return found; + }; + const editor = (): CustomerEditorSheetStub => + fixture.debugElement.query(By.directive(CustomerEditorSheetStub)) + .componentInstance as CustomerEditorSheetStub; + const changeSearch = async (value: string): Promise => { + const search = fixture.nativeElement.querySelector( + '[data-testid="customers-search"]', + ) as HTMLInputElement; + search.value = value; + search.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }; + + it('restarts server search and archived filtering from page one', async () => { + await render(); + expect(store.load.mock.calls).toEqual([ + [null], + [{ organizationId, page: 1, search: '', archived: false }], + ]); + const pagination = fixture.debugElement.query(By.directive(CollectionPagination)) + .componentInstance as CollectionPagination; + pagination.pageChanged.emit(3); await fixture.whenStable(); - expect(service.list).not.toHaveBeenCalled(); - expect(fixture.nativeElement.textContent).not.toContain('New customer'); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 3, + search: '', + archived: false, + }); + await changeSearch('Hospital'); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: 'Hospital', + archived: false, + }); + fixture.debugElement + .query(By.css('hlm-toggle-group')) + .triggerEventHandler('valueChange', 'archived'); + await fixture.whenStable(); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: 'Hospital', + archived: true, + }); + const calls = store.load.mock.calls.length; + fixture.debugElement + .query(By.css('hlm-toggle-group')) + .triggerEventHandler('valueChange', undefined); + await fixture.whenStable(); + expect(store.load).toHaveBeenCalledTimes(calls); + fixture.debugElement + .query(By.css('hlm-toggle-group')) + .triggerEventHandler('valueChange', 'active'); + await fixture.whenStable(); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: 'Hospital', + archived: false, + }); + }); + + it('opens creation with cleared feedback and forwards the exact normalized input', async () => { + await render(); + button('New customer').click(); + await fixture.whenStable(); + expect(store.clearWrite).toHaveBeenCalledOnce(); + expect(store.read).toHaveBeenCalledExactlyOnceWith(null); + expect(editor().customer()).toBeNull(); + expect(editor().visible()).toBe(true); + const input: CustomerInput = { name: 'North Hospital', email: null, contacts: [] }; + editor().submitted.emit(input); + await fixture.whenStable(); + expect(store.save).toHaveBeenCalledExactlyOnceWith({ kind: 'create', organizationId, input }); + expect(fixture.debugElement.query(By.directive(CustomerEditorSheetStub))).not.toBeNull(); + }); + + it('updates the displayed revision and dismisses only after a matching organization saved event', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + const input: CustomerInput = { name: 'Updated Hospital', contacts: [] }; + expect(editor().customer()).toEqual(customer); + editor().submitted.emit(input); + expect(store.save).toHaveBeenCalledExactlyOnceWith({ + kind: 'update', + organizationId, + customer, + input, + }); + TestBed.inject(Dispatcher).dispatch( + customerStoreEvents.saved({ organizationId: otherOrganizationId, customerId: customer.id }), + ); + await fixture.whenStable(); + expect(editor().customer()).toEqual(customer); + const loads = store.load.mock.calls.length; + TestBed.inject(Dispatcher).dispatch( + customerStoreEvents.saved({ organizationId, customerId: customer.id }), + ); + await fixture.whenStable(); + expect(fixture.debugElement.query(By.directive(CustomerEditorSheetStub))).toBeNull(); + expect(store.load).toHaveBeenCalledTimes(loads + 1); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: '', + archived: false, + }); + }); + + it('forwards revision-checked archive and restore commands for the corresponding row', async () => { + await render(); + button('Archive').click(); + expect(store.save).toHaveBeenLastCalledWith({ kind: 'archive', organizationId, customer }); + const archived = { ...customer, archivedAt: '2026-10-06T11:00:00Z' }; + store.customerEntities.set([archived]); + await fixture.whenStable(); + button('Restore').click(); + expect(store.save).toHaveBeenLastCalledWith({ + kind: 'restore', + organizationId, + customer: archived, + }); + }); + + it('keeps a conflicting editor open, refreshes its revision and retries with the form payload', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + store.writeCallState.set( + errorCallState( + toStoreError({ type: 'about:blank', status: 412, detail: 'Revision conflict' }), + ), + ); + await fixture.whenStable(); + expect(editor().error()).toBe('Revision conflict'); + expect(editor().conflict()).toBe(true); + editor().refreshRequested.emit(); + expect(store.read).toHaveBeenLastCalledWith({ organizationId, customerId: customer.id }); + const refreshed = { ...customer, revision: 4, name: 'Concurrent name' }; + store.readCallState.set(successCallState(refreshed)); + await fixture.whenStable(); + expect(editor().customer()).toEqual(refreshed); + expect(store.clearWrite).toHaveBeenCalledTimes(2); + const input: CustomerInput = { name: 'My draft' }; + editor().submitted.emit(input); + expect(store.save).toHaveBeenLastCalledWith({ + kind: 'update', + organizationId, + customer: refreshed, + input, + }); + }); + + it('ignores refreshed records from another scope or identity and prioritizes read feedback', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + store.readCallState.set(successCallState({ ...customer, organizationId: otherOrganizationId })); + await fixture.whenStable(); + expect(editor().customer()).toEqual(customer); + store.readCallState.set(successCallState({ ...customer, id: 'customer-2' })); + await fixture.whenStable(); + expect(editor().customer()).toEqual(customer); + store.writeCallState.set(errorCallState(toStoreError(new Error('Save failed')))); + store.readCallState.set(errorCallState(toStoreError(new Error('Refresh failed')))); + await fixture.whenStable(); + expect(editor().error()).toBe('Refresh failed'); + }); + + it('resets editor and directory context when the route organization changes', async () => { + await render(); + await changeSearch('Hospital'); + button('Edit').click(); + await fixture.whenStable(); + fixture.componentRef.setInput('organizationId', otherOrganizationId); + await fixture.whenStable(); + expect(fixture.debugElement.query(By.directive(CustomerEditorSheetStub))).toBeNull(); + expect( + (fixture.nativeElement.querySelector('[data-testid="customers-search"]') as HTMLInputElement) + .value, + ).toBe(''); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId: otherOrganizationId, + page: 1, + search: '', + archived: false, + }); + }); + + it('locks row and creation actions during writes and forwards pending state to the editor', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + store.writeCallState.set(pendingCallState()); + await fixture.whenStable(); + expect(editor().pending()).toBe(true); + expect(button('New customer').disabled).toBe(true); + expect(button('Edit').disabled).toBe(true); + expect(button('Archive').disabled).toBe(true); + }); + + it('preserves an open editor offline and rejects submission and refresh until reconnection', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + online.set(false); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('Your draft is preserved.'); + expect(button('New customer').disabled).toBe(true); + editor().submitted.emit({ name: 'My draft' }); + editor().refreshRequested.emit(); + expect(store.save).not.toHaveBeenCalled(); + expect(store.read).toHaveBeenCalledExactlyOnceWith(null); + expect(editor().customer()).toEqual(customer); + online.set(true); + await fixture.whenStable(); + editor().submitted.emit({ name: 'My draft' }); + expect(store.save).toHaveBeenCalledExactlyOnceWith({ + kind: 'update', + organizationId, + customer, + input: { name: 'My draft' }, + }); + }); + + it('keeps directory read permission independent from mutation permission', async () => { + grants.set([ORGANIZATION_PERMISSION.CUSTOMERS_READ]); + await render(); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: '', + archived: false, + }); + expect(fixture.nativeElement.querySelector('[data-testid="customers-new"]')).toBeNull(); + expect( + fixture.nativeElement.querySelector('[data-customer-id] td:last-child')?.textContent.trim(), + ).toBe(''); + grants.set([ORGANIZATION_PERMISSION.CUSTOMERS_MANAGE]); + store.load.mockClear(); + await fixture.whenStable(); + await changeSearch('Other'); + expect(store.load).not.toHaveBeenCalled(); + button('New customer').click(); + await fixture.whenStable(); + editor().submitted.emit({ name: 'Hospital' }); + expect(store.save).toHaveBeenCalledExactlyOnceWith({ + kind: 'create', + organizationId, + input: { name: 'Hospital' }, + }); + }); + + it('rechecks management permission before submitting an editor left open after grants change', async () => { + await render(); + button('Edit').click(); + await fixture.whenStable(); + grants.set([ORGANIZATION_PERMISSION.CUSTOMERS_READ]); + await fixture.whenStable(); + editor().submitted.emit({ name: 'My draft' }); + expect(store.save).not.toHaveBeenCalled(); + expect(editor().customer()).toEqual(customer); + editor().dismissed.emit(); + await fixture.whenStable(); + expect(fixture.debugElement.query(By.directive(CustomerEditorSheetStub))).toBeNull(); + }); + + it('renders failed reads and mutations and retries the current directory query', async () => { + await render(); + await changeSearch('Hospital'); + store.listCallState.set(errorCallState(toStoreError(new Error('Directory unavailable')))); + store.writeCallState.set(errorCallState(toStoreError(new Error('Archive refused')))); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('Directory unavailable'); + expect(fixture.nativeElement.textContent).toContain('Archive refused'); + button('Retry').click(); + expect(store.load).toHaveBeenLastCalledWith({ + organizationId, + page: 1, + search: 'Hospital', + archived: false, + }); + store.customerEntities.set([]); + store.listCallState.set(pendingCallState()); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).not.toContain('No customers in this view'); + store.listCallState.set(successCallState(null)); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('No customers in this view'); }); }); diff --git a/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/testing/customer-editor-sheet.component.spec.ts b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/testing/customer-editor-sheet.component.spec.ts new file mode 100644 index 000000000..95125fef9 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/testing/customer-editor-sheet.component.spec.ts @@ -0,0 +1,148 @@ +import { signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { HlmSheet } from '@shared/ui/sheet'; +import { UnsavedChangesDialog } from '@shared/unsaved-changes'; +import { CustomerEditorSheet } from '../customer-editor-sheet.component'; + +const button = (text: string): HTMLButtonElement => { + const found = Array.from(document.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === text, + ); + if (!found) throw new Error('Missing action: ' + text); + return found; +}; + +describe('CustomerEditorSheet', () => { + const customer: CustomerOutput = { + '@id': '/api/organizations/org/customers/customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Hospital', + contacts: [], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + let fixture: ComponentFixture; + let dismissed: ReturnType void>>; + const mobile = signal(false); + + beforeEach(() => { + mobile.set(false); + TestBed.configureTestingModule({ + imports: [CustomerEditorSheet], + providers: [ + { provide: INTERACTION_CAPABILITIES_PORT, useValue: { isMobileInteractionMode: mobile } }, + ], + }); + fixture = TestBed.createComponent(CustomerEditorSheet); + dismissed = vi.fn<() => void>(); + fixture.componentInstance.dismissed.subscribe(dismissed); + }); + const open = async (): Promise => { + fixture.componentRef.setInput('visible', true); + await fixture.whenStable(); + }; + const confirmation = (): UnsavedChangesDialog => + fixture.debugElement.query(By.directive(UnsavedChangesDialog)) + .componentInstance as UnsavedChangesDialog; + const fillName = async (value: string): Promise => { + const input = document.querySelector('#customer-name') as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }; + + it('uses the create or edit title and forwards normalized form submissions', async () => { + const emitted: CustomerInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await open(); + expect(document.querySelector('[hlmSheetTitle]')?.textContent).toBe('New customer'); + await fillName(' New Hospital '); + (document.querySelector('app-customer-form form') as HTMLFormElement).dispatchEvent( + new Event('submit', { cancelable: true }), + ); + await fixture.whenStable(); + expect(emitted).toEqual([ + { name: 'New Hospital', code: null, email: null, phone: null, contacts: [] }, + ]); + fixture.componentRef.setInput('customer', customer); + await fixture.whenStable(); + expect(document.querySelector('[hlmSheetTitle]')?.textContent).toBe('Edit customer'); + expect((document.querySelector('#customer-name') as HTMLInputElement).value).toBe('Hospital'); + }); + + it('dismisses an untouched form and ignores native open-state echoes', async () => { + await open(); + fixture.debugElement.query(By.directive(HlmSheet)).triggerEventHandler('stateChanged', 'open'); + expect(dismissed).not.toHaveBeenCalled(); + button('Cancel').click(); + expect(dismissed).toHaveBeenCalledOnce(); + expect(confirmation().state()).toBe('closed'); + }); + + it('keeps a dirty draft on cancellation and dismisses only after explicit discard', async () => { + await open(); + await fillName('My draft'); + button('Cancel').click(); + await fixture.whenStable(); + expect(confirmation().state()).toBe('open'); + expect(dismissed).not.toHaveBeenCalled(); + confirmation().dismissed.emit(); + await fixture.whenStable(); + expect(confirmation().state()).toBe('closed'); + expect((document.querySelector('#customer-name') as HTMLInputElement).value).toBe('My draft'); + button('Cancel').click(); + await fixture.whenStable(); + confirmation().confirmed.emit(); + await fixture.whenStable(); + expect(confirmation().state()).toBe('closed'); + expect(dismissed).toHaveBeenCalledOnce(); + }); + + it('routes a native close request through dirty-draft confirmation', async () => { + await open(); + await fillName('My draft'); + fixture.debugElement + .query(By.directive(HlmSheet)) + .triggerEventHandler('stateChanged', 'closed'); + await fixture.whenStable(); + expect(confirmation().state()).toBe('open'); + expect(dismissed).not.toHaveBeenCalled(); + expect((document.querySelector('#customer-name') as HTMLInputElement).value).toBe('My draft'); + }); + + it('retains rejected drafts, emits conflict refresh and locks native dismissal while writing', async () => { + const refreshed = vi.fn(); + fixture.componentInstance.refreshRequested.subscribe(refreshed); + fixture.componentRef.setInput('customer', customer); + await open(); + await fillName('My draft'); + fixture.componentRef.setInput('error', 'Revision conflict'); + fixture.componentRef.setInput('conflict', true); + await fixture.whenStable(); + expect(document.querySelector('app-customer-form [role="alert"]')?.textContent).toContain( + 'Revision conflict', + ); + button('Refresh the latest version and keep my draft').click(); + expect(refreshed).toHaveBeenCalledOnce(); + expect((document.querySelector('#customer-name') as HTMLInputElement).value).toBe('My draft'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(button('Cancel').disabled).toBe(true); + expect(button('Refresh the latest version and keep my draft').disabled).toBe(true); + fixture.debugElement + .query(By.directive(HlmSheet)) + .triggerEventHandler('stateChanged', 'closed'); + await fixture.whenStable(); + expect(confirmation().state()).toBe('closed'); + expect(dismissed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts b/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts index 20e501b4b..695042876 100644 --- a/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts +++ b/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts @@ -421,15 +421,18 @@ export const InterventionWorkspaceStore = signalStore( store, service = inject(InterventionService), facilityService = inject(FacilityService), - offline = inject(InterventionOfflineService), - connectivity = inject(ConnectivityService), - optimistic = inject( - InterventionWorkspaceOptimisticService, - ), + offlineExecution = { + offline: inject(InterventionOfflineService), + connectivity: inject(ConnectivityService), + optimistic: inject( + InterventionWorkspaceOptimisticService, + ), + }, dispatcher = inject(Dispatcher), equipmentCatalog = inject(InterventionEquipmentCatalogService), inventory = inject(InterventionInventoryService), ) => { + const { offline, connectivity, optimistic } = offlineExecution; let contextGeneration = 0; const teamAssignments = new Set(); diff --git a/src/app/features/organization/features/interventions/ui/pages/interventions-page/testing/interventions-page.component.spec.ts b/src/app/features/organization/features/interventions/ui/pages/interventions-page/testing/interventions-page.component.spec.ts index 641e9dc35..b0b47145b 100644 --- a/src/app/features/organization/features/interventions/ui/pages/interventions-page/testing/interventions-page.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/pages/interventions-page/testing/interventions-page.component.spec.ts @@ -42,6 +42,8 @@ import type { InterventionOutput, InterventionRecurrenceOutput, InterventionRecurrenceFormValues, + MemberSelectOption, + SelectOption, } from '@features/organization/features/interventions/models'; import { InterventionStore } from '@features/organization/features/interventions/state'; import { InterventionBoardStore } from '@features/organization/features/interventions/state/intervention-board'; @@ -175,22 +177,6 @@ const renderPageTabs = (): HTMLElement => { }; describe('InterventionsPage', () => { - it('prepares an equipment-scoped corrective intervention without creating any work implicitly', async () => { - const fixture = await createPage({ - create: '1', - targetEquipment: '00000000-0000-4000-8000-000000000001', - workAction: 'repair', - siteContext: '00000000-0000-4000-8000-000000000002', - }); - expect(fixture.componentInstance['duplicatePrefill']()).toEqual({ - name: '', - type: 'corrective_maintenance', - priority: 'normal', - site: '/api/facilities/00000000-0000-4000-8000-000000000002', - responsible: '', - }); - expect(fixture.componentInstance['createSheetVisible']()).toBe(true); - }); const mobile = signal(false); let fixture: ComponentFixture; let mutationCallStates: WritableSignal>; @@ -380,6 +366,57 @@ describe('InterventionsPage', () => { }); }); + it('prepares an equipment-scoped corrective intervention without creating any work implicitly', async () => { + const preparedFixture = await createPage({ + create: '1', + targetEquipment: '00000000-0000-4000-8000-000000000001', + workAction: 'repair', + siteContext: '00000000-0000-4000-8000-000000000002', + }); + expect(preparedFixture.componentInstance['duplicatePrefill']()).toEqual({ + name: '', + type: 'corrective_maintenance', + priority: 'normal', + site: '/api/facilities/00000000-0000-4000-8000-000000000002', + responsible: '', + }); + expect(preparedFixture.componentInstance['createSheetVisible']()).toBe(true); + }); + + it.each([ + ['inspection', 'inspection_campaign'], + ['maintenance', 'preventive_maintenance'], + ['replacement', 'corrective_maintenance'], + ] as const)( + 'prepares %s work from an equipment dossier and clears its route hints when cancelled', + async (workAction, type) => { + fixture = await createPage({ + create: '1', + targetEquipment: '00000000-0000-4000-8000-000000000001', + workAction, + }); + expect(fixture.componentInstance['duplicatePrefill']()).toMatchObject({ type, site: '' }); + expect(fixture.componentInstance['createSheetVisible']()).toBe(true); + expect(create).not.toHaveBeenCalled(); + expect(instantiateFromTemplate).not.toHaveBeenCalled(); + navigate.mockClear(); + + fixture.componentInstance['onCreateSheetVisibleChange'](false); + + expect(fixture.componentInstance['duplicatePrefill']()).toBeNull(); + expect(fixture.componentInstance['createSheetVisible']()).toBe(false); + expect(navigate).toHaveBeenCalledExactlyOnceWith( + [], + expect.objectContaining({ + queryParams: { targetEquipment: null, workAction: null, siteContext: null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }), + ); + expect(create).not.toHaveBeenCalled(); + }, + ); + it('should load the list for the workspace on arrival', async () => { fixture = await createPage(); @@ -1054,6 +1091,58 @@ describe('InterventionsPage', () => { }); describe('bulk transition', () => { + it('opens the publication workspace when a row requests publication', async () => { + fixture = await createPage(); + const row = intervention({ id: 'publish-1', status: 'submitted', revision: 7 }); + + fixture.componentInstance['applyTransition']({ intervention: row, status: 'published' }); + + expect(navigate).toHaveBeenCalledExactlyOnceWith([ + '/organizations', + 'org-1', + 'interventions', + 'publish-1', + ]); + expect(transition).not.toHaveBeenCalled(); + }); + + it('opens assignment from the selection bar and waits for the operator to confirm', async () => { + interventionList.set([intervention({ id: 'assign-1', status: 'draft' })]); + fixture = await createPage(); + fixture.componentInstance['onSelectionChanged'](new Set(['assign-1'])); + + fixture.componentInstance['onSelectionActionRequested']('assign'); + + expect(fixture.componentInstance['pendingBulkAssignIds']()).toEqual(['assign-1']); + expect(fixture.componentInstance['assignRequest']()).toMatchObject({ + interventionId: '', + interventionName: '1 intervention', + currentResponsible: null, + }); + expect(assignResponsible).not.toHaveBeenCalled(); + }); + + it('rejects selection commands when the viewer has no mutation permissions', async () => { + TestBed.overrideProvider(OrganizationPermissionService, { + useValue: { hasAnyPermission: (): boolean => false, hasPermission: (): boolean => false }, + }); + interventionList.set([ + intervention({ id: 'locked-1', status: 'draft', allowedTransitions: ['planned'] }), + ]); + fixture = await createPage(); + fixture.componentInstance['onSelectionChanged'](new Set(['locked-1'])); + + for (const command of ['assign', 'delete', 'transition:planned']) { + fixture.componentInstance['onSelectionActionRequested'](command); + } + + expect(fixture.componentInstance['assignRequest']()).toBeNull(); + expect(fixture.componentInstance['pendingBulkDeleteIds']()).toBeNull(); + expect(assignResponsible).not.toHaveBeenCalled(); + expect(deleteIntervention).not.toHaveBeenCalled(); + expect(transition).not.toHaveBeenCalled(); + }); + it('routes a selected status command from the floating bar through the existing transition handler', async () => { interventionList.set([ intervention({ @@ -1273,6 +1362,25 @@ describe('InterventionsPage', () => { ); }); + it.each(['Export unavailable', '{"message":"Export unavailable"}'])( + 'reports the export failure when its blob has no API problem detail: %s', + async (body) => { + totalInterventions.set(2); + exportCsv.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500, error: new Blob([body]) })), + ); + fixture = await createPage(); + + fixture.componentInstance['exportCsv'](); + + await vi.waitFor(() => + expect(feedbackError).toHaveBeenCalledExactlyOnceWith("Couldn't export interventions."), + ); + expect(fixture.componentInstance['exportBusy']()).toBe(false); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }, + ); + it('should mark the export button busy and announce it while the export is in flight', async () => { totalInterventions.set(5); fixture = await createPage(); @@ -2143,6 +2251,54 @@ describe('InterventionsPage', () => { }); describe('list navigation and filter controls', () => { + it('passes site names and distinct member avatars to list rows from the planning catalogue', async () => { + const responsibleIri = '/api/organizations/org-1/members/responsible'; + const participantIri = '/api/organizations/org-1/members/participant'; + const responsible: MemberSelectOption = { + value: responsibleIri, + label: 'Alex Martin', + displayName: 'Alex Martin', + roleLabel: 'Technician', + avatarUrl: 'https://example.test/alex.png', + initials: 'AM', + }; + const participant: MemberSelectOption = { + value: participantIri, + label: 'Sam Dupont', + displayName: 'Sam Dupont', + roleLabel: '', + avatarUrl: null, + initials: 'SD', + }; + const planningOptions = TestBed.inject(InterventionPlanningOptionsStore) as unknown as { + sites: WritableSignal; + members: WritableSignal; + }; + planningOptions.sites.set([{ value: '/api/facilities/site-1', label: 'North workshop' }]); + planningOptions.members.set([responsible, participant]); + interventionList.set([ + intervention({ + site: '/api/facilities/site-1', + responsible: responsibleIri, + participants: [responsibleIri, participantIri, participantIri], + }), + ]); + fixture = await createPage(); + + expect(fixture.componentInstance['items']()[0]).toMatchObject({ + siteName: 'North workshop', + responsible, + people: [ + { + label: 'Alex Martin', + image: 'https://example.test/alex.png', + tooltip: 'Alex Martin · Technician', + }, + { label: 'Sam Dupont', image: undefined, tooltip: 'Sam Dupont' }, + ], + }); + }); + it('marks only active work as overdue or due soon and keeps repeated people to one avatar', async () => { const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); diff --git a/src/app/features/organization/features/maintenance-exports/FEATURE.md b/src/app/features/organization/features/maintenance-exports/FEATURE.md index 867aaebed..eb76b87fa 100644 --- a/src/app/features/organization/features/maintenance-exports/FEATURE.md +++ b/src/app/features/organization/features/maintenance-exports/FEATURE.md @@ -20,6 +20,8 @@ no private archive, reference or financial response enters TransferState or SSR Accepted writes survive navigation while late results cannot populate another session or scope. Uncertain operations retain their exact UUID, revision and body until recovery. Confirmed conflicts keep the draft and require explicit source review before a new command is accepted. +Replay checks the permission for the retained command itself. Losing confirm permission while +its sheet is open cannot convert that confirmation intent into an adjustment. Forms own Signal Forms state and emit intents only. Native sheets stay open during writes and failures. Downloads return retained server bytes; the browser never rebuilds a file. Adjustments diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts index 79729d2b9..952534b5d 100644 --- a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts @@ -342,17 +342,21 @@ export const MaintenanceExportStore = signalStore( !scope || !readable() || scope.sessionRevision !== session.sessionRevision() || - scope.organizationId !== incoming.organizationId || + scope.organizationId !== incoming.organizationId + ) + return EMPTY; + const command = store.uncertainWrite() + ? (store.command() ?? incoming) + : structuredClone(incoming); + if ( + scope.organizationId !== command.organizationId || !permissions.hasPermission( - incoming.kind === 'confirm' + command.kind === 'confirm' ? ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM : ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, ) ) return EMPTY; - const command = store.uncertainWrite() - ? (store.command() ?? incoming) - : structuredClone(incoming); const detail = store.detailCallState().data; if ( ((command.kind === 'create' && command.input.includeInternalCosts) || diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts index f7d0a0b6b..8d9c0bf3e 100644 --- a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts @@ -1,16 +1,21 @@ import { PLATFORM_ID, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; import { AUTH_SESSION_PORT } from '@features/auth/ports'; import { OrganizationPermissionService } from '@features/organization/access'; import { MaintenanceExportService } from '@features/organization/features/maintenance-exports/data-access'; import type { MaintenanceExportOutput, + MaintenanceExportReferenceOutput, MaintenanceExportReferencePage, + MaintenanceExportSourceOutput, } from '@features/organization/features/maintenance-exports/models'; import { maintenanceExportFixture } from '@features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture'; import { MaintenanceExportReferenceDirectoryService } from '@features/organization/features/maintenance-exports/services/maintenance-export-reference-directory'; import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { maintenanceExportStoreEvents } from '../events/events'; import { MaintenanceExportStore, type MaintenanceExportStoreType, @@ -23,6 +28,7 @@ describe('MaintenanceExportStore', () => { grants = signal([]); let api: Record>; let directory: { list: ReturnType }; + let dispatcher: { dispatch: ReturnType }; const archive = maintenanceExportFixture(), scope = { organizationId: 'org', sessionRevision: 1 }; const input = { @@ -31,12 +37,24 @@ describe('MaintenanceExportStore', () => { system: 'ERP', includeInternalCosts: false, }; + const mapping: MaintenanceExportReferenceOutput = { + '@id': '/api/organizations/org/maintenance-export-references/mapping', + '@type': 'MaintenanceExportReference', + id: 'mapping', + resourceType: 'equipment', + resourceId: 'equipment', + system: 'ERP', + reference: 'EXT-EQUIPMENT', + revision: 2, + updatedAt: '2026-10-07T12:00:00Z', + }; const setup = (platform = 'browser'): void => { TestBed.configureTestingModule({ providers: [ MaintenanceExportStore, { provide: MaintenanceExportService, useValue: api }, { provide: MaintenanceExportReferenceDirectoryService, useValue: directory }, + { provide: Dispatcher, useValue: dispatcher }, { provide: OrganizationPermissionService, useValue: { hasPermission: (permission: string) => grants().includes(permission) }, @@ -80,6 +98,7 @@ describe('MaintenanceExportStore', () => { download: vi.fn().mockReturnValue(of(new Blob(['retained']))), }; directory = { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }; + dispatcher = { dispatch: vi.fn() }; }); it.each(['server', 'anonymous', 'no-read'])('fetches no private data for %s', (mode) => { if (mode === 'anonymous') authenticated.set(false); @@ -143,6 +162,65 @@ describe('MaintenanceExportStore', () => { expect(api['create']?.mock.calls[1]?.[1]).toEqual(input); expect(store.command()).toBeNull(); }); + + it('requires the retained confirmation permission before replaying an uncertain command supplied as a creation', () => { + setup(); + const original = maintenanceExportFixture({ revision: 7 }); + const confirmed = maintenanceExportFixture({ revision: 8, state: 'import_confirmed' }); + api['read']?.mockReturnValueOnce(of(original)); + api['confirm'] + ?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Network', + detail: 'Confirmation reply lost.', + })), + ) + .mockReturnValueOnce(of(confirmed)); + store.read(original.id); + const confirmation = { + clientOperationId: '6b13bfd1-3c18-4e8a-ad98-5129c7300fe1', + externalImportReference: 'IMPORT-REVIEWED', + }; + const draft = { ...confirmation }; + const retained = { + kind: 'confirm' as const, + organizationId: 'org', + exportId: original.id, + revision: 7, + input: confirmation, + }; + store.write({ ...retained, input: draft }); + expect(api['confirm']).toHaveBeenCalledExactlyOnceWith('org', original.id, confirmation, 7); + expect(store.uncertainWrite()).toBe(true); + draft.clientOperationId = 'f5be7221-893e-443a-9f47-42525040a66a'; + draft.externalImportReference = 'UNREVIEWED-CHANGE'; + + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ]); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(api['confirm']).toHaveBeenCalledTimes(1); + expect(api['create']).not.toHaveBeenCalled(); + expect(store.command()).toEqual(retained); + expect(store.uncertainWrite()).toBe(true); + expect(store.writeCallState().status).toBe('error'); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM]); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(api['confirm']).toHaveBeenCalledTimes(2); + expect(api['confirm']?.mock.calls[1]).toEqual(['org', original.id, confirmation, 7]); + expect(api['create']).not.toHaveBeenCalled(); + expect(store.writeCallState()).toMatchObject({ status: 'success', data: confirmed }); + expect(store.command()).toBeNull(); + expect(dispatcher.dispatch).toHaveBeenCalledExactlyOnceWith( + maintenanceExportStoreEvents.acknowledged({ ...scope, kind: 'confirm', id: original.id }), + ); + }); + it('retains a confirmed stale-revision command for explicit review and does not classify it as an uncertain transport', () => { setup(); store.read(archive.id); @@ -369,4 +447,506 @@ describe('MaintenanceExportStore', () => { expect(api['download']).toHaveBeenLastCalledWith('other', 'other-export', 'json'); expect(store.downloadCallState().status).toBe('success'); }); + + it('replaces archive rows while a filtered server page loads and retains the authoritative total', () => { + setup(); + store.load({ page: 1 }); + const page = new Subject>(); + api['list']?.mockReturnValueOnce(page); + store.load({ page: 3, system: 'ERP' }); + expect(api['list']).toHaveBeenLastCalledWith('org', { + page: 3, + itemsPerPage: 30, + params: { system: 'ERP' }, + }); + expect(store.listCallState().status).toBe('pending'); + expect(store.page()).toBe(3); + expect(store.exportEntities()).toEqual([]); + const lastArchive = maintenanceExportFixture({ id: 'last-export' }); + page.next({ + '@id': '/api/organizations/org/maintenance-exports?page=3', + '@type': 'Collection', + member: [lastArchive], + totalItems: 61, + }); + expect(store.exportEntities()).toEqual([lastArchive]); + expect(store.listCallState().status).toBe('success'); + expect(store.total()).toBe(61); + expect(store.pageCount()).toBe(3); + api['list']?.mockReturnValueOnce(of({ member: [], totalItems: 0 })); + store.load({ page: 1 }); + expect(store.exportEntities()).toEqual([]); + expect(store.pageCount()).toBe(1); + }); + + it('exposes a failed archive page as a normalized error instead of a successful empty list', () => { + setup(); + store.load({ page: 1 }); + api['list']?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Archive page unavailable.', + })), + ); + store.load({ page: 2 }); + expect(store.listCallState()).toMatchObject({ + status: 'error', + data: null, + error: { code: 503, message: 'Archive page unavailable.' }, + }); + expect(store.exportEntities()).toEqual([]); + }); + + it('retains the selected archive during its refresh and a normalized refresh failure', () => { + setup(); + store.read(archive.id); + const refresh = new Subject(); + api['read']?.mockReturnValueOnce(refresh); + store.read(archive.id); + expect(store.detailCallState()).toMatchObject({ status: 'pending', data: archive }); + refresh.error({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Archive refresh unavailable.', + }); + expect(store.detailCallState()).toMatchObject({ + status: 'error', + data: archive, + error: { code: 503, message: 'Archive refresh unavailable.' }, + }); + expect(store.selectedId()).toBe(archive.id); + }); + + it('discards previous detail when selecting another archive and ignores a foreign organization reply', () => { + setup(); + store.read(archive.id); + const detail = new Subject(); + api['read']?.mockReturnValueOnce(detail); + store.read('another-export'); + expect(store.detailCallState()).toMatchObject({ status: 'pending', data: null }); + detail.next(maintenanceExportFixture({ id: 'another-export', organizationId: 'other' })); + expect(store.detailCallState().data).toBeNull(); + store.read(null); + expect(detail.observed).toBe(false); + expect(store.selectedId()).toBeNull(); + expect(store.detailCallState().status).toBe('idle'); + }); + + it('loads published source choices with their server readiness and exact search page', () => { + setup(); + const sources = new Subject>(); + api['listSources']?.mockReturnValueOnce(sources); + store.loadSources({ page: 2, search: 'published' }); + expect(api['listSources']).toHaveBeenLastCalledWith('org', { + page: 2, + search: 'published', + itemsPerPage: 30, + }); + expect(store.sourcesCallState().status).toBe('pending'); + const source: MaintenanceExportSourceOutput = { + '@id': '/api/organizations/org/maintenance-export-sources/dossier-1', + '@type': 'MaintenanceExportSource', + id: 'dossier-1', + number: 17, + name: 'Published maintenance', + type: 'maintenance', + publishedAt: '2026-10-07T12:00:00Z', + snapshotState: 'retained', + identityComplete: true, + ready: true, + }; + const collection: HydraCollection = { + '@id': '/api/organizations/org/maintenance-export-sources?page=2', + '@type': 'Collection', + member: [source], + totalItems: 31, + }; + sources.next(collection); + expect(store.sourcesCallState()).toMatchObject({ status: 'success', data: collection }); + store.loadSources(null); + expect(sources.observed).toBe(false); + expect(store.sourcesCallState()).toMatchObject({ status: 'idle', data: null }); + }); + + it('exposes source selection failure independently from previously loaded archives', () => { + setup(); + store.load({ page: 1 }); + api['listSources']?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 403, + title: 'Forbidden', + detail: 'Source selection denied.', + })), + ); + store.loadSources({ page: 1 }); + expect(store.sourcesCallState()).toMatchObject({ + status: 'error', + data: null, + error: { code: 403, message: 'Source selection denied.' }, + }); + expect(store.exportEntities()).toEqual([archive]); + expect(store.listCallState().status).toBe('success'); + }); + + it('loads and resets a filtered external-reference page independently from archive detail', () => { + setup(); + store.read(archive.id); + const references = new Subject>(); + api['listReferences']?.mockReturnValueOnce(references); + store.loadReferences({ page: 2, system: 'ERP' }); + expect(api['listReferences']).toHaveBeenLastCalledWith('org', { + page: 2, + itemsPerPage: 30, + params: { system: 'ERP' }, + }); + expect(store.referencesCallState().status).toBe('pending'); + const collection: HydraCollection = { + '@id': '/api/organizations/org/maintenance-export-references?page=2', + '@type': 'Collection', + member: [mapping], + totalItems: 31, + }; + references.next(collection); + expect(store.referencesCallState()).toMatchObject({ status: 'success', data: collection }); + store.loadReferences(null); + expect(references.observed).toBe(false); + expect(store.referencesCallState()).toMatchObject({ status: 'idle', data: null }); + expect(store.detailCallState().data).toEqual(archive); + }); + + it('exposes external-reference page failure instead of presenting an absent mapping', () => { + setup(); + api['listReferences']?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Reference page unavailable.', + })), + ); + store.loadReferences({ page: 1 }); + expect(api['listReferences']).toHaveBeenCalledWith('org', { + page: 1, + itemsPerPage: 30, + params: {}, + }); + expect(store.referencesCallState()).toMatchObject({ + status: 'error', + data: null, + error: { code: 503, message: 'Reference page unavailable.' }, + }); + expect(store.mappingCallState().status).toBe('idle'); + }); + + it('selects the mapping for the exact resource owner and system before clearing that selection', () => { + setup(); + api['listReferences']?.mockReturnValueOnce( + of({ + member: [ + { ...mapping, id: 'different-resource', resourceId: 'other-equipment' }, + { ...mapping, id: 'different-owner', resourceType: 'site' }, + { ...mapping, id: 'different-system', system: 'OTHER' }, + mapping, + ], + totalItems: 4, + }), + ); + const query = { system: 'ERP', resourceType: 'equipment' as const, resourceId: 'equipment' }; + store.readMapping(query); + expect(api['listReferences']).toHaveBeenCalledWith('org', { + page: 1, + itemsPerPage: 30, + params: query, + }); + expect(store.mappingCallState()).toMatchObject({ status: 'success', data: mapping }); + store.readMapping(null); + expect(store.mappingCallState()).toMatchObject({ status: 'idle', data: null }); + }); + + it('exposes a directory error and resets unavailable owner choices when their read is cleared', () => { + setup(); + const targets = new Subject(); + directory.list.mockReturnValueOnce(targets); + store.loadTargets({ resourceType: 'site', page: 1 }); + expect(store.targetsCallState().status).toBe('pending'); + targets.error({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Owner directory unavailable.', + }); + expect(store.targetsCallState()).toMatchObject({ + status: 'error', + data: null, + error: { code: 503, message: 'Owner directory unavailable.' }, + }); + store.loadTargets(null); + expect(store.targetsCallState()).toMatchObject({ status: 'idle', data: null }); + }); + + it('selects a successfully appended adjustment and acknowledges its exact accepted operation', () => { + setup(); + store.load({ page: 1 }); + store.read(archive.id); + const accepted = new Subject(); + api['adjust']?.mockReturnValueOnce(accepted); + const adjustment = { + clientOperationId: 'adjust-operation', + reason: 'Reviewed source correction', + }; + store.write({ + kind: 'adjust', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: adjustment, + }); + expect(api['adjust']).toHaveBeenCalledWith('org', archive.id, adjustment, 1); + expect(store.writePending()).toBe(true); + expect(store.detailCallState().data).toEqual(archive); + const adjusted = maintenanceExportFixture({ + id: 'adjusted-export', + kind: 'adjustment', + adjustmentOf: archive.id, + originalExportId: archive.id, + }); + accepted.next(adjusted); + expect(store.writeCallState()).toMatchObject({ status: 'success', data: adjusted }); + expect(store.detailCallState().data).toEqual(adjusted); + expect(store.selectedId()).toBe(adjusted.id); + expect(store.command()).toBeNull(); + expect(store.exportEntities()).toEqual([archive]); + expect(dispatcher.dispatch).toHaveBeenCalledExactlyOnceWith( + maintenanceExportStoreEvents.acknowledged({ ...scope, kind: 'adjust', id: adjusted.id }), + ); + }); + + it('acknowledges an external import using confirm permission without an export-management grant', () => { + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM, + ]); + setup(); + store.read(archive.id); + const confirmation = { + clientOperationId: 'confirm-operation', + externalImportReference: 'IMPORT-9', + }; + const confirmed = maintenanceExportFixture({ state: 'import_confirmed', revision: 2 }); + api['confirm']?.mockReturnValueOnce(of(confirmed)); + store.write({ + kind: 'confirm', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: confirmation, + }); + expect(api['confirm']).toHaveBeenCalledWith('org', archive.id, confirmation, 1); + expect(store.writeCallState()).toMatchObject({ status: 'success', data: confirmed }); + expect(store.detailCallState().data).toEqual(confirmed); + expect(store.command()).toBeNull(); + expect(dispatcher.dispatch).toHaveBeenCalledExactlyOnceWith( + maintenanceExportStoreEvents.acknowledged({ ...scope, kind: 'confirm', id: archive.id }), + ); + }); + + it('updates the reviewed external mapping after a successful write without replacing archive detail', () => { + setup(); + store.read(archive.id); + const reference = { + clientOperationId: 'reference-operation', + system: 'ERP', + reference: 'EXT-EQUIPMENT', + }; + api['writeReference']?.mockReturnValueOnce(of(mapping)); + store.write({ + kind: 'reference', + organizationId: 'org', + resourceType: 'equipment', + resourceId: 'equipment', + revision: 1, + input: reference, + }); + expect(api['writeReference']).toHaveBeenCalledWith( + 'org', + 'equipment', + 'equipment', + reference, + 1, + ); + expect(store.writeCallState()).toMatchObject({ status: 'success', data: mapping }); + expect(store.mappingCallState()).toMatchObject({ status: 'success', data: mapping }); + expect(store.detailCallState().data).toEqual(archive); + expect(store.selectedId()).toBe(archive.id); + expect(store.command()).toBeNull(); + expect(dispatcher.dispatch).toHaveBeenCalledExactlyOnceWith( + maintenanceExportStoreEvents.acknowledged({ ...scope, kind: 'reference', id: mapping.id }), + ); + }); + + it('rejects a successful write receipt belonging to another organization without emitting acknowledgement', () => { + setup(); + api['create']?.mockReturnValueOnce(of(maintenanceExportFixture({ organizationId: 'other' }))); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(store.writeCallState()).toMatchObject({ status: 'idle', data: null }); + expect(store.detailCallState().data).toBeNull(); + expect(store.command()).toBeNull(); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('keeps an accepted command fenced until a late failure releases the write in the new organization', () => { + setup(); + const accepted = new Subject(); + api['create']?.mockReturnValueOnce(accepted); + store.write({ kind: 'create', organizationId: 'org', input }); + store.clearCommand(); + expect(store.writePending()).toBe(true); + expect(store.command()).toEqual({ kind: 'create', organizationId: 'org', input }); + store.setScope({ ...scope, organizationId: 'other' }); + store.write({ kind: 'create', organizationId: 'other', input }); + expect(api['create']).toHaveBeenCalledTimes(1); + accepted.error({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Late write failure.', + }); + expect(store.writeCallState()).toMatchObject({ status: 'idle', data: null, error: null }); + expect(store.command()).toBeNull(); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('preserves a retryable command on editor reset and allows explicit reset after a confirmed conflict', () => { + setup(); + api['create'] + ?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Receipt unavailable.', + })), + ) + .mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 409, + title: 'Conflict', + detail: 'Review the selected sources.', + })), + ); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(store.uncertainWrite()).toBe(true); + store.clearCommand(); + expect(store.command()).toEqual({ kind: 'create', organizationId: 'org', input }); + expect(store.writeCallState().status).toBe('error'); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(store.uncertainWrite()).toBe(false); + store.clearCommand(); + expect(store.command()).toBeNull(); + expect(store.writeCallState()).toMatchObject({ status: 'idle', data: null, error: null }); + }); + + it('blocks adjustment and confirmation of a retained financial archive after financial read is denied', () => { + setup(); + api['read']?.mockReturnValueOnce(of(maintenanceExportFixture({ includeInternalCosts: true }))); + store.read(archive.id); + store.write({ + kind: 'adjust', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: { clientOperationId: 'financial-adjustment', reason: 'Reviewed correction' }, + }); + store.write({ + kind: 'confirm', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: { clientOperationId: 'financial-confirmation', externalImportReference: 'IMPORT-1' }, + }); + expect(api['adjust']).not.toHaveBeenCalled(); + expect(api['confirm']).not.toHaveBeenCalled(); + expect(store.writeCallState().status).toBe('idle'); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('rejects commands outside the active organization or without the independent management grant', () => { + setup(); + store.write({ kind: 'create', organizationId: 'other', input }); + expect(api['create']).not.toHaveBeenCalled(); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM, + ]); + store.write({ kind: 'create', organizationId: 'org', input }); + store.write({ + kind: 'reference', + organizationId: 'org', + resourceType: 'equipment', + resourceId: 'equipment', + revision: 0, + input: { clientOperationId: 'denied-reference', system: 'ERP', reference: 'EXT-EQUIPMENT' }, + }); + expect(api['create']).not.toHaveBeenCalled(); + expect(api['writeReference']).not.toHaveBeenCalled(); + expect(store.writeCallState().status).toBe('idle'); + }); + + it('retains the actual downloaded bytes until the browser consumer explicitly clears them', () => { + setup(); + const download = new Subject(); + api['download']?.mockReturnValueOnce(download); + store.download({ exportId: archive.id, format: 'csv', includeInternalCosts: false }); + expect(api['download']).toHaveBeenCalledWith('org', archive.id, 'csv'); + expect(store.downloadCallState().status).toBe('pending'); + const bytes = new Blob(['retained server bytes'], { type: 'text/csv' }); + download.next(bytes); + expect(store.downloadCallState().status).toBe('success'); + expect(store.downloadCallState().data).toBe(bytes); + store.clearDownload(); + expect(store.downloadCallState()).toMatchObject({ status: 'idle', data: null, error: null }); + expect(store.detailCallState().data).toBeNull(); + }); + + it('normalizes a download failure and allows a subsequent successful retry', () => { + setup(); + api['download']?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 404, + title: 'Not found', + detail: 'Retained file unavailable.', + })), + ); + const query = { exportId: archive.id, format: 'json' as const, includeInternalCosts: false }; + store.download(query); + expect(store.downloadCallState()).toMatchObject({ + status: 'error', + data: null, + error: { code: 404, message: 'Retained file unavailable.' }, + }); + store.download(query); + expect(store.downloadCallState().status).toBe('success'); + expect(store.downloadCallState().error).toBeNull(); + }); + + it('preserves an in-flight detail and loaded archive page when the same scope is supplied again', () => { + setup(); + store.load({ page: 1 }); + const detail = new Subject(); + api['read']?.mockReturnValueOnce(detail); + store.read(archive.id); + const generation = store.generation(); + store.setScope({ ...scope }); + expect(store.generation()).toBe(generation); + expect(detail.observed).toBe(true); + expect(store.exportEntities()).toEqual([archive]); + detail.next(archive); + expect(store.detailCallState()).toMatchObject({ status: 'success', data: archive }); + }); }); diff --git a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts index 0fa293815..4aaa570ea 100644 --- a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts +++ b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts @@ -862,7 +862,9 @@ export class MaintenanceExportsPage { protected act(value: string): void { const detail = this.store.detailCallState().data; if (!detail || this.locked() || this.conflict()) return; - if (this.confirming() && this.canConfirm()) + const confirming = this.confirming(); + if (!(confirming ? this.canConfirm() : this.canManage())) return; + if (confirming) this.store.write({ kind: 'confirm', organizationId: this.organizationId(), @@ -870,7 +872,7 @@ export class MaintenanceExportsPage { revision: detail.revision, input: { clientOperationId: crypto.randomUUID(), externalImportReference: value }, }); - else if (this.canManage()) + else this.store.write({ kind: 'adjust', organizationId: this.organizationId(), diff --git a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts index a4f0375f2..aabe02fec 100644 --- a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts +++ b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts @@ -1,8 +1,17 @@ -import { PLATFORM_ID, signal } from '@angular/core'; +import { getDebugNode, PLATFORM_ID, signal, type DebugElement } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { of, Subject } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; import { ConnectivityService } from '@core/connectivity'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; import { THEME_PORT } from '@core/theme'; import { AUTH_SESSION_PORT } from '@features/auth/ports'; import { OrganizationPermissionService } from '@features/organization/access'; @@ -10,12 +19,43 @@ import { CustomerService } from '@features/organization/features/customers/data- import { EquipmentService } from '@features/organization/features/equipments/data-access'; import { FacilityService } from '@features/organization/features/facilities/data-access'; import { MaintenanceExportService } from '@features/organization/features/maintenance-exports/data-access'; +import type { + MaintenanceExportReferenceOutput, + MaintenanceExportSourceOutput, +} from '@features/organization/features/maintenance-exports/models'; import { maintenanceExportFixture } from '@features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture'; +import { + MaintenanceExportStore, + maintenanceExportStoreEvents, + type MaintenanceExportStoreType, +} from '@features/organization/features/maintenance-exports/state'; import { ORGANIZATION_PERMISSION } from '@features/organization/models'; import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; import { BrowserDownloadService } from '@features/organization/services/browser-download'; +import { MaintenanceExportActionForm } from '../../../forms/maintenance-export-action-form'; +import { MaintenanceExportCreateForm } from '../../../forms/maintenance-export-create-form'; +import { MaintenanceExportReferenceForm } from '../../../forms/maintenance-export-reference-form'; import { MaintenanceExportsPage } from '../maintenance-exports-page.component'; +const failure = (status: number, detail: string) => + toStoreError({ type: 'about:blank', status, title: 'Request rejected', detail }); +const button = (label: string): HTMLButtonElement => { + const match = Array.from(document.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === label, + ); + if (!match) throw new Error(`Button ${label} is not rendered`); + return match; +}; +const child = (selector: string): T => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Child ${selector} is not rendered`); + return (getDebugNode(element) as DebugElement).componentInstance as T; +}; +const createForm = (): MaintenanceExportCreateForm => child('app-maintenance-export-create-form'); +const actionForm = (): MaintenanceExportActionForm => child('app-maintenance-export-action-form'); +const referenceForm = (): MaintenanceExportReferenceForm => + child('app-maintenance-export-reference-form'); + describe('MaintenanceExportsPage', () => { let fixture: ComponentFixture, api: Record>; @@ -143,4 +183,539 @@ describe('MaintenanceExportsPage', () => { expect(root().textContent).not.toContain('original-json-hash'); expect(api['list']).toHaveBeenCalledTimes(2); }); + + describe('command orchestration', () => { + const createStore = () => { + const command = signal>(null); + const writeCallState = + signal>(idleCallState()); + return { + scope: signal({ organizationId: 'org', sessionRevision: 1 }), + selectedId: signal(archive.id), + page: signal(3), + pageCount: signal(4), + exportEntities: signal([archive]), + listCallState: signal>( + successCallState(null), + ), + detailCallState: signal>( + successCallState(maintenanceExportFixture({ revision: 7 })), + ), + sourcesCallState: signal>( + successCallState>({ + '@id': '/api/organizations/org/maintenance-export-sources', + '@type': 'Collection', + member: [], + totalItems: 0, + }), + ), + referencesCallState: signal>( + successCallState>({ + '@id': '/api/organizations/org/maintenance-export-references', + '@type': 'Collection', + member: [], + totalItems: 0, + }), + ), + targetsCallState: signal>( + successCallState({ member: [], totalItems: 0 }), + ), + mappingCallState: + signal>(idleCallState()), + writeCallState, + downloadCallState: + signal>(idleCallState()), + command, + writePending: signal(false), + uncertainWrite: signal(false), + setScope: vi.fn(), + load: vi.fn(), + read: vi.fn(), + loadSources: vi.fn(), + loadReferences: vi.fn(), + loadTargets: vi.fn(), + readMapping: vi.fn(), + write: vi.fn(), + download: vi.fn(), + clearDownload: vi.fn(), + clearCommand: vi.fn().mockImplementation(() => { + command.set(null); + writeCallState.set(idleCallState()); + }), + }; + }; + let store: ReturnType; + let acknowledged: Subject>; + let trigger: ReturnType; + let navigate: ReturnType; + const operationId = '54be8e08-ef40-42d7-bb2c-d719ad660b86'; + const commandInput = { + interventionIds: ['published-dossier'], + system: 'ERP', + includeInternalCosts: false, + }; + const setupPage = async (): Promise => { + TestBed.configureTestingModule({ + imports: [MaintenanceExportsPage], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: Events, useValue: { on: vi.fn().mockReturnValue(acknowledged) } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: ConnectivityService, useValue: { isOnline: online } }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'UTC' }) }, + }, + { provide: BrowserDownloadService, useValue: { trigger } }, + ], + }) + .overrideComponent(MaintenanceExportsPage, { + set: { providers: [{ provide: MaintenanceExportStore, useValue: store }] }, + }) + .overrideComponent(MaintenanceExportCreateForm, { set: { template: '' } }) + .overrideComponent(MaintenanceExportActionForm, { set: { template: '' } }) + .overrideComponent(MaintenanceExportReferenceForm, { set: { template: '' } }); + navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(MaintenanceExportsPage); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('exportId', archive.id); + await fixture.whenStable(); + }; + beforeEach(() => { + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ORGANIZATION_PERMISSION.CUSTOMERS_READ, + ]); + store = createStore(); + acknowledged = new Subject(); + trigger = vi.fn(); + vi.spyOn(crypto, 'randomUUID').mockReturnValue(operationId); + }); + afterEach(() => vi.restoreAllMocks()); + + it('forwards a validated creation intent with one generated operation UUID', async () => { + await setupPage(); + expect(store.loadSources).not.toHaveBeenCalled(); + button('Generate export').click(); + await fixture.whenStable(); + expect(store.loadSources).toHaveBeenCalledExactlyOnceWith({ page: 1 }); + createForm().submitted.emit(commandInput); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'create', + organizationId: 'org', + input: { ...commandInput, clientOperationId: operationId }, + }); + }); + + it('forwards archive query changes and clearing without refetching the organization collection', async () => { + await setupPage(); + expect(store.setScope).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + sessionRevision: 1, + }); + store.load.mockClear(); + store.loadReferences.mockClear(); + store.read.mockClear(); + fixture.componentRef.setInput('exportId', 'other-archive'); + await fixture.whenStable(); + expect(store.read).toHaveBeenCalledExactlyOnceWith('other-archive'); + fixture.componentRef.setInput('exportId', undefined); + await fixture.whenStable(); + expect(store.read.mock.calls).toEqual([['other-archive'], [null]]); + expect(store.load).not.toHaveBeenCalled(); + expect(store.loadReferences).not.toHaveBeenCalled(); + }); + + it('projects each owner directory grant independently into the reference picker', async () => { + grants.set([ + ...grants(), + ORGANIZATION_PERMISSION.FACILITIES_READ, + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ]); + await setupPage(); + button('Map a reference').click(); + await fixture.whenStable(); + expect(referenceForm().allowedTypes()).toEqual(['customer', 'site', 'equipment']); + grants.set(grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.FACILITIES_READ)); + await fixture.whenStable(); + expect(referenceForm().allowedTypes()).toEqual(['customer', 'equipment']); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ]); + await fixture.whenStable(); + expect(referenceForm().allowedTypes()).toEqual([]); + }); + + it('retains the committed dossier search when the child changes only its page', async () => { + await setupPage(); + button('Generate export').click(); + await fixture.whenStable(); + createForm().searchChanged.emit('published operator'); + createForm().pageChanged.emit(3); + expect(store.loadSources.mock.calls.slice(-2)).toEqual([ + [{ page: 1, search: 'published operator' }], + [{ page: 3, search: 'published operator' }], + ]); + await fixture.whenStable(); + expect(createForm().sourcePage()).toBe(3); + }); + + it.each(['adjust', 'confirm'] as const)( + 'submits %s at the displayed archive revision with an explicit operator value', + async (kind) => { + if (kind === 'confirm') + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM, + ]); + await setupPage(); + button(kind === 'confirm' ? 'Confirm external import' : 'Append adjustment').click(); + await fixture.whenStable(); + expect(actionForm().confirmation()).toBe(kind === 'confirm'); + actionForm().submitted.emit('Reviewed ERP import'); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind, + organizationId: 'org', + exportId: archive.id, + revision: 7, + input: + kind === 'confirm' + ? { clientOperationId: operationId, externalImportReference: 'Reviewed ERP import' } + : { clientOperationId: operationId, reason: 'Reviewed ERP import' }, + }); + if (kind === 'confirm') + expect(root().querySelector('[data-testid=maintenance-export-create]')).toBeNull(); + else expect(root().textContent).not.toContain('Confirm external import'); + }, + ); + + it('keeps the confirmation intent and draft when confirm permission is revoked while management remains', async () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM]); + await setupPage(); + button('Confirm external import').click(); + await fixture.whenStable(); + const form = actionForm(); + form.dirtyChanged.emit(true); + grants.set( + grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM), + ); + await fixture.whenStable(); + form.submitted.emit('Actual ERP import reference'); + expect(store.write).not.toHaveBeenCalled(); + expect(actionForm()).toBe(form); + expect(form.confirmation()).toBe(true); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM]); + await fixture.whenStable(); + form.submitted.emit('Actual ERP import reference'); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'confirm', + organizationId: 'org', + exportId: archive.id, + revision: 7, + input: { + clientOperationId: operationId, + externalImportReference: 'Actual ERP import reference', + }, + }); + }); + + it('passes the owner directory query and reviewed mapping tuple without losing the revision', async () => { + await setupPage(); + button('Map a reference').click(); + await fixture.whenStable(); + expect(referenceForm().allowedTypes()).toEqual(['customer']); + const query = { + resourceType: 'customer' as const, + page: 3, + search: 'historical operator', + archived: true, + }; + referenceForm().targetQueryChanged.emit(query); + const mapping = { resourceType: 'customer' as const, resourceId: 'customer', system: 'ERP' }; + referenceForm().mappingQueryChanged.emit(mapping); + referenceForm().submitted.emit({ ...mapping, reference: 'ERP-CUSTOMER', revision: 6 }); + expect(store.loadTargets).toHaveBeenLastCalledWith(query); + expect(store.readMapping).toHaveBeenLastCalledWith(mapping); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'reference', + organizationId: 'org', + resourceType: 'customer', + resourceId: 'customer', + revision: 6, + input: { clientOperationId: operationId, system: 'ERP', reference: 'ERP-CUSTOMER' }, + }); + await fixture.whenStable(); + expect(referenceForm().targetPage()).toBe(3); + }); + + it.each(['pending', 'uncertain', 'offline'] as const)( + 'locks the open draft and refuses a second intent while %s', + async (condition) => { + await setupPage(); + button('Generate export').click(); + await fixture.whenStable(); + if (condition === 'pending') store.writePending.set(true); + if (condition === 'uncertain') store.uncertainWrite.set(true); + if (condition === 'offline') online.set(false); + await fixture.whenStable(); + expect(button('Generate export').disabled).toBe(true); + expect(createForm().locked()).toBe(true); + expect(createForm().pending()).toBe(condition === 'pending'); + createForm().submitted.emit(commandInput); + createForm().cancelled.emit(); + await fixture.whenStable(); + expect(store.write).not.toHaveBeenCalled(); + expect(document.querySelector('app-maintenance-export-create-form')).not.toBeNull(); + if (condition !== 'offline') { + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + await expect(fixture.componentInstance.confirmDeactivation()).resolves.toBe(false); + } + }, + ); + + it('recovers the retained uncertain command without generating another operation identity', async () => { + await setupPage(); + button('Append adjustment').click(); + await fixture.whenStable(); + const command = { + kind: 'adjust' as const, + organizationId: 'org', + exportId: archive.id, + revision: 7, + input: { clientOperationId: operationId, reason: 'Reviewed adjustment' }, + }; + store.command.set(command); + store.uncertainWrite.set(true); + await fixture.whenStable(); + button('Recover the same operation').click(); + expect(store.write).toHaveBeenCalledExactlyOnceWith(command); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + }); + + it('keeps an adjustment draft through a failed conflict review and adopts only a successful reread', async () => { + await setupPage(); + button('Append adjustment').click(); + await fixture.whenStable(); + const form = actionForm(); + form.dirtyChanged.emit(true); + store.writeCallState.set(errorCallState(failure(412, 'Archive changed'))); + await fixture.whenStable(); + form.submitted.emit('Retained reason'); + expect(store.write).not.toHaveBeenCalled(); + expect(button('Use reviewed revision and keep my draft').disabled).toBe(true); + store.detailCallState.set(pendingCallState(archive)); + button('Load latest source').click(); + expect(store.read).toHaveBeenLastCalledWith(archive.id); + await fixture.whenStable(); + expect(button('Use reviewed revision and keep my draft').disabled).toBe(true); + store.detailCallState.set(errorCallState(failure(503, 'Source unavailable'), archive)); + await fixture.whenStable(); + expect(button('Use reviewed revision and keep my draft').disabled).toBe(true); + store.detailCallState.set(successCallState(maintenanceExportFixture({ revision: 8 }))); + await fixture.whenStable(); + button('Use reviewed revision and keep my draft').click(); + await fixture.whenStable(); + expect(actionForm()).toBe(form); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + form.submitted.emit('Retained reason'); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'adjust', + organizationId: 'org', + exportId: archive.id, + revision: 8, + input: { clientOperationId: operationId, reason: 'Retained reason' }, + }); + }); + + it('rereads the selected mapping tuple and preserves its form until a reviewed source succeeds', async () => { + await setupPage(); + button('Map a reference').click(); + await fixture.whenStable(); + const form = referenceForm(); + const mapping = { resourceType: 'customer' as const, resourceId: 'customer', system: 'ERP' }; + form.mappingQueryChanged.emit(mapping); + form.dirtyChanged.emit(true); + store.writeCallState.set(errorCallState(failure(409, 'Mapping changed'))); + store.mappingCallState.set(pendingCallState()); + await fixture.whenStable(); + form.submitted.emit({ ...mapping, reference: 'retained-reference', revision: 0 }); + expect(store.write).not.toHaveBeenCalled(); + button('Load latest source').click(); + expect(store.readMapping).toHaveBeenLastCalledWith(mapping); + await fixture.whenStable(); + expect(button('Use reviewed revision and keep my draft').disabled).toBe(true); + store.mappingCallState.set(successCallState(null)); + await fixture.whenStable(); + button('Use reviewed revision and keep my draft').click(); + await fixture.whenStable(); + expect(referenceForm()).toBe(form); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + form.submitted.emit({ ...mapping, reference: 'retained-reference', revision: 0 }); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'reference', + organizationId: 'org', + resourceType: 'customer', + resourceId: 'customer', + revision: 0, + input: { clientOperationId: operationId, system: 'ERP', reference: 'retained-reference' }, + }); + }); + + it('lets the reader keep a dirty draft or explicitly discard it before navigation', async () => { + await setupPage(); + button('Generate export').click(); + await fixture.whenStable(); + const form = createForm(); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + form.dirtyChanged.emit(true); + const kept = fixture.componentInstance.confirmDeactivation(); + await fixture.whenStable(); + button('Keep editing').click(); + await expect(kept).resolves.toBe(false); + await fixture.whenStable(); + expect(createForm()).toBe(form); + form.cancelled.emit(); + await fixture.whenStable(); + expect(document.body.textContent).toContain('Discard the local draft'); + const discarded = fixture.componentInstance.confirmDeactivation(); + button('Discard draft').click(); + await expect(discarded).resolves.toBe(true); + await fixture.whenStable(); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + expect(document.querySelector('app-maintenance-export-create-form')).toBeNull(); + }); + + it('closes a clean cancelled form without showing the discard decision', async () => { + await setupPage(); + button('Generate export').click(); + await fixture.whenStable(); + createForm().cancelled.emit(); + await fixture.whenStable(); + expect(document.querySelector('app-maintenance-export-create-form')).toBeNull(); + expect(document.body.textContent).not.toContain('Discard the local draft'); + }); + + it.each(['create', 'reference'] as const)( + 'refreshes the current archive page after same-session %s acknowledgement', + async (kind) => { + await setupPage(); + button(kind === 'create' ? 'Generate export' : 'Map a reference').click(); + await fixture.whenStable(); + if (kind === 'create') createForm().dirtyChanged.emit(true); + else referenceForm().dirtyChanged.emit(true); + store.load.mockClear(); + store.loadReferences.mockClear(); + acknowledged.next( + maintenanceExportStoreEvents.acknowledged({ + organizationId: 'org', + sessionRevision: 1, + kind, + id: 'acknowledged-archive', + }), + ); + await fixture.whenStable(); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + expect(store.load).toHaveBeenCalledExactlyOnceWith({ page: 3 }); + expect(store.loadReferences).toHaveBeenCalledExactlyOnceWith({ page: 1 }); + expect(document.querySelector('[data-testid=maintenance-export-editor]')).toBeNull(); + if (kind === 'reference') expect(navigate).not.toHaveBeenCalled(); + else + expect(navigate).toHaveBeenCalledExactlyOnceWith([], { + relativeTo: fixture.debugElement.injector.get(ActivatedRoute), + queryParams: { exportId: 'acknowledged-archive' }, + queryParamsHandling: 'merge', + }); + }, + ); + + it.each(['organization', 'session'] as const)( + 'ignores an acknowledgement from another %s and keeps the active draft', + async (different) => { + await setupPage(); + button('Generate export').click(); + await fixture.whenStable(); + const form = createForm(); + form.dirtyChanged.emit(true); + store.load.mockClear(); + store.loadReferences.mockClear(); + acknowledged.next( + maintenanceExportStoreEvents.acknowledged({ + organizationId: different === 'organization' ? 'other' : 'org', + sessionRevision: different === 'session' ? 2 : 1, + kind: 'create', + id: 'foreign-archive', + }), + ); + await fixture.whenStable(); + expect(createForm()).toBe(form); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + expect(store.load).not.toHaveBeenCalled(); + expect(store.loadReferences).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }, + ); + + it.each(['json', 'csv'] as const)( + 'downloads the exact retained %s blob once using the archive schema filename', + async (format) => { + await setupPage(); + button(format === 'json' ? 'Download retained JSON' : 'Download retained CSV').click(); + expect(store.download).toHaveBeenCalledExactlyOnceWith({ + exportId: archive.id, + format, + includeInternalCosts: false, + }); + const bytes = new Blob(['server-retained-bytes'], { + type: archive.files[format].mediaType, + }); + store.downloadCallState.set(successCallState(bytes)); + await fixture.whenStable(); + expect(trigger).toHaveBeenCalledExactlyOnceWith( + bytes, + `fireguard-prestations-${archive.id}-v${archive.schemaVersion}.${format}`, + ); + expect(store.clearDownload).toHaveBeenCalledTimes(1); + await fixture.whenStable(); + expect(trigger).toHaveBeenCalledTimes(1); + }, + ); + + it('requires the independent financial grant and disables duplicate retained byte requests', async () => { + store.detailCallState.set( + successCallState(maintenanceExportFixture({ includeInternalCosts: true })), + ); + await setupPage(); + expect(button('Download retained JSON').disabled).toBe(true); + expect(button('Append adjustment').disabled).toBe(true); + button('Download retained JSON').click(); + expect(store.download).not.toHaveBeenCalled(); + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + await fixture.whenStable(); + expect(button('Download retained JSON').disabled).toBe(false); + button('Download retained JSON').click(); + expect(store.download).toHaveBeenCalledExactlyOnceWith({ + exportId: archive.id, + format: 'json', + includeInternalCosts: true, + }); + store.downloadCallState.set(pendingCallState()); + await fixture.whenStable(); + expect(button('Download retained JSON').disabled).toBe(true); + expect(button('Download retained CSV').disabled).toBe(true); + button('Download retained CSV').click(); + expect(store.download).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/app/features/organization/features/maintenance-schedules/FEATURE.md b/src/app/features/organization/features/maintenance-schedules/FEATURE.md index 6d2c8e17c..c8246387a 100644 --- a/src/app/features/organization/features/maintenance-schedules/FEATURE.md +++ b/src/app/features/organization/features/maintenance-schedules/FEATURE.md @@ -63,8 +63,15 @@ engine and historical campaign linkage. Generation is bounded to one equipment operation. Ordinary submission recovers already created work; a new attempt is a separate action exposed only by `retryAllowed` from the API. Original due dates and attempt numbers remain visible. Creating work never counts -as completion. Accepted writes survive navigation without replacing another -organization's current page; stale queries and previews are cancelled or ignored. +as completion. In the page store, each organization visit owns its reads, preview and +command feedback. Accepted preparation, configuration, generation and migration writes +finish across organization changes; each command admits one write per organization at a +time, so the newly displayed organization can submit immediately. Returning to an +organization with an unfinished command keeps that action pending until settlement. +An earlier visit's result cannot replace current data, close an editor or emit a completion +event, even after returning to the same organization. Stale queries and previews are +cancelled or ignored. Only plans owned by the requested organization enter the list or +command feedback; an inconsistent response leaves existing rows and totals intact. Named `maintenance-schedules`, not the unqualified `maintenance` — an app-level feature already owns that name for the unrelated app-maintenance-mode @@ -116,6 +123,12 @@ retain their current rows. Override commands include the originating organizatio results retain it for intervention navigation. Accepted writes finish without cancellation, but responses from an earlier organization visit cannot update the current scope or its action feedback. +The independent `MaintenancePlansStore` applies the same visit boundary to its plan list, +engine read, equipment choices and saved-plan preview. Its preparation, configuration, +generation and migration commands keep separate admission and action states from historical +overrides and campaigns. Client request admission does not decide the server's active scheduling +engine or legacy migration outcome. + Primary service: `MaintenanceScheduleService` — extends `HydraApiService` but calls the **canonical** `/api/maintenance/schedules` and `/api/maintenance/campaigns` resources, not an organization-scoped path; the diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts index 6eb2c1b3a..aa50d9133 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts @@ -4,7 +4,7 @@ import { patchState, signalStore, type, withComputed, withMethods, withState } f import { removeAllEntities, setAllEntities, setEntity, withEntities } from '@ngrx/signals/entities'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { EMPTY, exhaustMap, pipe, switchMap } from 'rxjs'; +import { EMPTY, filter, finalize, mergeMap, pipe, switchMap } from 'rxjs'; import type { RequestOptions } from '@core/api'; import { errorCallState, @@ -21,7 +21,10 @@ import type { UpdateMaintenancePlanInput, } from '@features/organization/features/maintenance-schedules/models'; import { maintenancePlansStoreEvents } from './events/events'; -import type { MaintenancePlansState } from './models/state.interface'; +import type { + MaintenancePlanCommandCallStateKey, + MaintenancePlansState, +} from './models/state.interface'; /** * Constant INITIAL_STATE @@ -31,6 +34,7 @@ import type { MaintenancePlansState } from './models/state.interface'; */ const INITIAL_STATE: MaintenancePlansState = { organizationId: '', + scopeGeneration: 0, totalPlans: 0, listCallState: idleCallState(), engineCallState: idleCallState(), @@ -78,356 +82,500 @@ export const MaintenancePlansStore = signalStore( service = inject(MaintenancePlanService), equipmentService = inject(EquipmentService), dispatcher = inject(Dispatcher), - ) => ({ + ) => { /** - * Method setScope - * @method setScope + * Constant pendingWrites * * @description - * Clears the previous organization's view without cancelling its accepted commands. - * - * @access public - * @since unreleased + * Keeps accepted commands pending for their organization across later page visits. * - * @param {string} organizationId - Newly displayed organization. - * - * @returns {void} Clears local data only when scope changes. + * @type {Map>} */ - setScope(organizationId: string): void { - if (organizationId !== store.organizationId()) { - patchState(store, removeAllEntities({ collection: 'plan' }), { - ...INITIAL_STATE, - organizationId, - }); - } - }, + const pendingWrites = new Map>(); + /** - * Method load - * @method load + * Function isCurrentScope * * @description - * Replaces the server-filtered page; latest query wins. + * Results belong to the organization visit that accepted the request. * - * @access public - * @since unreleased + * @param {string} organizationId - Request's organization. + * @param {number} scopeGeneration - Request's page visit. * - * @type {RxMethod<{ organizationId: string; options?: RequestOptions }>} + * @returns {boolean} Whether the originating visit is still displayed. */ - load: rxMethod<{ organizationId: string; options?: RequestOptions }>( - pipe( - switchMap(({ organizationId, options }) => { - patchState(store, { listCallState: pendingCallState() }); - return service.list(organizationId, options).pipe( - tapResponse({ - next: (response) => { - if (store.organizationId() !== organizationId) return; - patchState(store, setAllEntities([...response.member], { collection: 'plan' }), { - totalPlans: response.totalItems, - listCallState: successCallState(null), - selectedPlan: - response.member.find((plan) => plan.id === store.selectedPlan()?.id) ?? - store.selectedPlan(), - }); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { listCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), - ), - ), + const isCurrentScope = (organizationId: string, scopeGeneration: number): boolean => + store.organizationId() === organizationId && store.scopeGeneration() === scopeGeneration; + /** - * Method loadEngine - * @method loadEngine + * Function acceptWrite * * @description - * Reads the active authority without inferring mode from equipment or plans. + * Admits one command of each kind for the displayed organization without cancelling other + * organizations' accepted work. * - * @access public - * @since unreleased + * @param {string} organizationId - Command's organization. + * @param {MaintenancePlanCommandCallStateKey} action - Command state. * - * @type {RxMethod} + * @returns {number | null} Originating visit, or null for an inadmissible command. */ - loadEngine: rxMethod( - pipe( - switchMap((organizationId) => { - patchState(store, { engineCallState: pendingCallState(store.engineCallState().data) }); - return service.engine(organizationId).pipe( - tapResponse({ - next: (result) => { - if (store.organizationId() === organizationId) - patchState(store, { engineCallState: successCallState(result) }); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { - engineCallState: errorCallState( - toStoreError(error), - store.engineCallState().data, - ), - }); - }, - }), - ); - }), - ), - ), + const acceptWrite = ( + organizationId: string, + action: MaintenancePlanCommandCallStateKey, + ): number | null => { + if ( + store.organizationId() !== organizationId || + pendingWrites.get(organizationId)?.has(action) + ) + return null; + const actions = + pendingWrites.get(organizationId) ?? new Set(); + actions.add(action); + pendingWrites.set(organizationId, actions); + patchState(store, { [action]: pendingCallState() }); + return store.scopeGeneration(); + }; + /** - * Method loadEquipment - * @method loadEquipment + * Function settleWrite * * @description - * Searches authorized equipment on browser demand, one bounded server page at a time. + * Releases command admission and clears pending feedback restored for a later visit. * - * @access public - * @since unreleased + * @param {string} organizationId - Command's organization. + * @param {number} scopeGeneration - Command's originating visit. + * @param {MaintenancePlanCommandCallStateKey} action - Command state. * - * @type {RxMethod<{ organizationId: string; search: string; page: number }>} + * @returns {void} Preserves current-visit success and errors. */ - loadEquipment: rxMethod<{ organizationId: string; search: string; page: number }>( - pipe( - switchMap(({ organizationId, search, page }) => { - patchState(store, { - equipmentCallState: pendingCallState(store.equipmentCallState().data), + const settleWrite = ( + organizationId: string, + scopeGeneration: number, + action: MaintenancePlanCommandCallStateKey, + ): void => { + const actions = pendingWrites.get(organizationId); + actions?.delete(action); + if (!actions?.size) pendingWrites.delete(organizationId); + if ( + store.organizationId() === organizationId && + (!isCurrentScope(organizationId, scopeGeneration) || store[action]().status === 'pending') + ) + patchState(store, { [action]: idleCallState() }); + }; + + return { + /** + * Method setScope + * @method setScope + * + * @description + * Clears the previous organization's view without cancelling its accepted commands. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Newly displayed organization. + * + * @returns {void} Clears local data only when scope changes. + */ + setScope(organizationId: string): void { + if (organizationId !== store.organizationId()) { + patchState(store, removeAllEntities({ collection: 'plan' }), { + ...INITIAL_STATE, + organizationId, + scopeGeneration: store.scopeGeneration() + 1, + createCallState: pendingWrites.get(organizationId)?.has('createCallState') + ? pendingCallState() + : idleCallState(), + updateCallState: pendingWrites.get(organizationId)?.has('updateCallState') + ? pendingCallState() + : idleCallState(), + generationCallState: pendingWrites.get(organizationId)?.has('generationCallState') + ? pendingCallState() + : idleCallState(), + migrationCallState: pendingWrites.get(organizationId)?.has('migrationCallState') + ? pendingCallState() + : idleCallState(), }); - return equipmentService.list(organizationId, { search, page, itemsPerPage: 30 }).pipe( - tapResponse({ - next: (result) => { - if (store.organizationId() === organizationId) - patchState(store, { - equipmentCallState: successCallState(result.member), - totalEquipment: result.totalItems, - }); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { - equipmentCallState: errorCallState( - toStoreError(error), - store.equipmentCallState().data, - ), - }); - }, - }), - ); - }), + } + }, + /** + * Method load + * @method load + * + * @description + * Replaces the server-filtered page; latest query wins. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; options?: RequestOptions }>} + */ + load: rxMethod<{ organizationId: string; options?: RequestOptions }>( + pipe( + filter(({ organizationId }) => store.organizationId() === organizationId), + switchMap(({ organizationId, options }) => { + const scopeGeneration = store.scopeGeneration(); + patchState(store, { listCallState: pendingCallState() }); + return service.list(organizationId, options).pipe( + tapResponse({ + next: (response) => { + if (!isCurrentScope(organizationId, scopeGeneration)) return; + if (response.member.some((plan) => plan.organizationId !== organizationId)) { + patchState(store, { listCallState: idleCallState() }); + return; + } + patchState( + store, + setAllEntities([...response.member], { collection: 'plan' }), + { + totalPlans: response.totalItems, + listCallState: successCallState(null), + selectedPlan: + response.member.find((plan) => plan.id === store.selectedPlan()?.id) ?? + store.selectedPlan(), + }, + ); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { listCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), ), - ), - /** - * Method create - * @method create - * - * @description - * Prepares a plan; concurrent submissions cannot cancel an accepted write. - * - * @access public - * @since unreleased - * - * @type {RxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>} - */ - create: rxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>( - pipe( - exhaustMap(({ organizationId, input }) => { - patchState(store, { createCallState: pendingCallState() }); - return service.create(organizationId, input).pipe( - tapResponse({ - next: (plan) => { - dispatcher.dispatch( - maintenancePlansStoreEvents.planPrepared({ organizationId, plan }), - ); - if (store.organizationId() === organizationId) + /** + * Method loadEngine + * @method loadEngine + * + * @description + * Reads the active authority without inferring mode from equipment or plans. + * + * @access public + * @since unreleased + * + * @type {RxMethod} + */ + loadEngine: rxMethod( + pipe( + filter((organizationId) => store.organizationId() === organizationId), + switchMap((organizationId) => { + const scopeGeneration = store.scopeGeneration(); + patchState(store, { + engineCallState: pendingCallState(store.engineCallState().data), + }); + return service.engine(organizationId).pipe( + tapResponse({ + next: (result) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { engineCallState: successCallState(result) }); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { + engineCallState: errorCallState( + toStoreError(error), + store.engineCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + /** + * Method loadEquipment + * @method loadEquipment + * + * @description + * Searches authorized equipment on browser demand, one bounded server page at a time. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; search: string; page: number }>} + */ + loadEquipment: rxMethod<{ organizationId: string; search: string; page: number }>( + pipe( + filter(({ organizationId }) => store.organizationId() === organizationId), + switchMap(({ organizationId, search, page }) => { + const scopeGeneration = store.scopeGeneration(); + patchState(store, { + equipmentCallState: pendingCallState(store.equipmentCallState().data), + }); + return equipmentService.list(organizationId, { search, page, itemsPerPage: 30 }).pipe( + tapResponse({ + next: (result) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { + equipmentCallState: successCallState(result.member), + totalEquipment: result.totalItems, + }); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { + equipmentCallState: errorCallState( + toStoreError(error), + store.equipmentCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + /** + * Method create + * @method create + * + * @description + * Prepares a plan; concurrent submissions cannot cancel an accepted write. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>} + */ + create: rxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>( + pipe( + mergeMap(({ organizationId, input }) => { + const scopeGeneration = acceptWrite(organizationId, 'createCallState'); + if (scopeGeneration === null) return EMPTY; + return service.create(organizationId, input).pipe( + tapResponse({ + next: (plan) => { + if ( + !isCurrentScope(organizationId, scopeGeneration) || + plan.organizationId !== organizationId + ) + return; patchState(store, { createCallState: successCallState(plan), selectedPlan: plan, }); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { createCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), + dispatcher.dispatch( + maintenancePlansStoreEvents.planPrepared({ organizationId, plan }), + ); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { createCallState: errorCallState(toStoreError(error)) }); + }, + }), + finalize(() => settleWrite(organizationId, scopeGeneration, 'createCallState')), + ); + }), + ), ), - ), - /** - * Method preview - * @method preview - * - * @description - * Selects a saved plan and fetches its backend dates; a stale preview cannot replace another - * selection. - * - * @access public - * @since unreleased - * - * @type {RxMethod} - */ - preview: rxMethod( - pipe( - switchMap((plan) => { - patchState(store, { - selectedPlan: plan, - previewCallState: plan ? pendingCallState() : idleCallState(), - }); - if (!plan) return EMPTY; - return service.preview(plan.organizationId, plan.id).pipe( - tapResponse({ - next: (result) => { - if ( - store.organizationId() === plan.organizationId && - store.selectedPlan()?.id === plan.id - ) - patchState(store, { previewCallState: successCallState(result) }); - }, - error: (error: unknown) => { - if ( - store.organizationId() === plan.organizationId && - store.selectedPlan()?.id === plan.id - ) - patchState(store, { previewCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), + /** + * Method preview + * @method preview + * + * @description + * Selects a saved plan and fetches its backend dates; a stale preview cannot replace + * another selection. + * + * @access public + * @since unreleased + * + * @type {RxMethod} + */ + preview: rxMethod( + pipe( + filter((plan) => !plan || store.organizationId() === plan.organizationId), + switchMap((plan) => { + const scopeGeneration = store.scopeGeneration(); + patchState(store, { + selectedPlan: plan, + previewCallState: plan ? pendingCallState() : idleCallState(), + }); + if (!plan) return EMPTY; + return service.preview(plan.organizationId, plan.id).pipe( + tapResponse({ + next: (result) => { + if ( + isCurrentScope(plan.organizationId, scopeGeneration) && + store.selectedPlan()?.id === plan.id + ) + patchState(store, { previewCallState: successCallState(result) }); + }, + error: (error: unknown) => { + if ( + isCurrentScope(plan.organizationId, scopeGeneration) && + store.selectedPlan()?.id === plan.id + ) + patchState(store, { previewCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), ), - ), - /** - * Method update - * @method update - * - * @description - * Applies explicit configuration or activation after preview review. - * - * @access public - * @since unreleased - * - * @type {RxMethod<{ - * organizationId: string; - * planId: string; - * input: UpdateMaintenancePlanInput; - * }>} - */ - update: rxMethod<{ - organizationId: string; - planId: string; - input: UpdateMaintenancePlanInput; - }>( - pipe( - exhaustMap(({ organizationId, planId, input }) => { - patchState(store, { updateCallState: pendingCallState() }); - return service.update(organizationId, planId, input).pipe( - tapResponse({ - next: (plan) => { - if (store.organizationId() === organizationId) { + /** + * Method update + * @method update + * + * @description + * Applies explicit configuration or activation after preview review. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ + * organizationId: string; + * planId: string; + * input: UpdateMaintenancePlanInput; + * }>} + */ + update: rxMethod<{ + organizationId: string; + planId: string; + input: UpdateMaintenancePlanInput; + }>( + pipe( + mergeMap(({ organizationId, planId, input }) => { + const scopeGeneration = acceptWrite(organizationId, 'updateCallState'); + if (scopeGeneration === null) return EMPTY; + return service.update(organizationId, planId, input).pipe( + tapResponse({ + next: (plan) => { + if ( + !isCurrentScope(organizationId, scopeGeneration) || + plan.organizationId !== organizationId || + plan.id !== planId + ) + return; patchState(store, setEntity(plan, { collection: 'plan' }), { updateCallState: successCallState(plan), selectedPlan: store.selectedPlan()?.id === planId ? plan : store.selectedPlan(), }); - } - dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { updateCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), + dispatcher.dispatch( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { updateCallState: errorCallState(toStoreError(error)) }); + }, + }), + finalize(() => settleWrite(organizationId, scopeGeneration, 'updateCallState')), + ); + }), + ), ), - ), - /** - * Method generate - * @method generate - * - * @description - * Creates or recovers a single operation's work; only an explicit retry requests another - * attempt. - * - * @access public - * @since unreleased - * - * @type {RxMethod<{ organizationId: string; planId: string; retry: boolean }>} - */ - generate: rxMethod<{ organizationId: string; planId: string; retry: boolean }>( - pipe( - exhaustMap(({ organizationId, planId, retry }) => { - patchState(store, { generationCallState: pendingCallState() }); - return service.generate(organizationId, planId, retry).pipe( - tapResponse({ - next: (result) => { - if (store.organizationId() === organizationId) + /** + * Method generate + * @method generate + * + * @description + * Creates or recovers a single operation's work; only an explicit retry requests another + * attempt. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; planId: string; retry: boolean }>} + */ + generate: rxMethod<{ organizationId: string; planId: string; retry: boolean }>( + pipe( + mergeMap(({ organizationId, planId, retry }) => { + const scopeGeneration = acceptWrite(organizationId, 'generationCallState'); + if (scopeGeneration === null) return EMPTY; + return service.generate(organizationId, planId, retry).pipe( + tapResponse({ + next: (result) => { + if (!isCurrentScope(organizationId, scopeGeneration)) return; patchState(store, { generationCallState: successCallState(result) }); - dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { generationCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), + dispatcher.dispatch( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { + generationCallState: errorCallState(toStoreError(error)), + }); + }, + }), + finalize(() => settleWrite(organizationId, scopeGeneration, 'generationCallState')), + ); + }), + ), ), - ), - /** - * Method migrate - * @method migrate - * - * @description - * Prepares legacy plans or explicitly activates their authority, preserving inline conflicts. - * - * @access public - * @since unreleased - * - * @type {RxMethod<{ organizationId: string; activate: boolean }>} - */ - migrate: rxMethod<{ organizationId: string; activate: boolean }>( - pipe( - exhaustMap(({ organizationId, activate }) => { - patchState(store, { migrationCallState: pendingCallState() }); - return ( - activate - ? service.activateEngine(organizationId) - : service.prepareLegacy(organizationId) - ).pipe( - tapResponse({ - next: (result) => { - if (store.organizationId() === organizationId) + /** + * Method migrate + * @method migrate + * + * @description + * Prepares legacy plans or explicitly activates their authority, preserving inline + * conflicts. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; activate: boolean }>} + */ + migrate: rxMethod<{ organizationId: string; activate: boolean }>( + pipe( + mergeMap(({ organizationId, activate }) => { + const scopeGeneration = acceptWrite(organizationId, 'migrationCallState'); + if (scopeGeneration === null) return EMPTY; + return ( + activate + ? service.activateEngine(organizationId) + : service.prepareLegacy(organizationId) + ).pipe( + tapResponse({ + next: (result) => { + if (!isCurrentScope(organizationId, scopeGeneration)) return; patchState(store, { migrationCallState: successCallState(result), engineCallState: successCallState(result), }); - dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); - }, - error: (error: unknown) => { - if (store.organizationId() === organizationId) - patchState(store, { migrationCallState: errorCallState(toStoreError(error)) }); - }, - }), - ); - }), + dispatcher.dispatch( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }, + error: (error: unknown) => { + if (isCurrentScope(organizationId, scopeGeneration)) + patchState(store, { + migrationCallState: errorCallState(toStoreError(error)), + }); + }, + }), + finalize(() => settleWrite(organizationId, scopeGeneration, 'migrationCallState')), + ); + }), + ), ), - ), - /** - * Method resetCreate - * @method resetCreate - * - * @description - * Starts a fresh preparation attempt after the operator explicitly reopens the form. - * - * @access public - * @since unreleased - * - * @returns {void} Clears the completed create action. - */ - resetCreate(): void { - if (store.createCallState().status !== 'pending') - patchState(store, { createCallState: idleCallState() }); - }, - }), + /** + * Method resetCreate + * @method resetCreate + * + * @description + * Clears preparation feedback when reopening the form while preserving accepted work as + * pending. + * + * @access public + * @since unreleased + * + * @returns {void} Clears the completed create action. + */ + resetCreate(): void { + patchState(store, { + createCallState: pendingWrites.get(store.organizationId())?.has('createCallState') + ? pendingCallState() + : idleCallState(), + }); + }, + }; + }, ), ); diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts index 0fd475c66..10e789a68 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts @@ -27,6 +27,18 @@ export interface MaintenancePlansState { * @type {string} */ readonly organizationId: string; + /** + * Property scopeGeneration + * @readonly + * + * @description + * Distinguishes later visits to the same organization from earlier accepted requests. + * + * @since unreleased + * + * @type {number} + */ + readonly scopeGeneration: number; /** * Property totalPlans * @readonly @@ -160,3 +172,17 @@ export interface MaintenancePlansState { */ readonly totalEquipment: number; } + +/** + * Type MaintenancePlanCommandCallStateKey + * + * @description + * Named action states whose accepted writes outlive their originating organization visit. + * + * @type MaintenancePlanCommandCallStateKey + */ +export type MaintenancePlanCommandCallStateKey = + | 'createCallState' + | 'updateCallState' + | 'generationCallState' + | 'migrationCallState'; diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts index ad081a893..95e3b9825 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts @@ -1,16 +1,24 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { Dispatcher } from '@ngrx/signals/events'; import { of, Subject, throwError } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; import { MaintenancePlanService } from '@features/organization/features/maintenance-schedules/data-access'; import type { + CreateMaintenancePlanInput, + MaintenanceEngineOutput, MaintenancePlanOutput, MaintenancePlanPreviewOutput, MaintenancePlanGenerationOutput, } from '@features/organization/features/maintenance-schedules/models'; +import { maintenancePlansStoreEvents } from '../events/events'; import { MaintenancePlansStore } from '../maintenance-plans.store'; +const problem = (detail: string): HttpErrorResponse => + new HttpErrorResponse({ status: 422, error: { title: 'Conflict', detail } }); + describe('MaintenancePlansStore', () => { let store: InstanceType; let service: { @@ -23,6 +31,28 @@ describe('MaintenancePlansStore', () => { prepareLegacy: ReturnType; activateEngine: ReturnType; }; + let equipmentService: { list: ReturnType }; + let dispatch: ReturnType; + const organizationId = 'c40bc4a3-c8c6-4177-aaef-fc406b78ee64'; + const otherOrganizationId = 'eaf9bc58-a98c-4552-8122-277689746b0f'; + const planId = '94b24e9b-9ebf-4462-8a2f-12b369c79481'; + const equipmentId = '34575c2d-5632-4f39-af26-d843028d6d77'; + const preparedPlan = (overrides: Partial = {}): MaintenancePlanOutput => ({ + ...plan, + '@id': `/api/organizations/${organizationId}/maintenance/plans/${planId}`, + id: planId, + organizationId, + equipmentId, + ...overrides, + }); + const input: CreateMaintenancePlanInput = { + equipmentId, + name: 'Monthly equipment service', + operationKind: 'maintenance', + interval: 'P1M', + anchorOn: '2027-01-31', + nextDueOn: '2027-02-28', + }; const plan: MaintenancePlanOutput = { '@id': '/api/organizations/org-1/maintenance/plans/plan-1', '@type': 'MaintenancePlan', @@ -67,15 +97,17 @@ describe('MaintenancePlansStore', () => { prepareLegacy: vi.fn().mockReturnValue(of({ mode: 'legacy', preparedCount: 3 })), activateEngine: vi.fn().mockReturnValue(of({ mode: 'plans', preparedCount: 3 })), }; + equipmentService = { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }; + dispatch = vi.fn(); TestBed.configureTestingModule({ providers: [ MaintenancePlansStore, { provide: MaintenancePlanService, useValue: service }, { provide: EquipmentService, - useValue: { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }, + useValue: equipmentService, }, - { provide: Dispatcher, useValue: { dispatch: vi.fn() } }, + { provide: Dispatcher, useValue: { dispatch } }, ], }); store = TestBed.inject(MaintenancePlansStore); @@ -151,4 +183,429 @@ describe('MaintenancePlansStore', () => { expect(store.engineCallState().data?.mode).toBe('legacy'); expect(store.migrationCallState().status).toBe('error'); }); + + it('prepares inactive work, prevents duplicates and announces the originating plan', () => { + const accepted = new Subject(); + const result = preparedPlan(); + service.create.mockReturnValue(accepted); + store.setScope(organizationId); + store.create({ organizationId, input }); + store.create({ organizationId, input }); + store.resetCreate(); + expect(service.create).toHaveBeenCalledExactlyOnceWith(organizationId, input); + expect(store.createCallState().status).toBe('pending'); + expect(store.commandPending()).toBe(true); + expect(store.selectedPlan()).toBeNull(); + expect(service.update).not.toHaveBeenCalled(); + accepted.next(result); + accepted.complete(); + expect(store.createCallState().status).toBe('success'); + expect(store.commandPending()).toBe(false); + expect(store.selectedPlan()).toEqual(result); + expect(store.selectedPlan()?.active).toBe(false); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + maintenancePlansStoreEvents.planPrepared({ organizationId, plan: result }), + ); + store.resetCreate(); + expect(store.createCallState().status).toBe('idle'); + expect(store.selectedPlan()).toEqual(result); + }); + + it('preserves preparation errors inline and allows a corrected submission', () => { + store.setScope(organizationId); + service.create.mockReturnValueOnce(throwError(() => problem('Choose a first deadline.'))); + store.create({ organizationId, input }); + expect(store.createCallState().status).toBe('error'); + expect(store.createCallState().error?.message).toBe('Choose a first deadline.'); + expect(store.commandPending()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + service.create.mockReturnValue(of(preparedPlan())); + store.create({ organizationId, input }); + expect(store.createCallState().status).toBe('success'); + }); + + it('lets accepted preparation finish after navigation without replacing the new organization', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + store.setScope(organizationId); + store.create({ organizationId, input }); + store.setScope(otherOrganizationId); + accepted.next(preparedPlan()); + accepted.complete(); + expect(store.createCallState().status).toBe('idle'); + expect(store.selectedPlan()).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('ignores rejected preparation from a previous organization', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + store.setScope(organizationId); + store.create({ organizationId, input }); + store.setScope(otherOrganizationId); + accepted.error(problem('Previous organization conflict.')); + expect(store.createCallState().status).toBe('idle'); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('retains the selected operation during refreshes and replaces it with the server version', () => { + const selected = preparedPlan(); + store.setScope(organizationId); + store.preview(selected); + service.list.mockReturnValueOnce(of({ ...collection, member: [], totalItems: 0 })); + store.load({ organizationId }); + expect(store.selectedPlan()).toEqual(selected); + const renamed = preparedPlan({ name: 'Server-confirmed name' }); + service.list.mockReturnValueOnce(of({ ...collection, member: [renamed], totalItems: 73 })); + store.load({ organizationId }); + store.setScope(organizationId); + expect(store.selectedPlan()).toEqual(renamed); + expect(store.planEntities()).toEqual([renamed]); + expect(store.totalPlans()).toBe(73); + }); + + it('retains the current list on rejection and ignores a stale list rejection', () => { + store.load({ organizationId: 'org-1' }); + service.list.mockReturnValueOnce(throwError(() => problem('Plan search unavailable.'))); + store.load({ organizationId: 'org-1' }); + expect(store.listCallState().status).toBe('error'); + expect(store.listCallState().error?.message).toBe('Plan search unavailable.'); + expect(store.planEntities()).toEqual([plan]); + const stale = new Subject>(); + service.list.mockReturnValue(stale); + store.load({ organizationId: 'org-1' }); + store.setScope(otherOrganizationId); + stale.error(problem('Old search rejection.')); + expect(store.listCallState().status).toBe('idle'); + expect(store.planEntities()).toEqual([]); + }); + + it.each(['success', 'error'] as const)('ignores an old engine %s after navigation', (outcome) => { + const stale = new Subject(); + service.engine.mockReturnValue(stale); + store.loadEngine('org-1'); + expect(store.engineCallState().status).toBe('pending'); + store.setScope(otherOrganizationId); + if (outcome === 'success') stale.next({ mode: 'plans', preparedCount: 3 }); + else stale.error(problem('Previous engine unavailable.')); + expect(store.engineCallState().status).toBe('idle'); + }); + + it('keeps confirmed engine data during reload and preserves it with a normalized read error', () => { + store.loadEngine('org-1'); + const refreshed = new Subject(); + service.engine.mockReturnValue(refreshed); + store.loadEngine('org-1'); + expect(store.engineCallState().status).toBe('pending'); + expect(store.engineCallState().data?.mode).toBe('legacy'); + refreshed.error(problem('Engine temporarily unavailable.')); + expect(store.engineCallState().status).toBe('error'); + expect(store.engineCallState().error?.message).toBe('Engine temporarily unavailable.'); + expect(store.engineCallState().data?.mode).toBe('legacy'); + }); + + it('loads bounded equipment choices and keeps the last authorized page on a recoverable failure', () => { + store.setScope(organizationId); + equipmentService.list.mockReturnValueOnce(of({ member: [], totalItems: 71 })); + store.loadEquipment({ organizationId, search: 'Extinguisher', page: 2 }); + expect(equipmentService.list).toHaveBeenCalledWith(organizationId, { + search: 'Extinguisher', + page: 2, + itemsPerPage: 30, + }); + expect(store.equipmentCallState().status).toBe('success'); + expect(store.totalEquipment()).toBe(71); + const read = new Subject>(); + equipmentService.list.mockReturnValue(read); + store.loadEquipment({ organizationId, search: 'Detector', page: 1 }); + expect(store.equipmentCallState().status).toBe('pending'); + expect(store.equipmentCallState().data).toEqual([]); + read.error(problem('Equipment unavailable.')); + expect(store.equipmentCallState().error?.message).toBe('Equipment unavailable.'); + expect(store.totalEquipment()).toBe(71); + }); + + it.each(['success', 'error'] as const)('ignores obsolete equipment option %s', (outcome) => { + const stale = new Subject>(); + equipmentService.list.mockReturnValue(stale); + store.loadEquipment({ organizationId: 'org-1', search: '', page: 1 }); + store.setScope(otherOrganizationId); + if (outcome === 'success') stale.next({ ...collection, member: [], totalItems: 91 }); + else stale.error(problem('Previous equipment rejection.')); + expect(store.equipmentCallState().status).toBe('idle'); + expect(store.totalEquipment()).toBe(0); + }); + + it('allows a rejected preview to retry and explicitly clears a closed selection', () => { + service.preview.mockReturnValueOnce(throwError(() => problem('Calendar unavailable.'))); + store.preview(plan); + expect(store.previewCallState().status).toBe('error'); + expect(store.previewCallState().error?.message).toBe('Calendar unavailable.'); + store.preview(plan); + expect(store.previewCallState().status).toBe('success'); + store.preview(null); + expect(store.selectedPlan()).toBeNull(); + expect(store.previewCallState().status).toBe('idle'); + expect(service.preview).toHaveBeenCalledTimes(2); + }); + + it.each(['success', 'error'] as const)( + 'ignores a selected plan preview %s after changing scope', + (outcome) => { + const stale = new Subject(); + service.preview.mockReturnValue(stale); + store.preview(plan); + store.setScope(otherOrganizationId); + if (outcome === 'success') stale.next({ dates: ['1900-01-01'] }); + else stale.error(problem('Previous calendar rejection.')); + expect(store.selectedPlan()).toBeNull(); + expect(store.previewCallState().status).toBe('idle'); + }, + ); + + it.each([true, false])('replaces only a confirmed plan when selection matches: %s', (matches) => { + const selected = preparedPlan({ + id: matches ? planId : '1bd9ce03-ecee-4989-8253-0cd3f3327a91', + }); + const changed = preparedPlan({ active: true }); + store.setScope(organizationId); + store.preview(selected); + service.update.mockReturnValue(of(changed)); + store.update({ organizationId, planId, input: { active: true } }); + expect(store.planEntities()).toEqual([changed]); + expect(store.selectedPlan()).toEqual(matches ? changed : selected); + expect(store.updateCallState().data).toEqual(changed); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }); + + it('ignores a stale update rejection without emitting a confirmed change', () => { + const accepted = new Subject(); + service.update.mockReturnValue(accepted); + store.setScope(organizationId); + store.update({ organizationId, planId, input: { name: 'Renamed' } }); + store.setScope(otherOrganizationId); + accepted.error(problem('Old update conflict.')); + expect(store.updateCallState().status).toBe('idle'); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('forwards ordinary recovery and explicit retry without marking the operation completed', () => { + store.setScope(organizationId); + store.preview(preparedPlan({ active: true })); + store.generate({ organizationId, planId, retry: false }); + expect(service.generate).toHaveBeenLastCalledWith(organizationId, planId, false); + store.generate({ organizationId, planId, retry: true }); + expect(service.generate).toHaveBeenLastCalledWith(organizationId, planId, true); + expect(store.selectedPlan()?.lastCompletedAt).toBeNull(); + expect(dispatch).toHaveBeenCalledWith( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }); + + it('exposes generation errors inline and permits a later retry', () => { + store.setScope(organizationId); + service.generate.mockReturnValueOnce(throwError(() => problem('Operation is not due.'))); + store.generate({ organizationId, planId, retry: false }); + expect(store.generationCallState().status).toBe('error'); + expect(store.generationCallState().error?.message).toBe('Operation is not due.'); + expect(store.commandPending()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + store.generate({ organizationId, planId, retry: false }); + expect(store.generationCallState().status).toBe('success'); + }); + + it.each(['success', 'error'] as const)( + 'preserves accepted generation and ignores its stale %s', + (outcome) => { + const accepted = new Subject(); + service.generate.mockReturnValue(accepted); + store.setScope(organizationId); + store.generate({ organizationId, planId, retry: false }); + store.setScope(otherOrganizationId); + if (outcome === 'success') accepted.next(work); + else accepted.error(problem('Old generation conflict.')); + expect(store.generationCallState().status).toBe('idle'); + expect(store.selectedPlan()).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it('switches authority only after explicit successful confirmation and blocks a duplicate command', () => { + const accepted = new Subject(); + service.activateEngine.mockReturnValue(accepted); + store.setScope(organizationId); + store.loadEngine(organizationId); + store.migrate({ organizationId, activate: true }); + store.migrate({ organizationId, activate: true }); + expect(store.migrationCallState().status).toBe('pending'); + expect(store.commandPending()).toBe(true); + expect(store.engineCallState().data?.mode).toBe('legacy'); + expect(service.activateEngine).toHaveBeenCalledExactlyOnceWith(organizationId); + expect(service.prepareLegacy).not.toHaveBeenCalled(); + accepted.next({ mode: 'plans', preparedCount: 3, conflicts: [] }); + accepted.complete(); + expect(store.engineCallState().data?.mode).toBe('plans'); + expect(store.migrationCallState().status).toBe('success'); + expect(store.commandPending()).toBe(false); + expect(dispatch).toHaveBeenCalledWith( + maintenancePlansStoreEvents.planChanged({ organizationId }), + ); + }); + + it.each(['success', 'error'] as const)( + 'ignores previous organization migration %s', + (outcome) => { + const accepted = new Subject(); + service.prepareLegacy.mockReturnValue(accepted); + store.setScope(organizationId); + store.migrate({ organizationId, activate: false }); + store.setScope(otherOrganizationId); + if (outcome === 'success') accepted.next({ mode: 'legacy', preparedCount: 3 }); + else accepted.error(problem('Old preparation conflict.')); + expect(store.engineCallState().status).toBe('idle'); + expect(store.migrationCallState().status).toBe('idle'); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it('does not restore an obsolete operation page after returning to its organization', () => { + const previousVisit = new Subject>(); + service.list.mockReturnValue(previousVisit); + store.setScope(organizationId); + store.load({ organizationId }); + store.setScope(otherOrganizationId); + store.setScope(organizationId); + previousVisit.next({ + ...collection, + member: [preparedPlan({ name: 'Obsolete visit' })], + totalItems: 1, + }); + expect(store.planEntities()).toEqual([]); + expect(store.totalPlans()).toBe(0); + expect(store.listCallState().status).toBe('idle'); + }); + + it('keeps a confirmed accepted update out of a later visit to the original organization', () => { + const previousVisit = new Subject(); + service.update.mockReturnValue(previousVisit); + store.setScope(organizationId); + store.update({ organizationId, planId, input: { name: 'Earlier visit change' } }); + store.setScope(otherOrganizationId); + store.setScope(organizationId); + expect(store.updateCallState().status).toBe('pending'); + expect(store.commandPending()).toBe(true); + store.update({ organizationId, planId, input: { name: 'Duplicate attempt' } }); + expect(service.update).toHaveBeenCalledExactlyOnceWith(organizationId, planId, { + name: 'Earlier visit change', + }); + previousVisit.next(preparedPlan({ name: 'Earlier visit change' })); + previousVisit.complete(); + expect(store.planEntities()).toEqual([]); + expect(store.updateCallState().status).toBe('idle'); + expect(store.commandPending()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('allows the new organization to save while an accepted write in another organization finishes', () => { + const previousOrganizationWrite = new Subject(); + const currentOrganizationWrite = new Subject(); + service.update + .mockReturnValueOnce(previousOrganizationWrite) + .mockReturnValueOnce(currentOrganizationWrite); + store.setScope(organizationId); + store.update({ organizationId, planId, input: { name: 'First organization change' } }); + store.setScope(otherOrganizationId); + const otherPlanId = 'da48c31b-efc2-4bc0-931a-093c480e9da1'; + store.update({ + organizationId: otherOrganizationId, + planId: otherPlanId, + input: { name: 'Current organization change' }, + }); + expect(service.update).toHaveBeenCalledTimes(2); + expect(service.update).toHaveBeenLastCalledWith(otherOrganizationId, otherPlanId, { + name: 'Current organization change', + }); + expect(store.updateCallState().status).toBe('pending'); + previousOrganizationWrite.next(preparedPlan({ name: 'First organization change' })); + previousOrganizationWrite.complete(); + expect(store.updateCallState().status).toBe('pending'); + expect(store.planEntities()).toEqual([]); + const confirmed = preparedPlan({ + id: otherPlanId, + organizationId: otherOrganizationId, + name: 'Current organization change', + }); + currentOrganizationWrite.next(confirmed); + currentOrganizationWrite.complete(); + expect(store.updateCallState().data).toEqual(confirmed); + expect(store.planEntities()).toEqual([confirmed]); + expect(store.commandPending()).toBe(false); + }); + + it('does not select or announce a prepared plan returned for another organization', () => { + store.setScope(organizationId); + service.create.mockReturnValue(of(preparedPlan({ organizationId: otherOrganizationId }))); + store.create({ organizationId, input }); + expect(store.createCallState().status).toBe('idle'); + expect(store.selectedPlan()).toBeNull(); + expect(store.planEntities()).toEqual([]); + expect(store.commandPending()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + service.create.mockReturnValue(of(preparedPlan())); + store.create({ organizationId, input }); + expect(store.createCallState().data).toEqual(preparedPlan()); + }); + + it.each([ + { organizationId: otherOrganizationId }, + { id: 'da48c31b-efc2-4bc0-931a-093c480e9da1' }, + ])('does not replace or announce a different plan from an update response: %o', (overrides) => { + const selected = preparedPlan(); + store.setScope(organizationId); + store.preview(selected); + service.update.mockReturnValue(of(preparedPlan(overrides))); + store.update({ organizationId, planId, input: { active: true } }); + expect(store.updateCallState().status).toBe('idle'); + expect(store.selectedPlan()).toEqual(selected); + expect(store.planEntities()).toEqual([]); + expect(store.commandPending()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('retains the authorized operation page when a response mixes organization ownership', () => { + store.setScope(organizationId); + service.list.mockReturnValueOnce( + of({ ...collection, member: [preparedPlan()], totalItems: 72 }), + ); + store.load({ organizationId }); + service.list.mockReturnValueOnce( + of({ + ...collection, + member: [ + preparedPlan({ name: 'Unexpected replacement' }), + preparedPlan({ + id: 'da48c31b-efc2-4bc0-931a-093c480e9da1', + organizationId: otherOrganizationId, + }), + ], + totalItems: 2, + }), + ); + store.load({ organizationId }); + expect(store.listCallState().status).toBe('idle'); + expect(store.planEntities()).toEqual([preparedPlan()]); + expect(store.totalPlans()).toBe(72); + }); + + it('does not send a preview request for an operation owned by another organization', () => { + store.setScope(organizationId); + store.preview(preparedPlan()); + const authorizedPreview = store.previewCallState(); + store.preview(preparedPlan({ organizationId: otherOrganizationId })); + expect(store.selectedPlan()).toEqual(preparedPlan()); + expect(store.previewCallState()).toEqual(authorizedPreview); + expect(service.preview).toHaveBeenCalledExactlyOnceWith(organizationId, planId); + }); }); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts index b99907e88..61ce6b0ff 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts @@ -1,15 +1,33 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; +import type { + MaintenancePlanOccurrenceOutput, + MaintenancePlanOutput, +} from '@features/organization/features/maintenance-schedules/models'; import { MaintenancePlanList } from '../maintenance-plan-list.component'; describe('MaintenancePlanList', () => { + let fixture: ComponentFixture; + const organizationId = '019bd877-9628-756a-a6ea-e0ea1b025711'; + const equipmentId = '019bd877-9628-756a-a6ea-e0ea1b025712'; + const interventionId = '019bd877-9628-756a-a6ea-e0ea1b025715'; + const occurrence: MaintenancePlanOccurrenceOutput = { + id: '019bd877-9628-756a-a6ea-e0ea1b025714', + dueAt: '2026-01-01T00:00:00Z', + attempt: 2, + interventionId, + status: 'open', + retryAllowed: false, + }; const plan: MaintenancePlanOutput = { - '@id': '/api/plans/plan-1', + '@id': + '/api/organizations/' + + organizationId + + '/maintenance/plans/019bd877-9628-756a-a6ea-e0ea1b025713', '@type': 'MaintenancePlan', - id: 'plan-1', - organizationId: 'org-1', - equipmentId: 'equipment-1', + id: '019bd877-9628-756a-a6ea-e0ea1b025713', + organizationId, + equipmentId, equipmentType: 'fire_extinguisher', name: 'Annual control', operationKind: 'control', @@ -18,73 +36,220 @@ describe('MaintenancePlanList', () => { anchorAt: null, nextDueAt: null, active: true, - openOccurrence: { - id: 'occurrence-1', - dueAt: '2026-01-01T00:00:00Z', - attempt: 2, - interventionId: 'work-1', - status: 'open', - retryAllowed: false, - }, + openOccurrence: occurrence, + }; + + /** + * Function root + * + * @description + * Provides the rendered list boundary for observable action and date assertions. + * + * @returns {HTMLElement} Current fixture host. + */ + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + + /** + * Function button + * + * @description + * Finds an action by its user-visible label. + * + * @param {string} label - Exact rendered action label. + * + * @returns {HTMLButtonElement | undefined} Matching action, absent when not offered. + */ + const button = (label: string): HTMLButtonElement | undefined => + [...root().querySelectorAll('button')].find( + (item) => item.textContent?.trim() === label, + ); + + /** + * Function click + * + * @description + * Activates an offered action and fails clearly when the expected control is absent. + * + * @param {string} label - Exact rendered action label. + * + * @returns {void} Dispatches a native click. + */ + const click = (label: string): void => { + const action = button(label); + if (!action) throw new Error('Missing button ' + label); + action.click(); }; - beforeEach(() => + + beforeEach(() => { TestBed.configureTestingModule({ imports: [MaintenancePlanList], providers: [provideRouter([])], - }), - ); - - it('retains original deadlines, attempts and historical cadence without claiming completion', () => { - const fixture = TestBed.createComponent(MaintenancePlanList); - fixture.componentRef.setInput('organizationId', 'org-1'); + }); + fixture = TestBed.createComponent(MaintenancePlanList); + fixture.componentRef.setInput('organizationId', organizationId); fixture.componentRef.setInput('regionalFormatting', { dateFormat: 'yyyy-MM-dd', timezone: 'UTC', }); fixture.componentRef.setInput('plans', [plan]); fixture.detectChanges(); - const root: HTMLElement = fixture.nativeElement as HTMLElement; - expect(root.textContent).toContain('Historical cadence retained'); - expect(root.textContent).toContain('Original deadline: 2026-01-01'); - expect(root.textContent).toContain('attempt 2'); - expect(root.querySelector('a')?.getAttribute('href')).toBe( - '/organizations/org-1/equipments/equipment-1', + }); + + it('retains server deadlines, attempts and historical cadence without claiming completion', () => { + expect(root().textContent).toContain('Historical cadence retained'); + expect(root().textContent).toContain('Control'); + expect(root().textContent).toContain('1 year'); + expect(root().textContent).toContain('First deadline to configure'); + expect(root().textContent).toContain('Original deadline: 2026-01-01'); + expect(root().textContent).toContain('attempt 2'); + expect(root().querySelector('a')?.getAttribute('href')).toBe( + '/organizations/' + organizationId + '/equipments/' + equipmentId, ); - expect( - root.querySelector('a[href="/organizations/org-1/interventions/work-1"]'), - ).not.toBeNull(); - expect(root.textContent).not.toContain('Completed'); + expect(root().querySelector('a[href$="/interventions/' + interventionId + '"]')).not.toBeNull(); + expect(root().textContent).not.toContain('Completed'); }); - it('offers a new attempt only when the server allows it and both actions are authorized', () => { - const fixture = TestBed.createComponent(MaintenancePlanList); - fixture.componentRef.setInput('organizationId', 'org-1'); - fixture.componentRef.setInput('regionalFormatting', { - dateFormat: 'yyyy-MM-dd', - timezone: 'UTC', - }); - fixture.componentRef.setInput('plans', [plan]); + it('emits the selected server plan from both public preview controls', () => { + const previews: MaintenancePlanOutput[] = []; + fixture.componentInstance.previewRequested.subscribe((value) => previews.push(value)); + click('Annual control'); + click('Preview dates'); + expect(previews).toEqual([plan, plan]); + }); + + it.each([ + { canManage: false, canGenerate: false, active: true, configure: false, generate: false }, + { canManage: true, canGenerate: false, active: true, configure: true, generate: false }, + { canManage: false, canGenerate: true, active: true, configure: false, generate: true }, + { canManage: true, canGenerate: true, active: true, configure: true, generate: true }, + { canManage: true, canGenerate: true, active: false, configure: true, generate: false }, + { canManage: false, canGenerate: true, active: false, configure: false, generate: false }, + ])( + 'offers actions for manage=$canManage generate=$canGenerate active=$active', + ({ canManage, canGenerate, active, configure, generate }) => { + fixture.componentRef.setInput('canManage', canManage); + fixture.componentRef.setInput('canGenerate', canGenerate); + fixture.componentRef.setInput('plans', [ + { ...plan, active, openOccurrence: { ...occurrence, retryAllowed: true } }, + ]); + fixture.detectChanges(); + expect(button('Configure') !== undefined).toBe(configure); + expect(button('Prepare intervention') !== undefined).toBe(generate); + expect(button('Start a new attempt') !== undefined).toBe(generate); + expect(button('Preview dates')).toBeDefined(); + expect(root().textContent?.includes('Prepared')).toBe(!active); + }, + ); + + it('emits configuration only for the selected server plan', () => { + const edits: MaintenancePlanOutput[] = []; + fixture.componentInstance.editRequested.subscribe((value) => edits.push(value)); + fixture.componentRef.setInput('canManage', true); + fixture.detectChanges(); + click('Configure'); + expect(edits).toEqual([plan]); + }); + + it.each([false, undefined])( + 'keeps ordinary generation separate when retryAllowed=%s', + (retryAllowed) => { + const generations: { planId: string; retry: boolean }[] = []; + fixture.componentInstance.generationRequested.subscribe((value) => generations.push(value)); + fixture.componentRef.setInput('canGenerate', true); + fixture.componentRef.setInput('plans', [ + { ...plan, openOccurrence: { ...occurrence, retryAllowed } }, + ]); + fixture.detectChanges(); + expect(button('Start a new attempt')).toBeUndefined(); + click('Prepare intervention'); + expect(generations).toEqual([{ planId: plan.id, retry: false }]); + expect(root().textContent).toContain('Original deadline: 2026-01-01'); + expect(root().textContent).toContain('attempt 2'); + }, + ); + + it('emits an explicit retry independently from ordinary recovery without altering the deadline', () => { + const generations: { planId: string; retry: boolean }[] = []; + fixture.componentInstance.generationRequested.subscribe((value) => generations.push(value)); + fixture.componentRef.setInput('canGenerate', true); + fixture.componentRef.setInput('plans', [ + { ...plan, openOccurrence: { ...occurrence, retryAllowed: true } }, + ]); + fixture.detectChanges(); + click('Prepare intervention'); + click('Start a new attempt'); + expect(generations).toEqual([ + { planId: plan.id, retry: false }, + { planId: plan.id, retry: true }, + ]); + expect(root().textContent).toContain('Original deadline: 2026-01-01'); + expect(root().textContent).toContain('attempt 2'); + }); + + it('locks accepted mutations while pending and permits the same actions after settling', () => { + const generations: { planId: string; retry: boolean }[] = []; + const edits: MaintenancePlanOutput[] = []; + fixture.componentInstance.generationRequested.subscribe((value) => generations.push(value)); + fixture.componentInstance.editRequested.subscribe((value) => edits.push(value)); fixture.componentRef.setInput('canManage', true); fixture.componentRef.setInput('canGenerate', true); + fixture.componentRef.setInput('plans', [ + { ...plan, openOccurrence: { ...occurrence, retryAllowed: true } }, + ]); + fixture.componentRef.setInput('pending', true); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Start a new attempt'); + for (const label of ['Configure', 'Prepare intervention', 'Start a new attempt']) { + expect(button(label)?.disabled).toBe(true); + click(label); + } + expect(edits).toEqual([]); + expect(generations).toEqual([]); + fixture.componentRef.setInput('pending', false); + fixture.detectChanges(); + click('Prepare intervention'); + expect(generations).toEqual([{ planId: plan.id, retry: false }]); + expect(button('Configure')?.disabled).toBe(false); + expect(button('Start a new attempt')?.disabled).toBe(false); + }); + + it('renders a prepared maintenance calendar without implying existing work', () => { fixture.componentRef.setInput('plans', [ - { ...plan, openOccurrence: { ...plan.openOccurrence, retryAllowed: true } }, + { + ...plan, + operationKind: 'maintenance', + cadenceMode: 'fixed', + nextDueAt: '2026-03-31T00:00:00+02:00', + active: false, + openOccurrence: null, + }, ]); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('Start a new attempt'); - fixture.componentRef.setInput('canGenerate', false); + expect(root().textContent).toContain('Maintenance'); + expect(root().textContent).toContain('Anchored calendar'); + expect(root().textContent).toContain('Prepared'); + expect(root().textContent).toContain('Next due: 2026-03-31'); + expect(root().textContent).not.toContain('Original deadline:'); + expect(root().textContent).not.toContain('Open existing intervention'); + }); + + it('omits an intervention link when the server has not linked work to the occurrence', () => { + fixture.componentRef.setInput('plans', [ + { ...plan, openOccurrence: { ...occurrence, interventionId: null } }, + ]); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).textContent).not.toContain( - 'Prepare intervention', - ); - expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Start a new attempt'); + expect(root().textContent).toContain('attempt 2'); + expect(root().textContent).not.toContain('Open existing intervention'); }); + + it('keeps an invalid historical interval visible as an unavailable duration', () => { + fixture.componentRef.setInput('plans', [{ ...plan, interval: 'unsupported' }]); + fixture.detectChanges(); + expect(root().textContent).toContain('Historical cadence retained · —'); + }); + it.each(['2026-03-31T00:00:00Z', '2026-03-31T00:00:00+02:00', '2026-03-31T00:00:00-07:00'])( 'keeps fixed calendar deadline %s on its server date in a negative viewer timezone', (date) => { - const fixture = TestBed.createComponent(MaintenancePlanList); - fixture.componentRef.setInput('organizationId', 'org-1'); fixture.componentRef.setInput('regionalFormatting', { dateFormat: 'yyyy-MM-dd', timezone: 'America/Los_Angeles', @@ -94,21 +259,17 @@ describe('MaintenancePlanList', () => { ...plan, cadenceMode: 'fixed', nextDueAt: date, - openOccurrence: { ...plan.openOccurrence, dueAt: date }, + openOccurrence: { ...occurrence, dueAt: date }, }, ]); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('Next due: 2026-03-31'); - expect((fixture.nativeElement as HTMLElement).textContent).toContain( - 'Original deadline: 2026-03-31', - ); - expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('2026-03-30'); + expect(root().textContent).toContain('Next due: 2026-03-31'); + expect(root().textContent).toContain('Original deadline: 2026-03-31'); + expect(root().textContent).not.toContain('2026-03-30'); }, ); it('continues to render historical deadlines as instants in the viewer organization timezone', () => { - const fixture = TestBed.createComponent(MaintenancePlanList); - fixture.componentRef.setInput('organizationId', 'org-1'); fixture.componentRef.setInput('regionalFormatting', { dateFormat: 'yyyy-MM-dd', timezone: 'America/Los_Angeles', @@ -117,6 +278,13 @@ describe('MaintenancePlanList', () => { { ...plan, nextDueAt: '2026-03-31T00:00:00+02:00', openOccurrence: null }, ]); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('Next due: 2026-03-30'); + expect(root().textContent).toContain('Next due: 2026-03-30'); + }); + + it('clears rendered rows when the parent provides an empty server page', () => { + fixture.componentRef.setInput('plans', []); + fixture.detectChanges(); + expect(root().querySelector('[data-testid^="maintenance-plan-"]')).toBeNull(); + expect(button('Preview dates')).toBeUndefined(); }); }); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts index 4a0f35d53..b8ef1572e 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts @@ -1,131 +1,452 @@ -import { TestBed } from '@angular/core/testing'; -import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrnCombobox } from '@spartan-ng/brain/combobox'; +import { BrnSelect } from '@spartan-ng/brain/select'; +import { errorCallState, pendingCallState, type StoreError } from '@core/request-state'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + CreateMaintenancePlanInput, + MaintenancePlanOutput, +} from '@features/organization/features/maintenance-schedules/models'; +import { HlmCombobox } from '@shared/ui/combobox'; +import { HlmSelect } from '@shared/ui/select'; import { MaintenancePlanForm } from '../maintenance-plan-form.component'; describe('MaintenancePlanForm', () => { - beforeEach(() => TestBed.configureTestingModule({ imports: [MaintenancePlanForm] })); - - it('requires an explicit calendar unit and equipment before preparation', () => { - const fixture = TestBed.createComponent(MaintenancePlanForm); - fixture.detectChanges(); - const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); - fixture.componentInstance['submit'](new Event('submit')); - expect(emitted).not.toHaveBeenCalled(); - expect(fixture.componentInstance['planForm'].unit().invalid()).toBe(true); - expect(fixture.componentInstance['planForm'].equipmentId().invalid()).toBe(true); + let fixture: ComponentFixture; + let submissions: CreateMaintenancePlanInput[]; + let cancellations: ReturnType void>>; + const rejected: StoreError = { + error: null, + message: 'Rejected', + code: 422, + retryable: false, + timestamp: Date.UTC(2026, 9, 9), + }; + const equipmentId = '019bd877-9628-756a-a6ea-e0ea1b025702'; + const organizationId = '019bd877-9628-756a-a6ea-e0ea1b025701'; + const plan: MaintenancePlanOutput = { + '@id': + '/api/organizations/' + + organizationId + + '/maintenance/plans/019bd877-9628-756a-a6ea-e0ea1b025703', + '@type': 'MaintenancePlan', + id: '019bd877-9628-756a-a6ea-e0ea1b025703', + organizationId, + equipmentId, + equipmentType: 'fire_extinguisher', + name: 'Monthly maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2027-02-28T00:00:00+01:00', + active: true, + openOccurrence: null, + }; + const equipment: EquipmentOutput = { + '@id': '/api/organizations/' + organizationId + '/equipment/' + equipmentId, + '@type': 'Equipment', + id: equipmentId, + organizationId, + name: 'Workshop extinguisher', + facilityId: null, + type: 'fire_extinguisher', + subType: null, + brand: null, + model: null, + serialNumber: null, + locationLabel: null, + facilityName: null, + status: 'operational', + installedAt: null, + commissionedAt: null, + tags: [], + maintenanceDueStatus: 'unscheduled', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + /** + * Function root + * + * @description + * Provides the rendered form boundary for assertions and native input events. + * + * @returns {HTMLElement} Current fixture host. + */ + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + + /** + * Function input + * + * @description + * Resolves a required native control by its public label target. + * + * @param {string} id - Native control identifier. + * + * @returns {HTMLInputElement} Rendered control. + */ + const input = (id: string): HTMLInputElement => { + const control = root().querySelector('#' + id); + if (!control) throw new Error('Missing input ' + id); + return control; + }; + + /** + * Function setValue + * + * @description + * Enters a value through the control's native input event. + * + * @param {string} id - Native control identifier. + * @param {string} value - User-entered value. + * + * @returns {Promise} Resolves once field state settles. + */ + const setValue = async (id: string, value: string): Promise => { + const control = input(id); + control.value = value; + control.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + + /** + * Function selectValue + * + * @description + * Selects an option through the installed control's public selection API. + * + * @param {number} index - Select position in DOM order. + * @param {string} value - Authorized option value. + * + * @returns {Promise} Resolves once the selection settles. + */ + const selectValue = async (index: number, value: string): Promise => { + const selected = fixture.debugElement.queryAll(By.directive(HlmSelect))[index]; + selected.injector.get>(BrnSelect).select(value); + await fixture.whenStable(); + }; + + /** + * Function prepareDraft + * + * @description + * Prepares a valid new operation through public controls without choosing a server deadline. + * + * @param {string} unit - Explicit calendar unit. + * + * @returns {Promise} Resolves once all draft fields settle. + */ + const prepareDraft = async (unit = 'M'): Promise => { + fixture.componentRef.setInput('equipment', [equipment]); + await fixture.whenStable(); + fixture.debugElement + .query(By.directive(HlmCombobox)) + .injector.get>(BrnCombobox) + .select(equipmentId); + await fixture.whenStable(); + await setValue('maintenance-plan-name', ' Service '); + await selectValue(0, 'maintenance'); + await setValue('maintenance-plan-every', '2'); + await selectValue(1, unit); + await setValue('maintenance-plan-anchor', '2026-01-31'); + }; + + /** + * Function submit + * + * @description + * Dispatches native submission so validation and emitted intent remain observable. + * + * @returns {Promise} Resolves once submission feedback settles. + */ + const submit = async (): Promise => { + const form = root().querySelector('form'); + if (!form) throw new Error('Missing maintenance plan form'); + form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + await fixture.whenStable(); + }; + + /** + * Function button + * + * @description + * Resolves an action by its user-visible label. + * + * @param {string} label - Rendered action label. + * + * @returns {HTMLButtonElement} Matching action. + */ + const button = (label: string): HTMLButtonElement => { + const action = [...root().querySelectorAll('button')].find( + (item) => item.textContent?.trim() === label, + ); + if (!action) throw new Error('Missing button ' + label); + return action; + }; + + beforeEach(async () => { + TestBed.configureTestingModule({ imports: [MaintenancePlanForm] }); + fixture = TestBed.createComponent(MaintenancePlanForm); + submissions = []; + cancellations = vi.fn<() => void>(); + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + fixture.componentInstance.cancelled.subscribe(cancellations); + await fixture.whenStable(); }); - it.each(['D', 'W', 'M', 'Y'])('emits calendar unit %s without computing future dates', (unit) => { - const fixture = TestBed.createComponent(MaintenancePlanForm); - fixture.detectChanges(); - fixture.componentInstance['model'].set({ - equipmentId: 'equipment-1', - name: 'Service', - operationKind: 'maintenance', - every: 2, - unit, - anchorDate: '2026-01-31', - firstDueDate: '', - }); - const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); - fixture.componentInstance['submit'](new Event('submit')); - expect(emitted).toHaveBeenCalledWith({ - equipmentId: 'equipment-1', - name: 'Service', + it('requires explicit equipment, unit, name and anchor before preparation', async () => { + await submit(); + expect(submissions).toEqual([]); + expect(root().textContent).toContain('Select an equipment.'); + expect(root().textContent).toContain('Choose a calendar unit.'); + expect(root().textContent).toContain('Enter an operation name.'); + expect(root().textContent).toContain('Choose an anchor date.'); + }); + + it.each(['D', 'W', 'M', 'Y'])( + 'emits explicitly selected unit %s and leaves future dates to the server', + async (unit) => { + await prepareDraft(unit); + await submit(); + expect(submissions).toEqual([ + { + equipmentId, + name: 'Service', + operationKind: 'maintenance', + interval: 'P2' + unit, + anchorOn: '2026-01-31', + }, + ]); + }, + ); + + it('includes an explicit first deadline without calculating calendar recurrence', async () => { + await prepareDraft(); + await setValue('maintenance-plan-first-due', '2026-03-20'); + await submit(); + expect(submissions).toEqual([ + { + equipmentId, + name: 'Service', + operationKind: 'maintenance', + interval: 'P2M', + anchorOn: '2026-01-31', + nextDueOn: '2026-03-20', + }, + ]); + }); + + it.each([ + ['0', 'Enter a positive interval.'], + ['1.5', 'Enter a whole number.'], + ])('refuses cadence %s with actionable validation', async (value, message) => { + await prepareDraft(); + await setValue('maintenance-plan-every', value); + await submit(); + expect(submissions).toEqual([]); + expect(root().textContent).toContain(message); + }); + + it('refuses an overlong name and permits correction within the same draft', async () => { + await prepareDraft(); + await setValue('maintenance-plan-name', 'A'.repeat(161)); + await submit(); + expect(submissions).toEqual([]); + expect(root().textContent).toContain('Use at most 160 characters.'); + await setValue('maintenance-plan-name', 'Corrected service'); + await submit(); + expect(submissions[0]?.name).toBe('Corrected service'); + }); + + it('preserves rejected values and permits a corrected resubmission', async () => { + await prepareDraft(); + await setValue('maintenance-plan-first-due', '2026-04-01'); + await submit(); + fixture.componentRef.setInput('serverError', rejected); + await fixture.whenStable(); + expect(input('maintenance-plan-name').value).toBe(' Service '); + expect(input('maintenance-plan-first-due').value).toBe('2026-04-01'); + expect(root().querySelector('[role="alert"]')?.textContent).toContain('Rejected'); + await setValue('maintenance-plan-name', 'Corrected annual check'); + fixture.componentRef.setInput('serverError', null); + await fixture.whenStable(); + await submit(); + expect(submissions[1]).toEqual({ + equipmentId, + name: 'Corrected annual check', operationKind: 'maintenance', - interval: `P2${unit}`, + interval: 'P2M', anchorOn: '2026-01-31', + nextDueOn: '2026-04-01', }); + expect(root().querySelector('[role="alert"]')).toBeNull(); }); - it('keeps the entered draft after server rejection and blocks pending resubmission', () => { - const fixture = TestBed.createComponent(MaintenancePlanForm); - fixture.detectChanges(); - fixture.componentInstance['model'].set({ - equipmentId: 'equipment-1', - name: 'Annual check', - operationKind: 'control', - every: 1, - unit: 'Y', - anchorDate: '2026-01-01', - firstDueDate: '2026-04-01', - }); - fixture.componentRef.setInput('serverError', { message: 'Rejected', code: 422 }); - fixture.detectChanges(); - expect(fixture.componentInstance['model']().firstDueDate).toBe('2026-04-01'); - expect(fixture.nativeElement.textContent).toContain('Rejected'); - fixture.componentRef.setInput('pending', true); - fixture.detectChanges(); - const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); - fixture.componentInstance['submit'](new Event('submit')); - expect(emitted).not.toHaveBeenCalled(); + it('renders a fallback save error when the server provides no message', async () => { + fixture.componentRef.setInput('serverError', { + ...rejected, + message: null, + } satisfies StoreError); + await fixture.whenStable(); + expect(root().querySelector('[role="alert"]')?.textContent).toContain( + 'The plan could not be saved.', + ); }); - it('rejects fractional cadence without silently rounding it', () => { - const fixture = TestBed.createComponent(MaintenancePlanForm); - fixture.detectChanges(); - fixture.componentInstance['model'].set({ - equipmentId: 'equipment-1', - name: 'Service', - operationKind: 'maintenance', - every: 1.5, - unit: 'M', - anchorDate: '2026-01-31', - firstDueDate: '', - }); - const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); - fixture.componentInstance['submit'](new Event('submit')); - expect(emitted).not.toHaveBeenCalled(); + it('locks pending controls and cancellation, then restores the same rejected draft', async () => { + await prepareDraft(); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + for (const id of [ + 'maintenance-plan-name', + 'maintenance-plan-every', + 'maintenance-plan-anchor', + 'maintenance-plan-first-due', + ]) { + expect(input(id).disabled).toBe(true); + } + expect(button('Cancel').disabled).toBe(true); + const save = button('Save and preview dates'); + expect(save.disabled).toBe(true); + expect(save.getAttribute('aria-busy')).toBe('true'); + button('Cancel').click(); + await submit(); + expect(cancellations).not.toHaveBeenCalled(); + expect(submissions).toEqual([]); + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('serverError', { + ...rejected, + message: 'Try again', + } satisfies StoreError); + await fixture.whenStable(); + expect(input('maintenance-plan-name').value).toBe(' Service '); + expect(input('maintenance-plan-name').disabled).toBe(false); + expect(button('Save and preview dates').getAttribute('aria-busy')).toBeNull(); + button('Cancel').click(); + expect(cancellations).toHaveBeenCalledOnce(); }); it.each(['2027-01-31T00:00:00+01:00', null])( - 'locks an open calendar and allows renaming with historical anchor %s', - (anchorAt) => { - const plan: MaintenancePlanOutput = { - '@id': '/api/plans/monthly-1', - '@type': 'MaintenancePlan', - id: 'monthly-1', - organizationId: 'org-1', - equipmentId: 'equipment-1', - equipmentType: 'fire_extinguisher', - name: 'Monthly maintenance', - operationKind: 'maintenance', - interval: 'P1M', + 'locks an open calendar and permits renaming with historical anchor %s', + async (anchorAt) => { + fixture.componentRef.setInput('initialPlan', { + ...plan, cadenceMode: anchorAt ? 'fixed' : 'legacy', - calendarTimezone: 'Europe/Paris', anchorAt, - nextDueAt: '2027-02-28T00:00:00+01:00', - active: true, openOccurrence: { - id: 'occurrence-1', + id: '019bd877-9628-756a-a6ea-e0ea1b025704', dueAt: '2027-02-28T00:00:00+01:00', attempt: 1, status: 'open', }, - }; - const fixture = TestBed.createComponent(MaintenancePlanForm); - fixture.componentRef.setInput('initialPlan', plan); - fixture.detectChanges(); - const form = fixture.componentInstance['planForm']; - expect(form.every().disabled()).toBe(true); - expect(form.unit().disabled()).toBe(true); - expect(form.anchorDate().disabled()).toBe(true); - expect(form.firstDueDate().disabled()).toBe(true); - expect(form.name().disabled()).toBe(false); - const host: HTMLElement = fixture.nativeElement as HTMLElement; - expect(host.querySelector('#maintenance-plan-anchor')?.disabled).toBe(true); + } satisfies MaintenancePlanOutput); + await fixture.whenStable(); + expect(input('maintenance-plan-every').disabled).toBe(true); + expect(root().querySelector('#maintenance-plan-unit')?.disabled).toBe( + true, + ); + expect(input('maintenance-plan-anchor').disabled).toBe(true); + expect(input('maintenance-plan-first-due').disabled).toBe(true); + expect(input('maintenance-plan-name').disabled).toBe(false); + expect(root().querySelector('#maintenance-plan-equipment')).toBeNull(); + expect(root().querySelector('#maintenance-plan-kind')).toBeNull(); expect( - host.querySelector('[data-testid="maintenance-plan-calendar-locked"]')?.textContent, + root().querySelector('[data-testid="maintenance-plan-calendar-locked"]')?.textContent, ).toContain('You can still change the operation name.'); - fixture.componentInstance['model'].update((draft) => ({ - ...draft, - name: 'Renamed maintenance', - })); - const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); - fixture.componentInstance['submit'](new Event('submit')); - expect(emitted).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Renamed maintenance' }), - ); + await setValue('maintenance-plan-name', 'Renamed maintenance'); + await submit(); + expect(submissions).toEqual([ + { + equipmentId, + name: 'Renamed maintenance', + operationKind: 'maintenance', + interval: 'P1M', + anchorOn: anchorAt ? '2027-01-31' : '', + nextDueOn: '2027-02-28', + }, + ]); }, ); + + it('adopts another selected plan and permits editing an unlocked calendar', async () => { + fixture.componentRef.setInput('initialPlan', plan); + await fixture.whenStable(); + await setValue('maintenance-plan-name', 'Unsaved rename'); + fixture.componentRef.setInput('initialPlan', { + ...plan, + id: '019bd877-9628-756a-a6ea-e0ea1b025705', + name: 'Weekly control', + operationKind: 'control', + interval: 'P3W', + anchorAt: '2027-03-02T00:00:00Z', + nextDueAt: null, + } satisfies MaintenancePlanOutput); + await fixture.whenStable(); + expect(input('maintenance-plan-name').value).toBe('Weekly control'); + expect(input('maintenance-plan-every').value).toBe('3'); + expect(input('maintenance-plan-anchor').value).toBe('2027-03-02'); + expect(input('maintenance-plan-first-due').value).toBe(''); + expect(input('maintenance-plan-anchor').disabled).toBe(false); + await setValue('maintenance-plan-anchor', '2027-03-03'); + await submit(); + expect(submissions[0]).toEqual({ + equipmentId, + name: 'Weekly control', + operationKind: 'control', + interval: 'P3W', + anchorOn: '2027-03-03', + }); + }); + + it('retains a selected equipment label across server option pages', async () => { + await prepareDraft(); + fixture.componentRef.setInput('equipment', []); + await fixture.whenStable(); + expect(input('maintenance-plan-equipment').value).toContain('Workshop extinguisher'); + await submit(); + expect(submissions[0]?.equipmentId).toBe(equipmentId); + }); + + it('exposes loading, retry and bounded server paging through public outputs', async () => { + const pages: number[] = []; + fixture.componentInstance.equipmentPageChanged.subscribe((value) => pages.push(value)); + fixture.componentRef.setInput('equipmentState', pendingCallState()); + fixture.componentRef.setInput('equipmentPageCount', 3); + await fixture.whenStable(); + expect(root().textContent).toContain('Loading equipment'); + expect(button('Previous equipment').disabled).toBe(true); + button('Next equipment').click(); + expect(pages).toEqual([2]); + fixture.componentRef.setInput('equipmentPage', 3); + fixture.componentRef.setInput( + 'equipmentState', + errorCallState({ + ...rejected, + code: 503, + message: 'Search unavailable', + retryable: true, + }), + ); + await fixture.whenStable(); + expect(button('Next equipment').disabled).toBe(true); + expect(root().textContent).toContain('Search unavailable'); + button('Previous equipment').click(); + button('Retry').click(); + expect(pages).toEqual([2, 2, 3]); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(button('Previous equipment').disabled).toBe(true); + expect(button('Next equipment').disabled).toBe(true); + }); + + it('forwards equipment search text for the owner to query', async () => { + const searches: string[] = []; + fixture.componentInstance.equipmentSearched.subscribe((value) => searches.push(value)); + await setValue('maintenance-plan-equipment', 'Workshop'); + expect(searches).toContain('Workshop'); + expect(submissions).toEqual([]); + }); }); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts index cae1d9a0d..9facb6017 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts @@ -1,10 +1,18 @@ import { signal, type WritableSignal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; import { Events } from '@ngrx/signals/events'; -import { EMPTY } from 'rxjs'; +import { Subject } from 'rxjs'; import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; -import { idleCallState, successCallState, type CallState } from '@core/request-state'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; import { THEME_PORT } from '@core/theme'; import { OrganizationPermissionService } from '@features/organization/access'; import type { EquipmentOutput } from '@features/organization/features/equipments/models'; @@ -13,11 +21,34 @@ import type { MaintenancePlanOutput, MaintenancePlanPreviewOutput, MaintenancePlanGenerationOutput, + CreateMaintenancePlanInput, } from '@features/organization/features/maintenance-schedules/models'; -import { MaintenancePlansStore } from '@features/organization/features/maintenance-schedules/state'; +import { + MaintenancePlansStore, + maintenancePlansStoreEvents, +} from '@features/organization/features/maintenance-schedules/state'; +import { MaintenancePlanList } from '@features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list'; +import { MaintenancePlanForm } from '@features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { CollectionSearchBox } from '@shared/collection-toolbar'; +import { HlmAlertDialog } from '@shared/ui/alert-dialog'; import { MaintenancePlansPage } from '../maintenance-plans-page.component'; +const button = (root: ParentNode, text: string): HTMLButtonElement => { + const candidate = [...root.querySelectorAll('button')].find( + (item) => item.textContent?.trim() === text, + ); + expect(candidate, `Expected the ${text} button`).toBeDefined(); + if (!candidate) throw new Error(`Missing ${text} button`); + return candidate; +}; + +const planForm = (fixture: ComponentFixture): MaintenancePlanForm => + fixture.debugElement.query(By.directive(MaintenancePlanForm)) + .componentInstance as MaintenancePlanForm; + describe('MaintenancePlansPage', () => { let load: ReturnType; let loadEngine: ReturnType; @@ -25,6 +56,58 @@ describe('MaintenancePlansPage', () => { let update: ReturnType; let selectedPlan: WritableSignal; let previewState: WritableSignal>; + let planEntities: WritableSignal; + let engineState: WritableSignal>; + let migrationState: WritableSignal>; + let createState: WritableSignal>; + let updateState: WritableSignal>; + let commandPending: WritableSignal; + let permissionGrants: WritableSignal; + let create: ReturnType; + let preview: ReturnType; + let generate: ReturnType; + let migrate: ReturnType; + let resetCreate: ReturnType; + let preparedEvents: Subject>; + let changedEvents: Subject>; + const organizationId = 'b113ff1a-18b5-4dc2-a8d5-c9e2d812076a'; + const planId = '9426e822-f8fb-4909-8463-caf4a1d9152e'; + const equipmentId = 'f0b97d51-f7e5-48d1-9797-941b6d2bc11c'; + const operation = (overrides: Partial = {}): MaintenancePlanOutput => ({ + '@id': `/api/organizations/${organizationId}/maintenance/plans/${planId}`, + '@type': 'MaintenancePlan', + id: planId, + organizationId, + equipmentId, + equipmentType: 'fire_extinguisher', + name: 'Monthly equipment maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2027-02-28T00:00:00+01:00', + active: false, + openOccurrence: null, + ...overrides, + }); + const render = async (): Promise> => { + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', organizationId); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture; + }; + const openEditor = ( + fixture: ComponentFixture, + plan: MaintenancePlanOutput, + ): void => { + planEntities.set([plan]); + fixture.detectChanges(); + button(fixture.nativeElement as HTMLElement, 'Configure').click(); + fixture.detectChanges(); + }; beforeEach(() => { vi.stubGlobal( 'ResizeObserver', @@ -38,8 +121,27 @@ describe('MaintenancePlansPage', () => { loadEngine = vi.fn(); loadEquipment = vi.fn(); update = vi.fn(); + create = vi.fn(); + preview = vi.fn(); + generate = vi.fn(); + migrate = vi.fn(); + resetCreate = vi.fn(); + preparedEvents = new Subject>(); + changedEvents = new Subject>(); selectedPlan = signal(null); previewState = signal>(idleCallState()); + planEntities = signal([]); + engineState = signal>( + successCallState({ mode: 'legacy', preparedCount: 0, conflicts: [] }), + ); + migrationState = signal>(idleCallState()); + createState = signal>(idleCallState()); + updateState = signal>(idleCallState()); + commandPending = signal(false); + permissionGrants = signal([ + ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ]); TestBed.configureTestingModule({ imports: [MaintenancePlansPage], providers: [ @@ -59,19 +161,21 @@ describe('MaintenancePlansPage', () => { loadEngine, loadEquipment, update, + create, + preview, + generate, + migrate, setScope: vi.fn(), - resetCreate: vi.fn(), - commandPending: signal(false), + resetCreate, + commandPending, totalPlans: signal(0), totalEquipment: signal(0), - planEntities: signal([]), + planEntities, listCallState: signal(successCallState(null)), - engineCallState: signal>( - successCallState({ mode: 'legacy', preparedCount: 0, conflicts: [] }), - ), - migrationCallState: signal>(idleCallState()), - createCallState: signal>(idleCallState()), - updateCallState: signal>(idleCallState()), + engineCallState: engineState, + migrationCallState: migrationState, + createCallState: createState, + updateCallState: updateState, equipmentCallState: signal>(idleCallState()), selectedPlan, previewCallState: previewState, @@ -79,12 +183,23 @@ describe('MaintenancePlansPage', () => { signal>(idleCallState()), }, }, - { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + { + provide: OrganizationPermissionService, + useValue: { + hasPermission: (permission: string) => permissionGrants().includes(permission), + }, + }, { provide: REGIONAL_FORMATTING_PORT, useValue: { regionalFormatting: signal({ dateFormat: 'yyyy-MM-dd', timezone: 'UTC' }) }, }, - { provide: Events, useValue: { on: () => EMPTY } }, + { + provide: Events, + useValue: { + on: (event: unknown) => + event === maintenancePlansStoreEvents.planPrepared ? preparedEvents : changedEvents, + }, + }, ], }); }); @@ -112,7 +227,7 @@ describe('MaintenancePlansPage', () => { fixture.componentRef.setInput('organizationId', 'org-1'); fixture.detectChanges(); await fixture.whenStable(); - fixture.componentInstance['openForm'](null); + button(fixture.nativeElement as HTMLElement, 'Prepare an operation plan').click(); expect(loadEquipment).toHaveBeenCalledWith({ organizationId: 'org-1', search: '', page: 1 }); }); @@ -142,8 +257,8 @@ describe('MaintenancePlansPage', () => { const fixture = TestBed.createComponent(MaintenancePlansPage); fixture.componentRef.setInput('organizationId', 'org-1'); fixture.detectChanges(); - fixture.componentInstance['openForm'](plan); - fixture.componentInstance['submitPlan']({ + openEditor(fixture, plan); + planForm(fixture).submitted.emit({ equipmentId: plan.equipmentId, operationKind: plan.operationKind, name: 'Renamed maintenance', @@ -180,8 +295,8 @@ describe('MaintenancePlansPage', () => { const fixture = TestBed.createComponent(MaintenancePlansPage); fixture.componentRef.setInput('organizationId', 'org-1'); fixture.detectChanges(); - fixture.componentInstance['openForm'](plan); - fixture.componentInstance['submitPlan']({ + openEditor(fixture, plan); + planForm(fixture).submitted.emit({ equipmentId: plan.equipmentId, operationKind: plan.operationKind, name: plan.name, @@ -195,7 +310,7 @@ describe('MaintenancePlansPage', () => { input: { name: plan.name, interval: 'P2M', nextDueOn: '2027-03-31' }, }); update.mockClear(); - fixture.componentInstance['openForm']({ + openEditor(fixture, { ...plan, openOccurrence: { id: 'occurrence-1', @@ -204,7 +319,7 @@ describe('MaintenancePlansPage', () => { status: 'open', }, }); - fixture.componentInstance['submitPlan']({ + planForm(fixture).submitted.emit({ equipmentId: plan.equipmentId, operationKind: plan.operationKind, name: 'Renamed maintenance', @@ -249,14 +364,363 @@ describe('MaintenancePlansPage', () => { const fixture = TestBed.createComponent(MaintenancePlansPage); fixture.componentRef.setInput('organizationId', 'org-1'); fixture.detectChanges(); - const preview: HTMLElement | null = (fixture.nativeElement as HTMLElement).querySelector( + const previewSection: HTMLElement | null = (fixture.nativeElement as HTMLElement).querySelector( '[data-testid="maintenance-plan-preview"]', ); - expect(preview?.textContent).toContain('2027-02-28'); - expect(preview?.textContent).toContain('2027-03-31'); - expect(preview?.textContent).toContain('2027-04-30'); - expect(preview?.textContent).not.toContain('2027-02-27'); - expect(preview?.textContent).not.toContain('2027-03-30'); - expect(preview?.textContent).not.toContain('2027-04-29'); + expect(previewSection?.textContent).toContain('2027-02-28'); + expect(previewSection?.textContent).toContain('2027-03-31'); + expect(previewSection?.textContent).toContain('2027-04-30'); + expect(previewSection?.textContent).not.toContain('2027-02-27'); + expect(previewSection?.textContent).not.toContain('2027-03-30'); + expect(previewSection?.textContent).not.toContain('2027-04-29'); + }); + + it('submits an inactive preparation and reviews its server calendar before offering activation', async () => { + const fixture = await render(); + const values: CreateMaintenancePlanInput = { + equipmentId, + name: 'Monthly equipment maintenance', + operationKind: 'maintenance', + interval: 'P1M', + anchorOn: '2027-01-31', + nextDueOn: '2027-02-28', + }; + button(fixture.nativeElement as HTMLElement, 'Prepare an operation plan').click(); + fixture.detectChanges(); + planForm(fixture).submitted.emit(values); + expect(create).toHaveBeenCalledExactlyOnceWith({ organizationId, input: values }); + expect(update).not.toHaveBeenCalled(); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).not.toBeNull(); + preparedEvents.next( + maintenancePlansStoreEvents.planPrepared({ organizationId, plan: operation() }), + ); + fixture.detectChanges(); + expect(preview).toHaveBeenCalledExactlyOnceWith(operation()); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).toBeNull(); + selectedPlan.set(operation()); + previewState.set(pendingCallState()); + fixture.detectChanges(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="maintenance-plan-activate"]', + ), + ).toBeNull(); + previewState.set(successCallState({ dates: ['2027-02-28', '2027-03-31', '2027-04-30'] })); + fixture.detectChanges(); + button(fixture.nativeElement as HTMLElement, 'Activate this operation').click(); + expect(update).toHaveBeenCalledExactlyOnceWith({ + organizationId, + planId, + input: { active: true }, + }); + }); + + it('keeps empty or unavailable server calendars from activating historical plans', async () => { + selectedPlan.set(operation({ cadenceMode: 'legacy', nextDueAt: null })); + previewState.set(errorCallState(toStoreError(new Error('Configure the first deadline.')))); + const fixture = await render(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-testid="maintenance-plan-activate"]')).toBeNull(); + expect(root.textContent).toContain('Configure the first deadline.'); + button( + root.querySelector('[data-testid="maintenance-plan-preview"]') as HTMLElement, + 'Retry', + ).click(); + expect(preview).toHaveBeenCalledWith(operation({ cadenceMode: 'legacy', nextDueAt: null })); + previewState.set(successCallState({ dates: [] })); + fixture.detectChanges(); + expect(button(root, 'Activate this operation').disabled).toBe(true); + button(root, 'Activate this operation').click(); + expect(update).not.toHaveBeenCalled(); + button(root, 'Close preview').click(); + expect(preview).toHaveBeenLastCalledWith(null); + }); + + it('pauses a reviewed active operation with only an activation command', async () => { + selectedPlan.set(operation({ active: true })); + previewState.set(successCallState({ dates: ['2027-02-28'] })); + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Pause this operation').click(); + expect(update).toHaveBeenCalledExactlyOnceWith({ + organizationId, + planId, + input: { active: false }, + }); + }); + + it.each([ + { + grants: [] as readonly string[], + engine: successCallState({ mode: 'plans', preparedCount: 1 }), + }, + { + grants: [ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE], + engine: successCallState({ mode: 'plans', preparedCount: 1 }), + }, + { + grants: [ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN], + engine: successCallState({ mode: 'plans', preparedCount: 1 }), + }, + { + grants: [ + ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ], + engine: successCallState({ mode: 'legacy', preparedCount: 1 }), + }, + { + grants: [ + ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ], + engine: pendingCallState({ mode: 'plans', preparedCount: 1 }), + }, + { + grants: [ + ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ], + engine: errorCallState( + toStoreError(new Error('Authority unavailable')), + { mode: 'plans', preparedCount: 1 }, + ), + }, + ])( + 'offers no generation without both grants and confirmed plans authority: %o', + async ({ grants, engine }) => { + permissionGrants.set(grants); + engineState.set(engine); + planEntities.set([ + operation({ + active: true, + openOccurrence: { + id: '5af04772-8fc9-4a2f-a64f-a1f4d8bbba49', + dueAt: '2027-02-28T00:00:00+01:00', + attempt: 2, + status: 'open', + retryAllowed: true, + }, + }), + ]); + const fixture = await render(); + const list = fixture.debugElement.query(By.directive(MaintenancePlanList)) + .componentInstance as MaintenancePlanList; + expect(list.canGenerate()).toBe(false); + const root = fixture.nativeElement as HTMLElement; + expect( + [...root.querySelectorAll('button')].some((candidate) => + candidate.textContent?.includes('Prepare intervention'), + ), + ).toBe(false); + expect( + [...root.querySelectorAll('button')].some((candidate) => + candidate.textContent?.includes('Start a new attempt'), + ), + ).toBe(false); + expect(generate).not.toHaveBeenCalled(); + if (!grants.includes(ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE)) { + expect(list.canManage()).toBe(false); + expect(root.querySelector('[data-testid="maintenance-prepare-legacy"]')).toBeNull(); + expect(root.querySelector('[data-testid="maintenance-switch-engine"]')).toBeNull(); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).toBeNull(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(migrate).not.toHaveBeenCalled(); + } + }, + ); + + it('forwards ordinary recovery separately from the explicit new-attempt action', async () => { + engineState.set(successCallState({ mode: 'plans', preparedCount: 1 })); + planEntities.set([ + operation({ + active: true, + openOccurrence: { + id: '5af04772-8fc9-4a2f-a64f-a1f4d8bbba49', + dueAt: '2027-02-28T00:00:00+01:00', + attempt: 2, + status: 'open', + retryAllowed: true, + }, + }), + ]); + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Prepare intervention').click(); + expect(generate).toHaveBeenLastCalledWith({ organizationId, planId, retry: false }); + button(fixture.nativeElement as HTMLElement, 'Start a new attempt').click(); + expect(generate).toHaveBeenLastCalledWith({ organizationId, planId, retry: true }); + commandPending.set(true); + fixture.detectChanges(); + expect(button(fixture.nativeElement as HTMLElement, 'Prepare intervention').disabled).toBe( + true, + ); + expect(button(fixture.nativeElement as HTMLElement, 'Start a new attempt').disabled).toBe(true); + button(fixture.nativeElement as HTMLElement, 'Start a new attempt').click(); + expect(generate).toHaveBeenCalledTimes(2); + }); + + it('retains an open editor and server rejection until a confirmed change closes it', async () => { + const fixture = await render(); + openEditor(fixture, operation()); + updateState.set( + errorCallState(toStoreError(new Error('The saved calendar conflicts with open work.'))), + ); + fixture.detectChanges(); + expect(planForm(fixture).serverError()?.message).toBe( + 'The saved calendar conflicts with open work.', + ); + commandPending.set(true); + fixture.detectChanges(); + expect(planForm(fixture).pending()).toBe(true); + expect(button(fixture.nativeElement as HTMLElement, 'Prepare an operation plan').disabled).toBe( + true, + ); + updateState.set(successCallState(operation({ name: 'Confirmed name' }))); + commandPending.set(false); + selectedPlan.set(operation({ name: 'Confirmed name' })); + changedEvents.next(maintenancePlansStoreEvents.planChanged({ organizationId })); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).toBeNull(); + expect(preview).toHaveBeenLastCalledWith(operation({ name: 'Confirmed name' })); + }); + + it('forwards equipment search and pagination from the editor and cancels without writing', async () => { + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Prepare an operation plan').click(); + fixture.detectChanges(); + const form = planForm(fixture); + form.equipmentSearched.emit('Extinguisher'); + expect(loadEquipment).toHaveBeenLastCalledWith({ + organizationId, + search: 'Extinguisher', + page: 1, + }); + form.equipmentPageChanged.emit(2); + expect(loadEquipment).toHaveBeenLastCalledWith({ + organizationId, + search: 'Extinguisher', + page: 2, + }); + form.cancelled.emit(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).toBeNull(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('ignores previous-organization command events after route context changes', async () => { + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Prepare an operation plan').click(); + fixture.detectChanges(); + fixture.componentRef.setInput('organizationId', 'e6c7893c-cbd6-416a-97a1-65f10af08d51'); + fixture.detectChanges(); + await fixture.whenStable(); + const readsAfterNavigation = load.mock.calls.length; + preparedEvents.next( + maintenancePlansStoreEvents.planPrepared({ organizationId, plan: operation() }), + ); + changedEvents.next(maintenancePlansStoreEvents.planChanged({ organizationId })); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.directive(MaintenancePlanForm))).toBeNull(); + expect(preview).not.toHaveBeenCalled(); + expect(load).toHaveBeenCalledTimes(readsAfterNavigation); + expect(loadEngine).toHaveBeenLastCalledWith('e6c7893c-cbd6-416a-97a1-65f10af08d51'); + }); + + it('prepares legacy history without switching authority', async () => { + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Prepare historical plans').click(); + expect(migrate).toHaveBeenCalledExactlyOnceWith({ organizationId, activate: false }); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Historical scheduling remains active', + ); + expect(document.querySelector('[data-testid="maintenance-confirm-switch"]')).toBeNull(); + }); + + it('keeps a rejected authority switch open for correction and closes after server confirmation', async () => { + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Review scheduling switch').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const confirmation = document.querySelector( + '[data-testid="maintenance-confirm-switch"]', + ); + expect(confirmation).not.toBeNull(); + confirmation?.click(); + expect(migrate).toHaveBeenCalledExactlyOnceWith({ organizationId, activate: true }); + migrationState.set( + errorCallState(toStoreError(new Error('Resolve ambiguous historical intervention links.'))), + ); + fixture.detectChanges(); + expect(document.querySelector('[role="alertdialog"]')?.textContent).toContain( + 'Resolve ambiguous historical intervention links.', + ); + engineState.set(successCallState({ mode: 'plans', preparedCount: 3 })); + migrationState.set(successCallState({ mode: 'plans', preparedCount: 3 })); + changedEvents.next(maintenancePlansStoreEvents.planChanged({ organizationId })); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.querySelector('[data-testid="maintenance-confirm-switch"]')).toBeNull(); + }); + + it('busy-locks the authority confirmation until its accepted command settles', async () => { + const fixture = await render(); + button(fixture.nativeElement as HTMLElement, 'Review scheduling switch').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const dialog = fixture.debugElement.query(By.directive(HlmAlertDialog)) + .componentInstance as HlmAlertDialog; + expect(dialog.state()).toBe('open'); + migrationState.set(pendingCallState()); + commandPending.set(true); + fixture.detectChanges(); + expect(dialog.disableClose()).toBe(true); + expect(button(document, 'Activate operation plans').disabled).toBe(true); + expect(button(document, 'Cancel').disabled).toBe(true); + dialog.stateChanged.emit('closed'); + fixture.detectChanges(); + expect(dialog.state()).toBe('open'); + migrationState.set(errorCallState(toStoreError(new Error('Resolve historical links.')))); + commandPending.set(false); + fixture.detectChanges(); + expect(dialog.disableClose()).toBe(false); + dialog.stateChanged.emit('closed'); + fixture.detectChanges(); + expect(dialog.state()).toBe('closed'); + }); + + it('forwards server search and pagination while operation tabs reset the list page', async () => { + const fixture = await render(); + const pagination = fixture.debugElement.query(By.directive(CollectionPagination)) + .componentInstance as CollectionPagination; + const search = fixture.debugElement.query(By.directive(CollectionSearchBox)) + .componentInstance as CollectionSearchBox; + pagination.pageChanged.emit(3); + fixture.detectChanges(); + await fixture.whenStable(); + expect(load).toHaveBeenLastCalledWith({ + organizationId, + options: { search: '', page: 3, itemsPerPage: 30, params: { operationKind: 'control' } }, + }); + button(fixture.nativeElement as HTMLElement, 'Maintenance').click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(load).toHaveBeenLastCalledWith({ + organizationId, + options: { search: '', page: 1, itemsPerPage: 30, params: { operationKind: 'maintenance' } }, + }); + pagination.pageSizeChanged.emit(60); + search.queryChanged.emit('Monthly'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(load).toHaveBeenLastCalledWith({ + organizationId, + options: { + search: 'Monthly', + page: 1, + itemsPerPage: 60, + params: { operationKind: 'maintenance' }, + }, + }); }); }); diff --git a/src/app/features/organization/features/procurement/FEATURE.md b/src/app/features/organization/features/procurement/FEATURE.md index e60fa1c64..9c78819b4 100644 --- a/src/app/features/organization/features/procurement/FEATURE.md +++ b/src/app/features/organization/features/procurement/FEATURE.md @@ -20,6 +20,8 @@ populate another organization. Conflicts preserve the user's draft and require e Supplier and purchase draft creations always send an operation UUID. Failed identical creations retain it; success or a changed draft after a confirmed rejection starts a fresh operation. A lost response retains the original creation payload until its result is recovered. +Clearing a purchase selection cancels its detail, receipt and return reads, so late responses +cannot restore the cleared context. Pending or uncertain physical commands still block clearing. A received quantity remains retained while individualization is blocked, and reserve equipment is created only after explicit confirmation. diff --git a/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts b/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts index 007cd8a25..e46716cdf 100644 --- a/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts +++ b/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts @@ -4,7 +4,7 @@ import { patchState, signalStore, type, withComputed, withMethods, withState } f import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { EMPTY, exhaustMap, pipe, switchMap, type Observable } from 'rxjs'; +import { EMPTY, Subject, exhaustMap, pipe, switchMap, takeUntil, type Observable } from 'rxjs'; import type { RequestOptions } from '@core/api/models'; import { errorCallState, @@ -112,6 +112,15 @@ export const ProcurementStore = signalStore( selectedOrder: computed(() => store.orderCallState().data), })), withMethods((store, service = inject(ProcurementService), dispatcher = inject(Dispatcher)) => { + /** + * Constant sourceCleared + * + * @description + * Cancels detail and history reads when their purchase selection is cleared, without + * cancelling an accepted physical command. + */ + const sourceCleared = new Subject(); + /** * Constant request * @@ -181,6 +190,7 @@ export const ProcurementStore = signalStore( */ setScope(organizationId: string): void { if (store.organizationId() === organizationId) return; + sourceCleared.next(); patchState( store, removeAllEntities({ collection: 'supplier' }), @@ -307,6 +317,7 @@ export const ProcurementStore = signalStore( const scopeVersion = store.scopeVersion(); patchState(store, { orderCallState: pendingCallState(store.selectedOrder()) }); return service.readOrder(organizationId, orderId).pipe( + takeUntil(sourceCleared), tapResponse({ next: (order) => { if (store.scopeVersion() === scopeVersion) @@ -336,6 +347,7 @@ export const ProcurementStore = signalStore( patchState(store, removeAllEntities({ collection: 'receipt' })); patchState(store, { receiptOrderId: orderId, receiptsCallState: pendingCallState() }); return service.listReceipts(organizationId, orderId, options).pipe( + takeUntil(sourceCleared), tapResponse({ next: (response) => { if (store.scopeVersion() === scopeVersion) @@ -372,6 +384,7 @@ export const ProcurementStore = signalStore( const scopeVersion = store.scopeVersion(); patchState(store, { receiptCallState: pendingCallState() }); return service.readReceipt(organizationId, receiptId).pipe( + takeUntil(sourceCleared), tapResponse({ next: (receipt) => { if (store.scopeVersion() === scopeVersion) @@ -401,6 +414,7 @@ export const ProcurementStore = signalStore( const scopeVersion = store.scopeVersion(); patchState(store, { returnCallState: pendingCallState() }); return service.readReturn(organizationId, returnId).pipe( + takeUntil(sourceCleared), tapResponse({ next: (returned) => { if (store.scopeVersion() === scopeVersion) @@ -435,6 +449,7 @@ export const ProcurementStore = signalStore( patchState(store, removeAllEntities({ collection: 'supplyReturn' })); patchState(store, { returnReceiptId: receiptId, returnsCallState: pendingCallState() }); return service.listReturns(organizationId, receiptId, options).pipe( + takeUntil(sourceCleared), tapResponse({ next: (response) => { if (store.scopeVersion() === scopeVersion) @@ -536,6 +551,7 @@ export const ProcurementStore = signalStore( */ clearOrder(): void { if (store.commandPending() || store.uncertainCommand()) return; + sourceCleared.next(); patchState( store, removeAllEntities({ collection: 'receipt' }), diff --git a/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts b/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts index 2b17a5080..271627dea 100644 --- a/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts +++ b/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { Dispatcher } from '@ngrx/signals/events'; import { of, Subject, throwError } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; +import { idleCallState } from '@core/request-state'; import { ProcurementService } from '@features/organization/features/procurement/data-access'; import type { SupplierOutput, @@ -13,6 +14,7 @@ import type { CreateSupplierInput, CreatePurchaseOrderInput, } from '@features/organization/features/procurement/models'; +import { procurementStoreEvents } from '../events/events'; import type { ProcurementCommand } from '../models/procurement-command.type'; import { ProcurementStore } from '../procurement.store'; const supplier: SupplierOutput = { @@ -100,6 +102,12 @@ describe('ProcurementStore', () => { reconcileReturn: ReturnType; createSupplier: ReturnType; createOrder: ReturnType; + readSupplier: ReturnType; + updateSupplier: ReturnType; + archiveSupplier: ReturnType; + updateOrder: ReturnType; + placeOrder: ReturnType; + cancelRemaining: ReturnType; }; let dispatch: ReturnType; const input: ReceivePurchaseOrderInput = { @@ -124,6 +132,12 @@ describe('ProcurementStore', () => { reconcileReturn: vi.fn().mockReturnValue(of(returned)), createSupplier: vi.fn().mockReturnValue(of(supplier)), createOrder: vi.fn().mockReturnValue(of(order)), + readSupplier: vi.fn().mockReturnValue(of(supplier)), + updateSupplier: vi.fn().mockReturnValue(of(supplier)), + archiveSupplier: vi.fn().mockReturnValue(of(supplier)), + updateOrder: vi.fn().mockReturnValue(of(order)), + placeOrder: vi.fn().mockReturnValue(of(order)), + cancelRemaining: vi.fn().mockReturnValue(of(order)), }; dispatch = vi.fn(); TestBed.configureTestingModule({ @@ -608,4 +622,355 @@ describe('ProcurementStore', () => { expect(store.returnCallState().status).toBe('idle'); expect(store.returnCallState().data).toBeNull(); }); + + const readCases = [ + { method: 'listSuppliers', action: 'loadSuppliers', state: 'suppliersCallState', source: {} }, + { + method: 'readSupplier', + action: 'readSupplier', + state: 'supplierCallState', + source: { supplierId: 'supplier' }, + }, + { method: 'listOrders', action: 'loadOrders', state: 'ordersCallState', source: {} }, + { + method: 'readOrder', + action: 'readOrder', + state: 'orderCallState', + source: { orderId: 'order' }, + }, + { + method: 'listReceipts', + action: 'loadReceipts', + state: 'receiptsCallState', + source: { orderId: 'order' }, + }, + { + method: 'readReceipt', + action: 'readReceipt', + state: 'receiptCallState', + source: { receiptId: 'receipt' }, + }, + { + method: 'listReturns', + action: 'loadReturns', + state: 'returnsCallState', + source: { receiptId: 'receipt' }, + }, + { + method: 'readReturn', + action: 'readReturn', + state: 'returnCallState', + source: { returnId: 'returned' }, + }, + ] as const; + + it.each(readCases)( + 'normalizes $action failures and permits an explicit retry', + ({ method, action, state, source }) => { + const response = new Subject(); + service[method].mockReturnValueOnce(response); + const request = { + organizationId: 'org', + supplierId: 'supplier', + orderId: 'order', + receiptId: 'receipt', + returnId: 'returned', + ...source, + }; + store[action](request); + expect(store[state]().status).toBe('pending'); + const error = new HttpErrorResponse({ + status: 403, + error: { detail: 'Procurement access denied.' }, + }); + response.error(error); + expect(store[state]()).toEqual({ + status: 'error', + data: null, + error: { + error, + code: 403, + message: 'Procurement access denied.', + retryable: false, + timestamp: expect.any(Number), + }, + }); + store[action](request); + expect(store[state]().status).toBe('success'); + expect(service[method]).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it.each(readCases)( + 'ignores foreign scope $action requests and late errors from a previous visit', + ({ method, action, state, source }) => { + const request = { + supplierId: 'supplier', + orderId: 'order', + receiptId: 'receipt', + returnId: 'returned', + ...source, + }; + store[action]({ organizationId: 'another', ...request }); + expect(service[method]).not.toHaveBeenCalled(); + expect(store[state]()).toEqual(idleCallState()); + const previous = new Subject(); + service[method].mockReturnValueOnce(previous); + store[action]({ organizationId: 'org', ...request }); + store.setScope('another'); + store.setScope('org'); + previous.error(new HttpErrorResponse({ status: 503 })); + expect(store[state]()).toEqual(idleCallState()); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it('reads an archived supplier label independently from the active supplier picker', () => { + const archived = { ...supplier, archivedAt: '2026-10-06T10:00:00Z', revision: 4 }; + const superseded = new Subject(); + service.listSuppliers.mockReturnValue(of({ member: [], totalItems: 0 })); + service.readSupplier.mockReturnValueOnce(superseded).mockReturnValueOnce(of(archived)); + store.loadSuppliers({ organizationId: 'org', options: { params: { archived: false } } }); + store.readSupplier({ organizationId: 'org', supplierId: 'other' }); + expect(store.supplierCallState().status).toBe('pending'); + store.readSupplier({ organizationId: 'org', supplierId: 'supplier' }); + superseded.next({ ...supplier, id: 'other' }); + expect(store.supplierEntities()).toEqual([]); + expect(store.supplierCallState()).toEqual({ status: 'success', data: archived, error: null }); + expect(service.readSupplier).toHaveBeenLastCalledWith('org', 'supplier'); + }); + + it('preserves the displayed order revision while its refresh is pending or fails', () => { + store.readOrder({ organizationId: 'org', orderId: order.id }); + const refresh = new Subject(); + service.readOrder.mockReturnValueOnce(refresh); + store.readOrder({ organizationId: 'org', orderId: order.id }); + expect(store.orderCallState()).toEqual({ status: 'pending', data: order, error: null }); + refresh.error( + new HttpErrorResponse({ status: 503, error: { detail: 'Source is unavailable.' } }), + ); + expect(store.selectedOrder()).toEqual(order); + expect(store.orderCallState().status).toBe('error'); + expect(store.orderCallState().error).toMatchObject({ + code: 503, + retryable: true, + message: 'Source is unavailable.', + }); + }); + + it('keeps server order totals while excluding a superseded page response', () => { + const stale = new Subject>(); + service.listOrders + .mockReturnValueOnce(stale) + .mockReturnValueOnce(of({ member: [order], totalItems: 61 })); + store.loadOrders({ organizationId: 'org', options: { page: 1 } }); + expect(store.ordersCallState().status).toBe('pending'); + store.loadOrders({ + organizationId: 'org', + options: { page: 3, params: { status: 'ordered' } }, + }); + stale.next({ '@id': '/orders', '@type': 'Collection', member: [], totalItems: 0 }); + expect(store.orderEntities()).toEqual([order]); + expect(store.totalOrders()).toBe(61); + expect(store.ordersCallState().status).toBe('success'); + expect(service.listOrders).toHaveBeenLastCalledWith('org', { + page: 3, + params: { status: 'ordered' }, + }); + }); + + it('clears the previous source receipts during a new order read and retains server pagination', () => { + store.loadReceipts({ organizationId: 'org', orderId: 'order' }); + const response = new Subject>(); + service.listReceipts.mockReturnValueOnce(response); + store.loadReceipts({ organizationId: 'org', orderId: 'next', options: { page: 2 } }); + expect(store.receiptEntities()).toEqual([]); + expect(store.receiptOrderId()).toBe('next'); + expect(store.receiptsCallState().status).toBe('pending'); + const nextReceipt = { ...receipt, id: 'next-receipt', orderId: 'next' }; + response.next({ + '@id': '/receipts', + '@type': 'Collection', + member: [nextReceipt], + totalItems: 31, + }); + response.complete(); + expect(store.receiptEntities()).toEqual([nextReceipt]); + expect(store.totalReceipts()).toBe(31); + expect(service.listReceipts).toHaveBeenLastCalledWith('org', 'next', { page: 2 }); + store.loadReceipts({ organizationId: 'org', orderId: 'next', options: { page: 1 } }); + expect(store.receiptEntities()).toEqual([receipt]); + }); + + const lifecycleCases = [ + { + method: 'updateSupplier', + command: { + kind: 'update_supplier', + organizationId: 'org', + supplier, + input: { name: 'Updated supplier', code: null, contacts: [{ name: 'Mary' }] }, + }, + args: [ + 'org', + supplier, + { name: 'Updated supplier', code: null, contacts: [{ name: 'Mary' }] }, + ], + result: { ...supplier, revision: 4 }, + }, + { + method: 'archiveSupplier', + command: { kind: 'archive_supplier', organizationId: 'org', supplier }, + args: ['org', supplier], + result: { ...supplier, revision: 4, archivedAt: '2026-10-06T10:00:00Z' }, + }, + { + method: 'updateOrder', + command: { + kind: 'update_order', + organizationId: 'org', + order: { ...order, status: 'draft' }, + input: { + name: 'Updated purchase', + supplierId: supplier.id, + lines: [{ kind: 'part', partId: 'part', quantity: '0.250000' }], + }, + }, + args: [ + 'org', + { ...order, status: 'draft' }, + { + name: 'Updated purchase', + supplierId: supplier.id, + lines: [{ kind: 'part', partId: 'part', quantity: '0.250000' }], + }, + ], + result: { ...order, revision: 8 }, + }, + { + method: 'placeOrder', + command: { kind: 'place_order', organizationId: 'org', order: { ...order, status: 'draft' } }, + args: ['org', { ...order, status: 'draft' }], + result: { ...order, revision: 8 }, + }, + { + method: 'cancelRemaining', + command: { kind: 'cancel_remaining', organizationId: 'org', order }, + args: ['org', order], + result: { ...order, status: 'cancelled', revision: 8 }, + }, + ] as const satisfies readonly { + method: keyof typeof service; + command: ProcurementCommand; + args: readonly unknown[]; + result: SupplierOutput | PurchaseOrderOutput; + }[]; + + it.each(lifecycleCases)( + 'sends the displayed revision for $method and dispatches only its confirmed result', + ({ method, command, args, result }) => { + const accepted = new Subject(); + service[method].mockReturnValueOnce(accepted); + store.execute(command); + expect(service[method]).toHaveBeenCalledExactlyOnceWith(...args); + expect(store.command()).toEqual(command); + expect(store.commandCallState()).toEqual({ status: 'pending', data: null, error: null }); + store.clearCommand(); + expect(store.command()).toEqual(command); + expect(dispatch).not.toHaveBeenCalled(); + accepted.next(result); + accepted.complete(); + expect(store.commandCallState()).toEqual({ status: 'success', data: result, error: null }); + expect(store.command()).toBeNull(); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + procurementStoreEvents.saved({ organizationId: 'org', command, result }), + ); + }, + ); + + it('retains the original update payload and revision after a retryable server failure', () => { + const command: ProcurementCommand = { + kind: 'update_supplier', + organizationId: 'org', + supplier, + input: { name: 'Reviewed name', contacts: [] }, + }; + service.updateSupplier.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 503 })), + ); + store.execute(command); + expect(store.uncertainCommand()).toBe(true); + expect(store.commandCallState().error?.code).toBe(503); + store.execute({ + ...command, + supplier: { ...supplier, revision: 9 }, + input: { name: 'Edited later', contacts: [] }, + }); + expect(service.updateSupplier).toHaveBeenLastCalledWith('org', supplier, command.input); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + procurementStoreEvents.saved({ organizationId: 'org', command, result: supplier }), + ); + }); + + it.each(['pending', 'uncertain'] as const)( + 'retains the physical source when clearing a %s write', + (state) => { + store.readOrder({ organizationId: 'org', orderId: 'order' }); + store.loadReceipts({ organizationId: 'org', orderId: 'order' }); + service.receiveOrder.mockReturnValueOnce( + state === 'pending' + ? new Subject() + : throwError(() => new HttpErrorResponse({ status: 0 })), + ); + store.execute({ kind: 'receive', organizationId: 'org', order, input }); + store.clearOrder(); + expect(store.selectedOrder()).toEqual(order); + expect(store.receiptEntities()).toEqual([receipt]); + expect(store.receiptOrderId()).toBe('order'); + }, + ); + + it('excludes a source response that arrives after its order selection was cleared', () => { + const response = new Subject(); + service.readOrder.mockReturnValueOnce(response); + store.readOrder({ organizationId: 'org', orderId: 'order' }); + store.clearOrder(); + response.next(order); + response.complete(); + expect(store.selectedOrder()).toBeNull(); + expect(store.orderCallState()).toEqual(idleCallState()); + }); + + it('retains a physical receipt UUID and payload for recovery after a retryable server failure', () => { + const accepted = new Subject(); + service.receiveOrder.mockReturnValueOnce(accepted); + const original: ProcurementCommand = { kind: 'receive', organizationId: 'org', order, input }; + store.execute(original); + accepted.error( + new HttpErrorResponse({ + status: 503, + error: { detail: 'Original organization service failed.' }, + }), + ); + expect(store.uncertainCommand()).toBe(true); + expect(store.command()).toEqual(original); + store.clearCommand(); + expect(store.command()).toEqual(original); + expect(dispatch).not.toHaveBeenCalled(); + store.execute({ + ...original, + order: { ...order, revision: 8 }, + input: { + ...input, + quantity: '1.000000', + clientOperationId: '98cc99ec-bee2-4871-8cb7-93f62e7d0e83', + }, + }); + expect(service.receiveOrder).toHaveBeenLastCalledWith('org', order, input); + expect(store.commandCallState().status).toBe('success'); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + procurementStoreEvents.saved({ organizationId: 'org', command: original, result: receipt }), + ); + }); }); diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts index caddc83b3..25e0d8c76 100644 --- a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts @@ -1,10 +1,12 @@ import { PLATFORM_ID, type WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import type { FieldTree } from '@angular/forms/signals'; +import type { EquipmentTypeOption } from '@features/organization/features/equipments'; import { InventoryService } from '@features/organization/features/inventory/data-access'; import type { PurchaseOrderLineOutput, PurchaseOrderOutput, + SupplierOutput, } from '@features/organization/features/procurement/models'; import type { PurchaseOrderDraft, @@ -81,6 +83,8 @@ describe('PurchaseOrderForm', () => { draft: WritableSignal; orderForm: FieldTree; submit: (event: Event) => void; + addLine: () => void; + removeLine: (index: number) => void; }; return { fixture, form, emitted }; } @@ -271,4 +275,129 @@ describe('PurchaseOrderForm', () => { lines: [{ id: 'line', kind: 'part', partId: 'part', quantity: '0.250000' }], }); }); + + it('creates stable local line UUIDs, reports dirtiness and submits only the retained line', async () => { + const { fixture, form, emitted } = await setup(); + const originalId = form.draft().lines[0].id; + const dirty = vi.fn(); + fixture.componentInstance.dirtyChanged.subscribe(dirty); + form.addLine(); + const nextId = form.draft().lines[1].id; + expect(nextId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); + expect(nextId).not.toBe(originalId); + form.removeLine(0); + form.draft.update((draft) => ({ + ...draft, + name: 'Parts', + supplierId: 'supplier', + lines: [{ ...draftLine, id: nextId }], + })); + await fixture.whenStable(); + expect(dirty).toHaveBeenLastCalledWith(true); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + name: 'Parts', + supplierId: 'supplier', + lines: [{ id: nextId, kind: 'part', partId: 'part', quantity: '0.250000' }], + }); + emitted.mockClear(); + form.removeLine(0); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect( + form.orderForm + .lines() + .errors() + .map(({ kind, message }) => ({ kind, message })), + ).toEqual([{ kind: 'lines', message: 'Add between 1 and 100 lines.' }]); + }); + + it('bounds added purchase lines and locks local line editing while a write is pending', async () => { + const { fixture, form } = await setup(); + const lines = Array.from({ length: 100 }, (_, index) => ({ + ...draftLine, + id: `line-${index}`, + })); + form.draft.set({ name: 'Parts', supplierId: 'supplier', lines }); + form.addLine(); + expect(form.draft().lines).toEqual(lines); + form.draft.set({ name: 'Parts', supplierId: 'supplier', lines: [draftLine] }); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + form.addLine(); + form.removeLine(0); + expect(form.draft().lines).toEqual([draftLine]); + }); + + it('retains archived supplier labels independently from currently assignable supplier pages', async () => { + const { fixture } = await setup(order); + const archived: SupplierOutput = { + '@id': '/suppliers/supplier', + '@type': 'Supplier', + id: 'supplier', + organizationId: 'org', + name: 'Fire supplies', + code: 'SUP-1', + contacts: [], + revision: 3, + archivedAt: '2026-10-06T10:00:00Z', + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, + }; + const active = { + ...archived, + id: 'active', + code: null, + name: 'Active supplier', + archivedAt: null, + }; + fixture.componentRef.setInput('selectedSupplier', archived); + fixture.componentRef.setInput('suppliers', [active, archived]); + await fixture.whenStable(); + expect(fixture.componentInstance['activeSuppliers']()).toEqual([active]); + expect(fixture.componentInstance['supplierLabelOf']('supplier')).toBe('SUP-1 — Fire supplies'); + expect(fixture.componentInstance['supplierLabelOf']('active')).toBe('Active supplier'); + const input: HTMLInputElement = fixture.nativeElement.querySelector( + '#procurement-order-supplier', + ); + expect(input.value).toBe('SUP-1 — Fire supplies'); + fixture.componentRef.setInput('selectedSupplier', null); + fixture.componentRef.setInput('suppliers', []); + await fixture.whenStable(); + expect(input.value).toBe('SUP-1 — Fire supplies'); + expect(fixture.componentInstance['supplierLabelOf']('unavailable')).toBe('unavailable'); + }); + + it('requests equipment catalogue options only for equipment lines and retains a historical selected type', async () => { + const { fixture, form } = await setup(order); + const requested = vi.fn(); + fixture.componentInstance.equipmentTypesRequested.subscribe(requested); + const base = { + '@id': '/equipment-types/type', + '@type': 'EquipmentType', + family: 'fire' as const, + revision: 2, + icon: 'lucideFireExtinguisher', + }; + const options = [ + { ...base, value: 'ACTIVE', label: 'Active extinguisher', archived: false }, + { ...base, value: 'OLD', label: 'Archived extinguisher', archived: true }, + { ...base, value: 'OTHER_OLD', label: 'Other archived type', archived: true }, + ] satisfies readonly EquipmentTypeOption[]; + fixture.componentRef.setInput('typeOptions', options); + await fixture.whenStable(); + expect(requested).not.toHaveBeenCalled(); + form.draft.update((draft) => ({ + ...draft, + lines: [{ ...draft.lines[0], kind: 'equipment_to_individualize', typeCode: 'OLD' }], + })); + await fixture.whenStable(); + expect(requested).toHaveBeenCalledOnce(); + expect(fixture.componentInstance['selectableTypes']('OLD')).toEqual(options.slice(0, 2)); + expect(fixture.componentInstance['typeLabelOf']('OLD')).toBe('Archived extinguisher'); + expect(fixture.componentInstance['typeLabelOf']('UNKNOWN')).toBe('UNKNOWN'); + expect(fixture.componentInstance['selectableTypes']('UNKNOWN')).toEqual([options[0]]); + }); }); diff --git a/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts index ecde19f47..2dab7a4ee 100644 --- a/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts +++ b/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts @@ -126,4 +126,33 @@ describe('SupplierForm', () => { form.submit(new Event('submit')); expect(emitted).not.toHaveBeenCalled(); }); + + it('reports contact edits as dirty and submits only the remaining internal contact', async () => { + const { fixture, form, emitted } = await setup(); + const dirty = vi.fn(); + fixture.componentInstance.dirtyChanged.subscribe(dirty); + form.draft.set(validDraft); + form.addContact(); + await fixture.whenStable(); + expect(form.draft().contacts).toHaveLength(2); + const contactNameInput: HTMLInputElement = + fixture.nativeElement.querySelector('#contact-name-1'); + contactNameInput.value = ' Jane '; + contactNameInput.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + form.removeContact(0); + await fixture.whenStable(); + expect(dirty).toHaveBeenLastCalledWith(true); + expect( + form.draft().contacts.map(({ name, email, phone, role }) => ({ name, email, phone, role })), + ).toEqual([{ name: ' Jane ', email: '', phone: '', role: '' }]); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + name: 'Fire supplies', + code: 'SUP-1', + email: null, + phone: '+33 1 23 45 67 89', + contacts: [{ name: 'Jane', email: null, phone: null, role: null }], + }); + }); }); diff --git a/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts b/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts index de53b6899..6ace18e61 100644 --- a/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts +++ b/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { Events } from '@ngrx/signals/events'; import { Subject } from 'rxjs'; import { + errorCallState, idleCallState, pendingCallState, successCallState, @@ -156,7 +157,11 @@ describe('ProcurementPage', () => { totalReceipts: signal(0), totalReturns: signal(0), }; - const catalog = { load: vi.fn(), clear: vi.fn(), options: signal([]) }; + const catalog = { + load: vi.fn(), + clear: vi.fn(), + options: signal([]), + }; const navigate = vi.fn().mockResolvedValue(true); const savedEvents = new Subject>(); TestBed.configureTestingModule({ @@ -608,4 +613,451 @@ describe('ProcurementPage', () => { expect(event.defaultPrevented).toBe(state !== 'clean'); }, ); + + it('creates a supplier and forwards later edits with the displayed source revision', async () => { + const { page, store } = await setup(); + const input = { + name: 'Reviewed supplier', + code: null, + contacts: [{ name: 'Mary', role: 'Parts' }], + }; + page['openSupplier'](null); + page['saveSupplier'](input); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'create_supplier', + organizationId: 'org', + input, + }); + store.execute.mockClear(); + page['openSupplier'](supplier); + store.supplierCallState.set(successCallState({ ...supplier, revision: 3 })); + page['saveSupplier'](input); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'update_supplier', + organizationId: 'org', + supplier, + input, + }); + expect(page['editorTitle']()).toBe('Supplier information'); + }); + + it('opens a new purchase with an active-only supplier picker and submits exact decimal strings', async () => { + const { fixture, page, store, catalog } = await setup(); + page['pickerSearch'].set('previous'); + page['pickerPage'].set(4); + page['openOrder'](null); + await fixture.whenStable(); + expect(page['pickerSearch']()).toBe(''); + expect(page['pickerPage']()).toBe(1); + expect(store.loadSuppliers).toHaveBeenLastCalledWith({ + organizationId: 'org', + options: { page: 1, itemsPerPage: 30, search: '', params: { archived: false } }, + }); + expect(catalog.load).not.toHaveBeenCalled(); + page['ensureEquipmentTypes'](); + page['ensureEquipmentTypes'](); + expect(catalog.load).toHaveBeenCalledExactlyOnceWith('org'); + const input = { + name: 'Stock replenishment', + supplierId: supplier.id, + lines: [{ kind: 'part' as const, partId: 'part', quantity: '0.000001' }], + }; + page['saveOrder'](input); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'create_order', + organizationId: 'org', + input, + }); + expect(page['editorTitle']()).toBe('Purchase draft'); + }); + + it('edits only a purchase draft and sends its original revision after a background refresh', async () => { + const { page, store } = await setup(); + page['openOrder'](order); + expect(page['editor']()).toBeNull(); + const draft = { ...order, status: 'draft' as const }; + page['openOrder'](draft); + store.selectedOrder.set({ ...draft, revision: 8 }); + const input = { + name: 'Reviewed draft', + supplierId: supplier.id, + lines: [ + { kind: 'part' as const, partId: 'part', quantity: '0.750000', unitCost: '0.000000' }, + ], + }; + page['saveOrder'](input); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'update_order', + organizationId: 'org', + order: draft, + input, + }); + }); + + it('records a physical return with the retained receipt revision and validated UUID', async () => { + const { page, store } = await setup(); + const input = { + quantity: returned.quantity, + reason: returned.reason, + clientOperationId: returned.clientOperationId, + }; + page['recordReturn'](input); + expect(store.execute).not.toHaveBeenCalled(); + page['openReturn'](receipt); + store.receiptCallState.set(successCallState({ ...receipt, revision: 4 })); + page['recordReturn'](input); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'return', + organizationId: 'org', + receipt, + input, + }); + expect(page['editorTitle']()).toBe('Record a physical return'); + }); + + it('stops write intents when procurement management is unavailable', async () => { + const { page, store } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_READ]); + page['openSupplier'](null); + page['openOrder'](null); + page['openReturn'](receipt); + page['saveSupplier']({ name: 'Unauthorized', contacts: [] }); + page['saveOrder']({ name: 'Unauthorized', supplierId: 'supplier', lines: [] }); + page['requestConfirmation']({ kind: 'archive_supplier', organizationId: 'org', supplier }); + expect(page['editor']()).toBeNull(); + expect(page['confirmation']()).toBeNull(); + expect(store.clearCommand).not.toHaveBeenCalled(); + expect(store.execute).not.toHaveBeenCalled(); + }); + + it.each(['pending', 'uncertain'] as const)( + 'blocks edits, confirmation changes and source navigation during a %s write', + async (state) => { + const { page, store, navigate } = await setup(); + page['openReturn'](receipt); + const command: ProcurementCommand = { + kind: 'archive_supplier', + organizationId: 'org', + supplier, + }; + page['requestConfirmation'](command); + if (state === 'pending') store.commandPending.set(true); + else store.uncertainCommand.set(true); + page['openSupplier'](null); + page['openOrder'](null); + page['saveSupplier']({ name: 'Locked', contacts: [] }); + page['saveOrder']({ name: 'Locked', supplierId: 'supplier', lines: [] }); + page['recordReturn']({ + quantity: returned.quantity, + reason: returned.reason, + clientOperationId: returned.clientOperationId, + }); + page['requestIndividualization'](receipt); + page['requestReconciliation'](returned); + page['confirmOperation'](); + page['confirmationStateChanged']('closed'); + page['sheetStateChanged']('closed'); + page['reviewLatest'](); + page['selectOrder'](order, true); + page['clearSelection'](); + expect(page['editor']()).toBe('return'); + expect(page['confirmation']()).toEqual(command); + expect(page['reviewRequested']()).toBe(false); + expect(store.execute).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }, + ); + + it('requires equipment and inventory authority before opening their operation confirmations', async () => { + const { page, store } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE]); + page['requestIndividualization'](receipt); + page['requestReconciliation'](returned); + expect(page['confirmation']()).toBeNull(); + expect(store.clearCommand).not.toHaveBeenCalled(); + page['confirmOperation'](); + page['retryCommand'](); + expect(store.execute).not.toHaveBeenCalled(); + }); + + it('keeps source quantities and lifecycle authoritative for equipment and return operations', async () => { + const { page, store, permissions } = await setup(); + const hardware = { + ...receipt, + kind: 'equipment_to_individualize' as const, + status: 'awaiting_individualization' as const, + }; + expect(page['canIndividualize'](hardware)).toBe(true); + expect(page['canIndividualize']({ ...hardware, equipmentIds: ['equipment'] })).toBe(false); + expect(page['canIndividualize']({ ...hardware, status: 'individualized' })).toBe(false); + store.receiptsCallState.set(pendingCallState()); + expect(page['canIndividualize'](hardware)).toBe(false); + expect(page['canReconcile']({ ...returned, status: 'confirmed' })).toBe(false); + store.returnsCallState.set(pendingCallState()); + expect(page['canReconcile'](returned)).toBe(false); + expect(page['canCancelOrder']({ ...order, status: 'received' })).toBe(false); + expect(page['canCancelOrder']({ ...order, status: 'cancelled' })).toBe(false); + expect(page['canCancelOrder']({ ...order, status: 'partial_received' })).toBe(true); + expect(page['canEditOrder']({ ...order, status: 'draft' })).toBe(true); + expect(page['canReceive']({ ...order, status: 'partial_received' }, stockLine)).toBe(true); + expect(page['canReceive'](order, { ...stockLine, remainingQuantity: 'invalid' })).toBe(false); + permissions.set(new Set([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE])); + expect(page['canReturn'](hardware)).toBe(true); + expect(page['canReturn']({ ...receipt, returnedQuantity: 'invalid' })).toBe(false); + }); + + it('merges source navigation into the route while resetting receipt history for a different order', async () => { + const { page, navigate } = await setup(); + page['receiptPage'].set(3); + page['returnsReceiptId'].set(receipt.id); + page['selectOrder'](order, true); + expect(page['receiptPage']()).toBe(1); + expect(page['returnsReceiptId']()).toBeNull(); + expect(navigate).toHaveBeenLastCalledWith([], { + queryParams: { orderId: 'order', section: 'receipts' }, + queryParamsHandling: 'merge', + }); + page['selectOrder'](order); + expect(navigate).toHaveBeenLastCalledWith([], { + queryParams: { orderId: 'order', section: 'orders' }, + queryParamsHandling: 'merge', + }); + page['clearSelection'](); + expect(navigate).toHaveBeenLastCalledWith([], { + queryParams: { orderId: null }, + queryParamsHandling: 'merge', + }); + page['selectSection']('suppliers'); + expect(navigate).toHaveBeenLastCalledWith([], { + queryParams: { section: 'suppliers' }, + queryParamsHandling: 'merge', + }); + }); + + it('clears the previous organization editor and reloads catalogue labels only when the new source needs them', async () => { + const { page, store, fixture, catalog } = await setup(); + const hardwareLine = { + ...stockLine, + kind: 'equipment_to_individualize' as const, + typeCode: 'EXTINGUISHER', + }; + store.selectedOrder.set({ ...order, lines: [hardwareLine] }); + await fixture.whenStable(); + expect(catalog.load).toHaveBeenCalledExactlyOnceWith('org'); + store.selectedOrder.set({ ...order, revision: 8, lines: [hardwareLine] }); + await fixture.whenStable(); + expect(catalog.load).toHaveBeenCalledOnce(); + page['openSupplier'](supplier); + page['dirty'].set(true); + page['requestConfirmation']({ kind: 'archive_supplier', organizationId: 'org', supplier }); + fixture.componentRef.setInput('organizationId', 'another'); + await fixture.whenStable(); + expect(store.setScope).toHaveBeenLastCalledWith('another'); + expect(page['editor']()).toBeNull(); + expect(page['confirmation']()).toBeNull(); + expect(page.hasUnsavedChanges()).toBe(false); + expect(catalog.clear).toHaveBeenCalledTimes(2); + expect(catalog.load).toHaveBeenLastCalledWith('another'); + }); + + it.each(['create_order', 'update_order'] as const)( + 'selects the confirmed $kind result and refreshes the current server view', + async (kind) => { + const { page, store, savedEvents, navigate } = await setup(); + const input = { name: 'Saved purchase', supplierId: supplier.id, lines: [] }; + const command: ProcurementCommand = + kind === 'create_order' + ? { kind, organizationId: 'org', input } + : { kind, organizationId: 'org', order, input }; + page['openOrder'](null); + page['dirty'].set(true); + store.loadOrders.mockClear(); + savedEvents.next( + procurementStoreEvents.saved({ + organizationId: 'org', + command, + result: { ...order, id: 'saved-order' }, + }), + ); + expect(navigate).toHaveBeenCalledExactlyOnceWith([], { + queryParams: { orderId: 'saved-order', section: 'orders' }, + queryParamsHandling: 'merge', + }); + expect(page['editor']()).toBeNull(); + expect(page.hasUnsavedChanges()).toBe(false); + expect(store.loadOrders).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + options: { page: 1, itemsPerPage: 30, params: {} }, + }); + }, + ); + + it('renders historical equipment and stock references without inventing labels or units', async () => { + const { page, catalog, store } = await setup(); + catalog.options.set([{ value: 'OLD_TYPE', label: 'Archived extinguisher' }]); + const hardware = { + ...stockLine, + kind: 'equipment_to_individualize' as const, + typeCode: 'OLD_TYPE', + identityTemplate: { name: 'Reserve unit' }, + }; + expect(page['lineTitle'](hardware)).toBe('Reserve unit · Archived extinguisher'); + expect(page['lineTitle']({ ...hardware, identityTemplate: {}, typeCode: 'UNKNOWN_TYPE' })).toBe( + 'UNKNOWN_TYPE', + ); + expect(page['lineTitle']({ ...hardware, identityTemplate: { name: 42 }, typeCode: null })).toBe( + '', + ); + expect(page['lineUnit'](hardware)).toBe('units'); + expect(page['lineTitle'](stockLine)).toBe('SEAL — Seal'); + expect(page['lineTitle']({ ...stockLine, partCode: null })).toBe('Seal'); + expect(page['lineTitle']({ ...stockLine, partLabel: null })).toBe( + 'Stock article label unavailable', + ); + expect(page['lineUnit']({ ...stockLine, partUnit: null })).toBe('unit unavailable'); + expect(page['receiptUnit'](receipt)).toBe('piece'); + store.selectedOrder.set(null); + expect(page['receiptUnit'](receipt)).toBe(''); + expect(page['blockedLabel']('insufficient_stock')).toBe( + 'The physical return is retained. Inventory needs reconciliation before its movement can be confirmed.', + ); + expect(page['blockedLabel']('new_server_reason')).toBe('new server reason'); + }); + + it('exposes definitive revision conflicts for explicit review and retains the normalized error message', async () => { + const { page, store } = await setup(); + expect(page['commandError']()).toBeNull(); + expect(page['revisionConflict']()).toBe(false); + for (const code of [409, 412, 503]) { + store.commandCallState.set( + errorCallState({ + error: null, + code, + message: 'Review displayed revision.', + retryable: code === 503, + timestamp: 0, + }), + ); + expect(page['commandError']()).toBe('Review displayed revision.'); + expect(page['revisionConflict']()).toBe(code !== 503); + } + }); + + it('requests a discard for a dirty sheet and dismisses clean sheets and unaccepted confirmations', async () => { + const { page } = await setup(); + page['openSupplier'](supplier); + page['dirty'].set(true); + page['sheetStateChanged']('closed'); + expect(page['discardState']()).toBe('open'); + expect(page['editor']()).toBe('supplier'); + page['resolveDiscard'](false); + page['dirty'].set(false); + page['sheetStateChanged']('closed'); + expect(page['editor']()).toBeNull(); + page['requestConfirmation']({ kind: 'archive_supplier', organizationId: 'org', supplier }); + page['confirmationStateChanged']('open'); + expect(page['confirmation']()?.kind).toBe('archive_supplier'); + page['confirmationStateChanged']('closed'); + expect(page['confirmation']()).toBeNull(); + }); + + it.each([ + { + kind: 'archive_supplier', + title: 'Archive this supplier?', + description: + 'The supplier remains in existing purchases and history. New orders require an active supplier.', + }, + { + kind: 'place_order', + title: 'Place this purchase order?', + description: 'The draft becomes an order. Physical deliveries will be recorded separately.', + }, + { + kind: 'cancel_remaining', + title: 'Cancel undelivered quantities?', + description: + 'Only quantities still awaiting delivery are cancelled. Previous receipts and returns remain retained.', + }, + { + kind: 'individualize', + title: 'Create reserve equipment for this receipt?', + description: + 'The server creates a bounded set of individually identified reserve equipment. A quota or unavailable type keeps the physical receipt awaiting action.', + }, + { + kind: 'reconcile', + title: 'Reconcile this physical return?', + description: + 'The server tries to confirm the inventory reversal. The original physical quantity and reason stay unchanged if stock is still insufficient.', + }, + ] as const)( + 'requires an explicit confirmation explaining the $kind operation', + async ({ kind, title, description }) => { + const { page, store } = await setup(); + const hardware = { + ...receipt, + kind: 'equipment_to_individualize' as const, + status: 'awaiting_individualization' as const, + }; + if (kind === 'individualize') page['requestIndividualization'](hardware); + else if (kind === 'reconcile') page['requestReconciliation'](returned); + else + page['requestConfirmation']( + kind === 'archive_supplier' + ? { kind, organizationId: 'org', supplier } + : { kind, organizationId: 'org', order }, + ); + const command = page['confirmation'](); + expect(command?.kind).toBe(kind); + if (command?.kind === 'individualize') { + expect(command.receipt).toEqual(hardware); + expect(command.input.clientOperationId).toMatch( + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/, + ); + } + if (command?.kind === 'reconcile') { + expect(command.returned).toEqual(returned); + expect(command.input.clientOperationId).toMatch( + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/, + ); + } + expect(page['confirmationTitle']()).toBe(title); + expect(page['confirmationDescription']()).toBe(description); + expect(store.execute).not.toHaveBeenCalled(); + page['confirmOperation'](); + expect(store.execute).toHaveBeenCalledExactlyOnceWith(command); + }, + ); + + it('forwards server filters and pagination to orders and expanded receipt history on refresh', async () => { + const { page, store, fixture } = await setup(); + page['orderStatus'].set('partial_received'); + page['orderPage'].set(3); + page['receiptPage'].set(2); + page['showReturns'](receipt); + page['returnPage'].set(2); + store.totalSuppliers.set(0); + store.totalOrders.set(61); + store.totalReceipts.set(31); + store.totalReturns.set(60); + await fixture.whenStable(); + expect(page['supplierPageCount']()).toBe(1); + expect(page['orderPageCount']()).toBe(3); + expect(page['receiptPageCount']()).toBe(2); + expect(page['returnPageCount']()).toBe(2); + page['reload'](); + expect(store.loadOrders).toHaveBeenLastCalledWith({ + organizationId: 'org', + options: { page: 3, itemsPerPage: 30, params: { status: 'partial_received' } }, + }); + expect(store.loadReceipts).toHaveBeenLastCalledWith({ + organizationId: 'org', + orderId: 'order', + options: { page: 2, itemsPerPage: 30 }, + }); + expect(store.loadReturns).toHaveBeenLastCalledWith({ + organizationId: 'org', + receiptId: receipt.id, + options: { page: 2, itemsPerPage: 30 }, + }); + }); }); From 1a8f4991251120ad7bf88c445391d4634069430c Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:07:57 +0200 Subject: [PATCH 2/3] chore(i18n): sync source locations after maintenance refactors --- src/locale/messages.xlf | 52 ++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index 6b7ee53b0..b6da822c0 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -19970,28 +19970,28 @@ This intervention has not been saved on this device, so it cannot be opened offline. Reconnect and try again. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 527 + 530 The intervention workspace could not be loaded. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 531 + 534 The offline file queue is full (25 files or 50 MB). Reconnect to sync your pending files before adding more. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 804 + 807 The comment could not be posted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 903 + 906 src/app/features/organization/features/interventions/ui/forms/intervention-comment-form/intervention-comment-form.component.ts @@ -20002,14 +20002,14 @@ The intervention status could not be updated. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1064 + 1067 This intervention changed since it was loaded. Refresh and try again. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1066 + 1069 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20020,7 +20020,7 @@ You do not have permission to change this intervention's status. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1068 + 1071 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20031,7 +20031,7 @@ This status change is not allowed from the intervention's current status. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1070 + 1073 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20042,46 +20042,46 @@ Intervention planning details could not be saved. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1152 + 1155 src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1203 + 1206 Team assigned src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1254 + 1257 The team could not be assigned. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1262 + 1265 This team has no active members. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1264 + 1267 This intervention can no longer be assigned a team; refreshing its current state. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1266 + 1269 The work item could not be created. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1558 + 1561 src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts @@ -20092,35 +20092,35 @@ The work item could not be updated. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1696 + 1699 The proposed change could not be rejected. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1831 + 1834 Connect to the network to delete planned work items. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1874 + 1877 The work item could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1928 + 1931 Intervention deleted src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1970 + 1973 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20131,7 +20131,7 @@ The intervention could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1976 + 1979 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20142,7 +20142,7 @@ Only draft or abandoned interventions can be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1980 + 1983 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20153,7 +20153,7 @@ You do not have permission to delete this intervention. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1982 + 1985 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -20164,18 +20164,18 @@ The file could not be uploaded. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2110 + 2113 The file could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2148 + 2151 src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2194 + 2197 From c7bedc7d903e0de909cb961573ea00db143c4fb7 Mon Sep 17 00:00:00 2001 From: "Valentin F." <95174216+DevSkyLex@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:14:04 +0200 Subject: [PATCH 3/3] test(maintenance): preserve organization in intervention links --- .../testing/maintenance-plan-list.component.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts index 61ce6b0ff..26aa04a8c 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts @@ -105,7 +105,12 @@ describe('MaintenancePlanList', () => { expect(root().querySelector('a')?.getAttribute('href')).toBe( '/organizations/' + organizationId + '/equipments/' + equipmentId, ); - expect(root().querySelector('a[href$="/interventions/' + interventionId + '"]')).not.toBeNull(); + const interventionLink = [...root().querySelectorAll('a')].find( + (link) => link.textContent?.trim() === 'Open existing intervention', + ); + expect(interventionLink?.getAttribute('href')).toBe( + '/organizations/' + organizationId + '/interventions/' + interventionId, + ); expect(root().textContent).not.toContain('Completed'); });