Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<HydraCollection<CustomerOutput>>();
service.list.mockReturnValueOnce(old);
Expand Down Expand Up @@ -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<CustomerOutput>();
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<CustomerOutput>();
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');
});
});
Original file line number Diff line number Diff line change
@@ -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<CustomerLabel>;
let store: {
read: ReturnType<typeof vi.fn>;
readCallState: ReturnType<typeof signal<CallState<CustomerOutput>>>;
};

beforeEach(() => {
store = { read: vi.fn(), readCallState: signal<CallState<CustomerOutput>>(idleCallState()) };
TestBed.configureTestingModule({ imports: [CustomerLabel] }).overrideComponent(CustomerLabel, {
set: { providers: [{ provide: CustomerStore, useValue: store }] },
});
});
const render = async (customerId: string | null): Promise<void> => {
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();
});
});
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<HTMLButtonElement>,
).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<HTMLButtonElement>,
);
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<HTMLButtonElement>,
).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.');
});
});
Loading
Loading