Enhance error handling and storage utilities

Improved error handling and updated storage utilities for better performance.

-  **Feature**: Added custom error classes for return process errors
- 🛠️ **Refactor**: Updated hash function to handle strings and objects
- 🛠️ **Refactor**: Enhanced storage key generation with user context
- ⚙️ **Config**: Updated VSCode settings and package dependencies
This commit is contained in:
Lorenz Hilpert
2025-04-01 12:01:16 +02:00
parent 3bbec6a68d
commit aaa161424e
11 changed files with 317 additions and 45 deletions

View File

@@ -91,5 +91,5 @@
{
"file": "docs/guidelines/testing.md"
}
]
],
}

View File

@@ -1,4 +1,13 @@
export function hash(obj: object): string {
// TODO: Implement hash function
return JSON.stringify(obj);
export function hash(obj: object | string): string {
if (typeof obj === 'string') {
return obj;
}
const str = JSON.stringify(obj, Object.keys(obj).sort());
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash.toString(16);
}

View File

@@ -1,15 +1,19 @@
import { Type } from '@angular/core';
import { getState, patchState, signalStoreFeature, withHooks, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { StorageProvider } from './storage-provider';
import { injectStorage } from './storage';
import { debounceTime, pipe, switchMap } from 'rxjs';
export function withStorage(storageKey: string, storageProvider: Type<StorageProvider>) {
return signalStoreFeature(
withMethods((store, storage = injectStorage(storageProvider)) => ({
storeState: () => {
const state = getState(store);
storage.set(storageKey, state);
},
storeState: rxMethod<void>(
pipe(
debounceTime(1000),
switchMap(() => storage.set(storageKey, getState(store))),
),
),
restoreState: async () => {
const data = await storage.get(storageKey);
if (data) {

View File

@@ -1,49 +1,40 @@
import { inject, Type } from '@angular/core';
import { inject, InjectionToken, Type } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { z } from 'zod';
import { OAuthService } from 'angular-oauth2-oidc';
import { hash } from './hash.utils';
export const USER_SUB = new InjectionToken<() => string>('core.storage.user-sub', {
factory: () => {
const auth = inject(OAuthService, { optional: true });
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
},
});
export class Storage {
private readonly userSub = inject(USER_SUB);
constructor(private storageProvider: StorageProvider) {}
private getKey(token: string | object): string {
const userSub = this.userSub();
return `${userSub}:${hash(token)}`;
}
set<T>(token: string | object, value: T): Promise<void> {
let key: string;
if (typeof token === 'string') {
key = token;
} else {
key = hash(token);
}
return this.storageProvider.set(key, value);
return this.storageProvider.set(this.getKey(token), value);
}
async get<T>(token: string | object, schema?: z.ZodType<T>): Promise<T | unknown> {
let key: string;
if (typeof token === 'string') {
key = token;
} else {
key = hash(token);
}
const data = await this.storageProvider.get(key);
const data = await this.storageProvider.get(this.getKey(token));
if (schema) {
return await schema.parse(data);
return schema.parse(data);
}
return data;
}
async clear(token: string | object): Promise<void> {
let key: string;
if (typeof token === 'string') {
key = token;
} else {
key = hash(token);
}
return this.storageProvider.clear(key);
return this.storageProvider.clear(this.getKey(token));
}
}

View File

@@ -0,0 +1,2 @@
export * from './oms.error';
export * from './return-process.error';

View File

@@ -0,0 +1,5 @@
export abstract class OmsError extends Error {
constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,36 @@
import { OmsError } from './oms.error';
export const ReturnProcessErrorCode = {
NO_RETURNABLE_ITEMS: 'NO_RETURNABLE_ITEMS',
MISMATCH_RETURNABLE_ITEMS: 'MISMATCH_RETURNABLE_ITEMS',
} as const;
export type ReturnProcessErrorCode =
(typeof ReturnProcessErrorCode)[keyof typeof ReturnProcessErrorCode];
export class ReturnProcessError extends OmsError {
constructor(
public readonly code: ReturnProcessErrorCode,
message?: string,
) {
super(message || code);
}
}
export class ReturnProcessNoReturnableItemsError extends ReturnProcessError {
constructor(params: { processId: number; receiptId: number }) {
super(
ReturnProcessErrorCode.NO_RETURNABLE_ITEMS,
`No returnable items found for process id ${params.processId} and receipt id ${params.receiptId}.`,
);
}
}
export class ReturnProcessMismatchReturnableItemsError extends ReturnProcessError {
constructor(params: { processId: number; items: number; returnableItems: number }) {
super(
ReturnProcessErrorCode.MISMATCH_RETURNABLE_ITEMS,
`Mismatch in returnable items for process id ${params.processId}. Expected ${params.items} returnable items, but found ${params.returnableItems}.`,
);
}
}

View File

@@ -0,0 +1,148 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnProcessStore } from './return-process.store';
import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
import { setAllEntities } from '@ngrx/signals/entities';
import {
ReturnProcessMismatchReturnableItemsError,
ReturnProcessNoReturnableItemsError,
} from './errors';
import { ReturnProcess } from './models';
const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
1: {
id: 1,
actions: [{ key: 'canReturn', value: 'true' }],
product: { ean: '1234567890', format: 'TB', formatDetails: 'Taschenbuch' },
quantity: { quantity: 1 },
},
2: {
id: 2,
actions: [{ key: 'canReturn', value: 'false' }],
product: { ean: '0987654321', format: 'GEB', formatDetails: 'Buch' },
quantity: { quantity: 1 },
},
3: {
id: 3,
actions: [{ key: 'canReturn', value: 'true' }],
product: { ean: '1122334455', format: 'AU', formatDetails: 'Audio' },
quantity: { quantity: 1 },
},
};
describe('ReturnProcessStore', () => {
const createService = createServiceFactory({
service: ReturnProcessStore,
mocks: [IDBStorageProvider, ProcessService],
});
describe('Initialization', () => {
it('should create an instance of ReturnProcessStore', () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
it('should have a nextId computed property', () => {
const spectator = createService();
expect(spectator.service.nextId()).toBe(1); // Assuming no entities exist initially
});
});
describe('Entity Management', () => {
it('should remove all entities by process id', () => {
const spectator = createService();
const store = spectator.service;
patchState(
store as any,
setAllEntities([
{ id: 1, processId: 1, name: 'Process 1' },
{ id: 2, processId: 2, name: 'Process 2' },
{ id: 3, processId: 1, name: 'Process 3' },
]),
);
store.removeAllEntitiesByProcessId(1);
expect(store.entities()).toHaveLength(1);
expect(store.entities()[0].processId).toBe(2);
});
it('should set an answer for a given entity', () => {
const spectator = createService();
const store = spectator.service;
patchState(store as any, setAllEntities([{ id: 1, processId: 1, answers: {} }]));
store.setAnswer(1, 'question1', 'answer1');
expect(store.entityMap()[1].answers['question1']).toBe('answer1');
});
it('should remove an answer for a given entity', () => {
const spectator = createService();
const store = spectator.service;
patchState(
store as any,
setAllEntities([{ id: 1, processId: 1, answers: { question1: 'answer1' } }]),
);
store.removeAnswer(1, 'question1');
expect(store.entityMap()[1].answers['question1']).toBeUndefined();
});
it('should set a product category for a given entity', () => {
const spectator = createService();
const store = spectator.service;
patchState(
store as any,
setAllEntities([{ id: 1, processId: 1, productCategory: undefined }]),
);
store.setProductCategory(1, 'Electronics');
expect(store.entityMap()[1].productCategory).toBe('Electronics');
});
});
describe('Process Management', () => {
it('should initialize a new return process', () => {
const spectator = createService();
const store = spectator.service;
store.startProcess({
processId: 1,
receiptId: 123,
items: [TEST_ITEMS[1], TEST_ITEMS[3]],
});
expect(store.entities()).toHaveLength(2);
});
it('should throw an error if no returnable items are found', () => {
const spectator = createService();
const store = spectator.service;
expect(() => {
store.startProcess({
processId: 1,
receiptId: 123,
items: [TEST_ITEMS[2]],
});
}).toThrow(ReturnProcessNoReturnableItemsError);
});
it('should throw an error if the number of returnable items does not match the total items', () => {
const spectator = createService();
const store = spectator.service;
expect(() => {
store.startProcess({
processId: 1,
receiptId: 123,
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
});
}).toThrow(ReturnProcessMismatchReturnableItemsError);
});
});
});

View File

@@ -4,13 +4,45 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { ProcessService } from '@isa/core/process';
import { ReceiptItem, ReturnProcess } from './models';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
ReturnProcessMismatchReturnableItemsError,
ReturnProcessNoReturnableItemsError,
} from './errors';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
receiptId: number;
items: ReceiptItem[];
};
/**
* Store for managing return process entities.
*
* This store is responsible for handling the state and behavior of return process entities used in the application.
* It leverages persistence with the IDBStorageProvider, supports entity management operations, and includes computed
* properties and hooks to synchronize state based on external dependencies.
*
* Key Features:
* - Entity Management: Maintains return process entities using methods to add, update, and remove them.
* - Persistence: Automatically stores state changes via the configured IDBStorageProvider.
* - Computed Properties: Includes a computed "nextId" for generating new unique entity identifiers.
* - Process Actions: Provides methods to handle various actions on return process entities, such as:
* - removeAllEntitiesByProcessId: Removes entities not matching specified process IDs.
* - setAnswer and removeAnswer: Manage answers associated with specific questions for entities.
* - setProductCategory: Assigns a product category to an entity.
* - startProcess: Initializes a new return process by filtering receipt items, validating them, and creating a new set of entities.
*
* Hooks:
* - onInit: Ensures that any entities with process IDs not recognized by the ProcessService are automatically cleaned up.
*
* Exceptions:
* - Throws a NoReturnableItemsError if no returnable items are identified.
* - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count.
*/
export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
@@ -19,15 +51,27 @@ export const ReturnProcessStore = signalStore(
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
withMethods((store) => ({
/**
* Removes all entities associated with the specified process IDs from the store.
* @param processIds - The process IDs to filter entities by.
* @returns void
*/
removeAllEntitiesByProcessId: (...processIds: number[]) => {
const entitiesToRemove = store
.entities()
.filter((entity) => processIds.includes(entity.processId));
.filter((entity) => !processIds.includes(entity.processId));
patchState(store, setAllEntities(entitiesToRemove));
store.storeState();
},
setAnswer: (id: number, question: string, answer: unknown) => {
/**
* Sets an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer.
* @param answer - The answer to set for the specified question.
* @returns void
*/
setAnswer: <T>(id: number, question: string, answer: T) => {
const entity = store.entityMap()[id];
if (entity) {
const answers = { ...entity.answers, [question]: answer };
@@ -35,6 +79,13 @@ export const ReturnProcessStore = signalStore(
store.storeState();
}
},
/**
* Removes an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer to remove.
* @returns void
*/
removeAnswer: (id: number, question: string) => {
const entity = store.entityMap()[id];
if (entity) {
@@ -45,6 +96,13 @@ export const ReturnProcessStore = signalStore(
}
},
/**
* Sets the product category for a specific entity.
* If the entity does not have an existing return question, for its product category,
* it will be set to the provided category.
* @param id - The ID of the entity to update.
* @param category - The product category to set for the entity.
*/
setProductCategory: (id: number, category: string | undefined) => {
const entity = store.entityMap()[id];
if (entity) {
@@ -54,21 +112,41 @@ export const ReturnProcessStore = signalStore(
},
})),
withMethods((store) => ({
/**
* Initializes a new return process by removing previous entities for the given process id,
* then filtering and validating the receipt items, and finally creating new return process entities.
*
* @param params - The configuration for starting a new return process.
* @param params.processId - The unique identifier for the return process.
* @param params.receiptId - The identifier for the associated receipt.
* @param params.items - An array of receipt items to be processed.
*
* @throws {Error} Throws an error if no returnable items are found.
* @throws {Error} Throws an error if the number of returnable items does not match the total items.
*/
startProcess: (params: StartProcess) => {
// Remove existing entities related to the process to start fresh.
store.removeAllEntitiesByProcessId(params.processId);
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
const returnableItems = params.items.filter((item) =>
item.actions?.some((a) => a.key === 'canReturn' && Boolean(a.value)),
item.actions?.some((a) => a.key === 'canReturn' && coerceBooleanProperty(a.value)),
);
if (returnableItems.length === 0) {
throw new Error('No returnable items found');
throw new ReturnProcessNoReturnableItemsError({
processId: params.processId,
receiptId: params.receiptId,
});
}
if (returnableItems.length !== params.items.length) {
throw new Error('Not all items are returnable');
throw new ReturnProcessMismatchReturnableItemsError({
processId: params.processId,
items: params.items.length,
returnableItems: returnableItems.length,
});
}
for (let i = 0; i < params.items.length; i++) {

3
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"@ngrx/effects": "19.0.1",
"@ngrx/entity": "19.0.1",
"@ngrx/operators": "19.0.1",
"@ngrx/signals": "^19.0.1",
"@ngrx/store": "19.0.1",
"@ngrx/store-devtools": "19.0.1",
"angular-oauth2-oidc": "^17.0.2",
@@ -53,7 +54,6 @@
"@angular/pwa": "^19.2.0",
"@eslint/js": "^9.8.0",
"@ngneat/spectator": "^19.0.0",
"@ngrx/signals": "^19.0.1",
"@nx/angular": "20.4.6",
"@nx/eslint": "20.4.6",
"@nx/eslint-plugin": "20.4.6",
@@ -7133,7 +7133,6 @@
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.0.1.tgz",
"integrity": "sha512-e9cGgF//tIyN1PKDDcBQkI0csxRcw4r9ezTtDzQpM2gPU5frD9JxaW/YU5gM02ZMl97bUMoI82fBtnDN0RtyWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"

View File

@@ -37,6 +37,7 @@
"@ngrx/effects": "19.0.1",
"@ngrx/entity": "19.0.1",
"@ngrx/operators": "19.0.1",
"@ngrx/signals": "^19.0.1",
"@ngrx/store": "19.0.1",
"@ngrx/store-devtools": "19.0.1",
"angular-oauth2-oidc": "^17.0.2",
@@ -64,7 +65,6 @@
"@angular/pwa": "^19.2.0",
"@eslint/js": "^9.8.0",
"@ngneat/spectator": "^19.0.0",
"@ngrx/signals": "^19.0.1",
"@nx/angular": "20.4.6",
"@nx/eslint": "20.4.6",
"@nx/eslint-plugin": "20.4.6",