Merged PR 1853: feat(return-process): add getReceiptItemQuantity helper and related tests

feat(return-process): add getReceiptItemQuantity helper and related tests

Ref: #5156
This commit is contained in:
Lorenz Hilpert
2025-06-10 14:56:34 +00:00
committed by Nino Righi
parent a37201ef33
commit 61ce9940c9
9 changed files with 190 additions and 102 deletions

View File

@@ -0,0 +1,36 @@
import { getReceiptItemQuantity } from './get-receipt-item-quantity.helper';
describe('getItemQuantity', () => {
it('should return item quantity when not present in quantity map', () => {
// Arrange
const item = { id: 123, quantity: { quantity: 5 } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(5);
});
it('should return 1 as default when item quantity is 0', () => {
// Arrange
const item = { id: 123, quantity: { quantity: 0 } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(1);
});
it('should return 1 as default when item quantity is null', () => {
// Arrange
const item = { id: 123, quantity: { quantity: null as unknown as number } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(1);
});
});

View File

@@ -0,0 +1,6 @@
export function getReceiptItemQuantity(item: {
id: number;
quantity?: { quantity: number };
}): number {
return item.quantity?.quantity || 1; // Default to 1 if not specified
}

View File

@@ -6,6 +6,7 @@ export * from './eligible-for-return.helper';
export * from './get-next-question.helper';
export * from './get-receipt-item-action.helper';
export * from './get-receipt-item-product-category.helper';
export * from './get-receipt-item-quantity.helper';
export * from './get-return-info.helper';
export * from './get-return-process-questions.helper';
export * from './get-tolino-questions.helper';

View File

@@ -7,6 +7,7 @@ import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
import { Receipt, ReceiptItem } from '../models';
import { addEntities } from '@ngrx/signals/entities';
import { ProductCategory } from '../questions';
describe('ReturnDetailsStore', () => {
const createService = createServiceFactory({
@@ -25,10 +26,34 @@ describe('ReturnDetailsStore', () => {
const spectator = createService();
const receiptItems = [
{ id: 1 } as ReceiptItem,
{ id: 2 } as ReceiptItem,
{ id: 3 } as ReceiptItem,
{ id: 4 } as ReceiptItem,
{
id: 1,
quantity: { quantity: 1 },
features: {
category: ProductCategory.BookCalendar,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 2,
quantity: { quantity: 2 },
features: {
category: ProductCategory.ElektronischeGeraete,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 3,
quantity: { quantity: 3 },
features: {
category: ProductCategory.SpielwarenPuzzle,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 4,
quantity: { quantity: 4 },
features: {
category: ProductCategory.TonDatentraeger,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
];
const receiptsEntities = [

View File

@@ -1,4 +1,4 @@
import { computed, effect, inject, resource, untracked } from '@angular/core';
import { computed, inject, resource, untracked } from '@angular/core';
import {
CanReturn,
ProductCategory,
@@ -19,11 +19,13 @@ import {
import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities';
import {
canReturnReceiptItem,
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
interface ReturnDetailsState {
_storageId: number | undefined;
@@ -84,8 +86,41 @@ export const ReturnDetailsStore = signalStore(
.receiptsEntities()
.map((receipt) => receipt.items)
.flat()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((container) => container.data!),
.map((container) => {
const item = container.data;
if (!item) {
const err = new Error('Item data is undefined');
store._logger.error('Item data is undefined', err, () => ({
item: container,
}));
throw err;
}
const itemData = clone(item);
const quantityMap = store.selectedQuantity();
if (quantityMap[itemData.id]) {
itemData.quantity = { quantity: quantityMap[itemData.id] };
} else {
const quantity = getReceiptItemQuantity(itemData);
if (!itemData.quantity) {
itemData.quantity = { quantity };
} else {
itemData.quantity.quantity = quantity;
}
}
if (!itemData.features) {
itemData.features = {};
}
itemData.features['category'] =
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData);
return itemData;
}),
),
})),
withComputed((store) => ({
@@ -116,9 +151,7 @@ export const ReturnDetailsStore = signalStore(
}),
selectedItems: computed(() => {
const selectedIds = store.selectedItemIds();
const items = store
.receiptsEntities()
.flatMap((receipt) => receipt.items.map((item) => item.data!));
const items = store.items();
return items.filter((item) => selectedIds.includes(item.id));
}),
})),
@@ -186,10 +219,9 @@ export const ReturnDetailsStore = signalStore(
}),
getItems: (receiptId: () => number) =>
computed(() => {
const items = store.items();
const id = receiptId();
const entities = store.receiptsEntityMap();
const receipt = entities[id];
return receipt?.items.map((item) => item.data!);
return items.filter((item) => item.receipt?.id === id);
}),
getSelectableItems: (receiptId: () => number) =>
computed(() =>
@@ -202,21 +234,6 @@ export const ReturnDetailsStore = signalStore(
const selectedIds = store.selectedItemIds();
return selectedIds.includes(item().id);
}),
getItemQuantity: (item: () => ReceiptItem) =>
computed(() => {
const itemData = item();
return (
store.selectedQuantity()[itemData.id] || itemData.quantity.quantity
);
}),
getProductCategory: (item: () => ReceiptItem) =>
computed(() => {
const itemData = item();
return (
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData)
);
}),
isSelectable: (receiptItem: () => ReceiptItem) =>
computed(() => {
const item = receiptItem();

View File

@@ -1,6 +1,7 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnDetailsHeaderComponent } from './return-details-header.component';
import { Buyer } from '@isa/oms/data-access';
import { Receipt, ReturnDetailsStore } from '@isa/oms/data-access';
import { signal } from '@angular/core';
describe('ReturnDetailsHeaderComponent', () => {
let spectator: Spectator<ReturnDetailsHeaderComponent>;
@@ -8,17 +9,32 @@ describe('ReturnDetailsHeaderComponent', () => {
component: ReturnDetailsHeaderComponent,
});
let buyerMock: Buyer;
const getReceiptMock = signal<Receipt>({
id: 12345,
items: [],
buyer: {
reference: { id: 12345 },
firstName: 'John',
lastName: 'Doe',
buyerNumber: '123456',
},
});
const storeMock = {
getReceipt: () => getReceiptMock,
};
beforeEach(() => {
buyerMock = {
buyerNumber: '12345',
};
spectator = createComponent({
props: {
buyer: buyerMock,
receiptId: 12345, // Mock receiptId for testing
},
providers: [
{
provide: ReturnDetailsStore,
useValue: storeMock,
},
],
});
});

View File

@@ -1,11 +1,16 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { signal } from '@angular/core';
// Helper function to create mock ReceiptItem data
const createMockItem = (
@@ -16,6 +21,7 @@ const createMockItem = (
): ReceiptItem =>
({
id: 123,
receiptNumber: 'R-123456', // Add the required receiptNumber property
quantity: { quantity: 1 },
price: {
value: { value: 19.99, currency: 'EUR' },
@@ -40,9 +46,31 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockIsSelectable = signal<boolean>(true);
const mockGetItemSelectted = signal<boolean>(false);
const mockCanReturnResource = {
isLoading: signal<boolean>(true),
};
function resetMocks() {
mockIsSelectable.set(true);
mockGetItemSelectted.set(false);
mockCanReturnResource.isLoading.set(true);
}
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemControlsComponent,
mocks: [ReturnDetailsService],
providers: [
{
provide: ReturnDetailsStore,
useValue: {
isSelectable: jest.fn(() => mockIsSelectable),
getItemSelected: jest.fn(() => mockGetItemSelectted),
canReturnResource: jest.fn(() => mockCanReturnResource),
},
},
],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
@@ -65,12 +93,14 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
afterEach(() => {
resetMocks(); // Reset mocks after each test
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
@@ -81,10 +111,9 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
it('should display the checkbox when item is selectable', () => {
// Arrange
// The mock item has canReturn=true and a valid category, which should make it selectable
// after the effect executes
mockCanReturnResource.isLoading.set(false); // Simulate the resource being ready
mockIsSelectable.set(true); // Simulate the item being selectable
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
@@ -95,13 +124,9 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
// Create a non-returnable item by modifying the features.category to 'unknown'
const nonReturnableItem = createMockItem('1234567890123', true);
nonReturnableItem.features = { category: 'unknown' };
// Set the item to trigger the effects which determine selectability
spectator.setInput('item', nonReturnableItem);
mockIsSelectable.set(false); // Simulate the item not being selectable
spectator.detectChanges();
spectator.detectComponentChanges();
// Assert
expect(spectator.component.selectable()).toBe(false);
@@ -110,55 +135,6 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
).not.toExist();
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should update the selected model when checkbox is clicked', () => {
// Arrange
spectator.detectChanges(); // This will make the component selectable via the effect
// Use the component's method directly to toggle selection
// This is similar to what happens when a checkbox is clicked
spectator.component.selected.set(!spectator.component.selected());
spectator.detectChanges();
// Assert
expect(spectator.component.selected()).toBe(true);
});
it('should reflect the initial selected state in the checkbox', () => {
// Arrange
// First ensure the item is selectable (has a non-unknown category)
const selectableItem = createMockItem(
'1234567890123',
true,
'Test Product',
'BOOK',
);
spectator.setInput('item', selectableItem);
spectator.setInput('selected', true); // Start selected
spectator.detectChanges(); // This triggers the effects that set selectable
// Assert
expect(spectator.component.selected()).toBe(true);
expect(spectator.component.selectable()).toBe(true);
// For a checkbox, we need to check that it exists
const checkbox = spectator.query(
'input[type="checkbox"][data-what="return-item-checkbox"]',
);
expect(checkbox).toExist();
// With Spectator we can use toHaveProperty for HTML elements
expect(checkbox).toHaveProperty('checked', true);
});
it('should be true when actions include canReturn with truthy value', () => {
// Arrange
const item = createMockItem('0001', true);
spectator.setInput('item', item);
// Act
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(true);
});
it('should be false when no canReturn action is present', () => {
// Arrange

View File

@@ -9,6 +9,8 @@ import {
import { provideLoggerContext } from '@isa/core/logging';
import {
canReturnReceiptItem,
getReceiptItemProductCategory,
getReceiptItemQuantity,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
@@ -63,14 +65,20 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
availableCategories = this.#returnDetailsService.availableCategories();
quantity = this.#store.getItemQuantity(this.item);
quantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
quantityDropdownValues = computed(() => {
const itemQuantity = this.item().quantity.quantity;
return Array.from({ length: itemQuantity }, (_, i) => i + 1);
});
productCategory = this.#store.getProductCategory(this.item);
productCategory = computed(() => {
const item = this.item();
return getReceiptItemProductCategory(item);
});
selectable = this.#store.isSelectable(this.item);

View File

@@ -1,4 +1,8 @@
import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest';
import {
createComponentFactory,
Spectator,
mockProvider,
} from '@ngneat/spectator/jest';
import { FilterActionsComponent } from './filter-actions.component';
import { FilterService } from '../core';
@@ -13,7 +17,6 @@ describe('FilterActionsComponent', () => {
{ group: 'other', key: 'key2' },
]),
commit: jest.fn(),
commitInput: jest.fn(),
reset: jest.fn(),
resetInput: jest.fn(),
}),
@@ -51,7 +54,7 @@ describe('FilterActionsComponent', () => {
spectator.setInput('inputKey', 'key1');
spectator.component.onApply();
expect(filterService.commitInput).toHaveBeenCalledWith('key1');
expect(filterService.commit).toHaveBeenCalled();
expect(appliedSpy).toHaveBeenCalled();
});
@@ -74,7 +77,7 @@ describe('FilterActionsComponent', () => {
spectator.setInput('inputKey', 'key1');
spectator.component.onReset();
expect(filterService.resetInput).toHaveBeenCalledWith('key1');
expect(filterService.resetInput).toHaveBeenCalledWith(['key1']);
expect(filterService.commit).toHaveBeenCalled();
expect(resetedSpy).toHaveBeenCalled();
});