Merged PR 1886: feat: add unit tests for remission return receipt functionality

feat: add unit tests for remission return receipt functionality

- Add tests for 4 new RemissionReturnReceiptService methods:
  - removeReturnItemFromReturnReceipt()
  - completeReturnReceipt()
  - completeReturn()
  - completeReturnReceiptAndReturn()
- Update RemissionReturnReceiptDetailsCardComponent tests for itemCount -> positionCount
- Add tests for new inputs and remove functionality in RemissionReturnReceiptDetailsItemComponent
- Add tests for canRemoveItems and completeReturn in RemissionReturnReceiptDetailsComponent
- All tests focus on happy path scenarios and isolated functionality

Refs: #5138
This commit is contained in:
Lorenz Hilpert
2025-07-17 13:46:32 +00:00
committed by Nino Righi
parent 65ab3bfc0a
commit b015e97e1f
15 changed files with 29177 additions and 28551 deletions

View File

@@ -1,166 +1,166 @@
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: Error, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: unknown, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}

View File

@@ -18,6 +18,9 @@ describe('RemissionReturnReceiptService', () => {
let mockReturnService: {
ReturnQueryReturns: jest.Mock;
ReturnGetReturnReceipt: jest.Mock;
ReturnRemoveReturnItem: jest.Mock;
ReturnFinalizeReceipt: jest.Mock;
ReturnFinalizeReturn: jest.Mock;
};
let mockRemissionStockService: {
fetchAssignedStock: jest.Mock;
@@ -66,6 +69,9 @@ describe('RemissionReturnReceiptService', () => {
mockReturnService = {
ReturnQueryReturns: jest.fn(),
ReturnGetReturnReceipt: jest.fn(),
ReturnRemoveReturnItem: jest.fn(),
ReturnFinalizeReceipt: jest.fn(),
ReturnFinalizeReturn: jest.fn(),
};
mockRemissionStockService = {
@@ -412,4 +418,194 @@ describe('RemissionReturnReceiptService', () => {
);
});
});
describe('removeReturnItemFromReturnReceipt', () => {
it('should remove item from return receipt successfully', async () => {
mockReturnService.ReturnRemoveReturnItem.mockReturnValue(
of({ result: null, error: null }),
);
const params = {
returnId: 1,
receiptId: 101,
receiptItemId: 1001,
};
await service.removeReturnItemFromReturnReceipt(params);
expect(mockReturnService.ReturnRemoveReturnItem).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnRemoveReturnItem.mockReturnValue(
of(errorResponse),
);
const params = {
returnId: 1,
receiptId: 101,
receiptItemId: 1001,
};
await expect(
service.removeReturnItemFromReturnReceipt(params),
).rejects.toThrow(ResponseArgsError);
});
});
describe('completeReturnReceipt', () => {
const mockCompletedReceipt: Receipt = {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt;
it('should complete return receipt successfully', async () => {
mockReturnService.ReturnFinalizeReceipt.mockReturnValue(
of({ result: mockCompletedReceipt, error: null }),
);
const params = {
returnId: 1,
receiptId: 101,
};
const result = await service.completeReturnReceipt(params);
expect(result).toEqual(mockCompletedReceipt);
expect(mockReturnService.ReturnFinalizeReceipt).toHaveBeenCalledWith({
returnId: 1,
receiptId: 101,
data: {},
});
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnFinalizeReceipt.mockReturnValue(
of(errorResponse),
);
const params = {
returnId: 1,
receiptId: 101,
};
await expect(service.completeReturnReceipt(params)).rejects.toThrow(
ResponseArgsError,
);
});
});
describe('completeReturn', () => {
const mockCompletedReturn: Return = {
id: 1,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return;
it('should complete return successfully', async () => {
mockReturnService.ReturnFinalizeReturn.mockReturnValue(
of({ result: mockCompletedReturn, error: null }),
);
const params = { returnId: 1 };
const result = await service.completeReturn(params);
expect(result).toEqual(mockCompletedReturn);
expect(mockReturnService.ReturnFinalizeReturn).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnFinalizeReturn.mockReturnValue(of(errorResponse));
const params = { returnId: 1 };
await expect(service.completeReturn(params)).rejects.toThrow(
ResponseArgsError,
);
});
});
describe('completeReturnReceiptAndReturn', () => {
const mockCompletedReceipt: Receipt = {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt;
const mockCompletedReturn: Return = {
id: 1,
receipts: [
{
id: 101,
data: mockCompletedReceipt,
},
],
} as unknown as Return;
it('should complete both receipt and return successfully', async () => {
mockReturnService.ReturnFinalizeReceipt.mockReturnValue(
of({ result: mockCompletedReceipt, error: null }),
);
mockReturnService.ReturnFinalizeReturn.mockReturnValue(
of({ result: mockCompletedReturn, error: null }),
);
const params = {
returnId: 1,
receiptId: 101,
};
const result = await service.completeReturnReceiptAndReturn(params);
expect(result).toEqual(mockCompletedReturn);
expect(mockReturnService.ReturnFinalizeReceipt).toHaveBeenCalledWith({
returnId: 1,
receiptId: 101,
data: {},
});
expect(mockReturnService.ReturnFinalizeReturn).toHaveBeenCalledWith({
returnId: 1,
});
});
it('should throw error if completeReturnReceipt fails', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnFinalizeReceipt.mockReturnValue(
of(errorResponse),
);
const params = {
returnId: 1,
receiptId: 101,
};
await expect(
service.completeReturnReceiptAndReturn(params),
).rejects.toThrow(ResponseArgsError);
expect(mockReturnService.ReturnFinalizeReturn).not.toHaveBeenCalled();
});
});
});

View File

@@ -15,14 +15,14 @@ import { logger } from '@isa/core/logging';
/**
* Service responsible for managing remission return receipts.
* Handles fetching completed and incomplete return receipts from the inventory API.
*
*
* @class RemissionReturnReceiptService
* @injectable
*
*
* @example
* // Inject the service
* constructor(private remissionReturnReceiptService: RemissionReturnReceiptService) {}
*
*
* // Fetch completed receipts
* const completedReceipts = await this.remissionReturnReceiptService
* .fetchCompletedRemissionReturnReceipts();
@@ -39,12 +39,12 @@ export class RemissionReturnReceiptService {
/**
* Fetches all completed remission return receipts for the assigned stock.
* Returns receipts marked as completed within the last 7 days.
*
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of completed return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
*
* @example
* const controller = new AbortController();
* const completedReturns = await service
@@ -54,15 +54,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching completed remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching completed returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
startDate: subDays(new Date(), 7).toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
@@ -80,27 +80,30 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch completed returns', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch completed returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length
returnCount: returns.length,
}));
return returns;
}
/**
* Fetches all incomplete remission return receipts for the assigned stock.
* Returns receipts not yet marked as completed within the last 7 days.
*
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of incomplete return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
*
* @example
* const incompleteReturns = await service
* .fetchIncompletedRemissionReturnReceipts();
@@ -109,15 +112,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching incomplete remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching incomplete returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
startDate: subDays(new Date(), 7).toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
@@ -135,22 +138,25 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch incomplete returns', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch incomplete returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched incomplete returns', () => ({
returnCount: returns.length
returnCount: returns.length,
}));
return returns;
}
/**
* Fetches a specific remission return receipt by receipt and return IDs.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
*
*
* @async
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
@@ -159,7 +165,7 @@ export class RemissionReturnReceiptService {
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
*
* @example
* const receipt = await service.fetchRemissionReturnReceipt({
* receiptId: '123',
@@ -171,15 +177,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
const { receiptId, returnId } =
FetchRemissionReturnReceiptSchema.parse(params);
this.#logger.info('Fetching return receipt from API', () => ({
receiptId,
returnId
returnId,
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
returnId,
@@ -194,15 +200,113 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch return receipt', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully fetched return receipt', () => ({
found: !!receipt
found: !!receipt,
}));
return receipt;
}
async removeReturnItemFromReturnReceipt(params: {
returnId: number;
receiptId: number;
receiptItemId: number;
}) {
const res = await firstValueFrom(
this.#returnService.ReturnRemoveReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to remove item from return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
async completeReturnReceipt({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}): Promise<Receipt> {
this.#logger.debug('Completing return receipt', () => ({ returnId }));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReceipt({
returnId,
receiptId,
data: {},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
return res?.result as Receipt;
}
async completeReturn(params: { returnId: number }): Promise<Return> {
this.#logger.debug('Completing return', () => ({
returnId: params.returnId,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
}
async completeReturnReceiptAndReturn(params: {
returnId: number;
receiptId: number;
}): Promise<Return> {
this.#logger.debug('Completing return receipt and return', () => ({
returnId: params.returnId,
receiptId: params.receiptId,
}));
await this.completeReturnReceipt({
returnId: params.returnId,
receiptId: params.receiptId,
});
const completedReturn = await this.completeReturn({
returnId: params.returnId,
});
this.#logger.info('Successfully completed return and receipt', () => ({
returnId: params.returnId,
receiptId: params.receiptId,
}));
return completedReturn;
}
}

View File

@@ -13,7 +13,7 @@
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); height: '1.5rem'"
>
{{ itemCount() }}
{{ positionCount() }}
</div>
</div>
<div>

View File

@@ -1,3 +1,3 @@
:host {
@apply p-6 bg-isa-neutral-400 rounded-2xl grid grid-cols-3 gap-6 isa-text-body-1-regular;
@apply p-6 bg-isa-neutral-400 rounded-2xl grid grid-cols-3 desktop-large:grid-cols-5 gap-6 isa-text-body-1-regular justify-evenly;
}

View File

@@ -127,28 +127,28 @@ describe('RemissionReturnReceiptDetailsCardComponent', () => {
});
});
describe('itemCount computed signal', () => {
it('should calculate total quantity from items', () => {
describe('positionCount computed signal', () => {
it('should return the number of items', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
// mockReceipt has items with quantities 5 and 3 = 8 total
expect(component.itemCount()).toBe(8);
// mockReceipt has 2 items
expect(component.positionCount()).toBe(2);
});
it('should return 0 when no items', () => {
const receiptWithoutItems = { ...mockReceipt, items: [] };
fixture.componentRef.setInput('receipt', receiptWithoutItems);
expect(component.itemCount()).toBe(0);
expect(component.positionCount()).toBe(0);
});
it('should return undefined when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.itemCount()).toBeUndefined();
expect(component.positionCount()).toBeUndefined();
});
it('should handle items with undefined data', () => {
it('should count all items regardless of data', () => {
const receiptWithUndefinedItems = {
...mockReceipt,
items: [
@@ -158,7 +158,7 @@ describe('RemissionReturnReceiptDetailsCardComponent', () => {
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedItems);
expect(component.itemCount()).toBe(5);
expect(component.positionCount()).toBe(2);
});
});
@@ -282,7 +282,7 @@ describe('RemissionReturnReceiptDetailsCardComponent', () => {
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.status()).toBe('Abgeschlossen');
expect(component.itemCount()).toBe(8);
expect(component.positionCount()).toBe(2);
// Change receipt
const newReceipt = {
@@ -293,7 +293,7 @@ describe('RemissionReturnReceiptDetailsCardComponent', () => {
fixture.componentRef.setInput('receipt', newReceipt);
expect(component.status()).toBe('Offen');
expect(component.itemCount()).toBe(10);
expect(component.positionCount()).toBe(1);
});
it('should create supplier resource on initialization', () => {

View File

@@ -12,11 +12,11 @@ import { createSupplierResource } from './resources';
/**
* Component that displays detailed information about a remission return receipt in a card format.
* Shows supplier information, status, dates, item counts, and package numbers.
*
*
* @component
* @selector remi-remission-return-receipt-details-card
* @standalone
*
*
* @example
* <remi-remission-return-receipt-details-card
* [receipt]="receiptData"
@@ -59,15 +59,12 @@ export class RemissionReturnReceiptDetailsCardComponent {
});
/**
* Computed signal that calculates the total quantity of all items in the receipt.
* @returns {number} Sum of all item quantities
* Computed signal that calculates the items in the receipt.
* @returns {number} Count of items in the receipt or 0 if not available
*/
itemCount = computed(() => {
positionCount = computed(() => {
const receipt = this.receipt();
return receipt?.items?.reduce(
(acc, item) => acc + (item.data?.quantity || 0),
0,
);
return receipt?.items?.length;
});
/**

View File

@@ -24,9 +24,12 @@
<div class="flex justify-center items-center">
@if (removeable()) {
<ui-icon-button
[pending]="removing()"
[name]="'isaActionClose'"
[size]="'large'"
[color]="'secondary'"
(click)="remove()"
[disabled]="removing()"
></ui-icon-button>
}
</div>

View File

@@ -10,6 +10,7 @@ import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import {
ReceiptItem,
RemissionProductGroupService,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
@@ -45,6 +46,10 @@ describe('RemissionReturnReceiptDetailsItemComponent', () => {
fetchProductGroups: vi.fn().mockResolvedValue(mockProductGroups),
};
const mockRemissionReturnReceiptService = {
removeReturnItemFromReturnReceipt: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsItemComponent],
@@ -53,6 +58,10 @@ describe('RemissionReturnReceiptDetailsItemComponent', () => {
provide: RemissionProductGroupService,
useValue: mockRemissionProductGroupService,
},
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
})
.overrideComponent(RemissionReturnReceiptDetailsItemComponent, {
@@ -387,4 +396,98 @@ describe('RemissionReturnReceiptDetailsItemComponent', () => {
// This would improve E2E test reliability and maintainability
});
});
describe('New inputs - receiptId and returnId', () => {
it('should accept receiptId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
expect(component.receiptId()).toBe(123);
});
it('should accept returnId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('returnId', 456);
expect(component.returnId()).toBe(456);
});
it('should handle both receiptId and returnId inputs together', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
expect(component.receiptId()).toBe(123);
expect(component.returnId()).toBe(456);
});
});
describe('Remove functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
});
it('should initialize removing signal as false', () => {
expect(component.removing()).toBe(false);
});
it('should call service and emit removed event on successful remove', async () => {
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockResolvedValue(
undefined,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toEqual(mockReceiptItem);
expect(component.removing()).toBe(false);
});
it('should handle remove error gracefully', async () => {
const mockError = new Error('Remove failed');
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockRejectedValue(
mockError,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toBeUndefined();
expect(component.removing()).toBe(false);
});
it('should not call service if already removing', async () => {
component.removing.set(true);
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -2,9 +2,16 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { ReceiptItem } from '@isa/remission/data-access';
import {
ReceiptItem,
RemissionReturnReceiptService,
ReturnItem,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@@ -13,6 +20,7 @@ import { productGroupResource } from './resources';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { logger } from '@isa/core/logging';
/**
* Component for displaying a single receipt item within the remission return receipt details.
@@ -43,6 +51,11 @@ import { isaActionClose } from '@isa/icons';
providers: [provideIcons({ isaActionClose })],
})
export class RemissionReturnReceiptDetailsItemComponent {
#logger = logger(() => ({
component: 'RemissionReturnReceiptDetailsItemComponent',
}));
#returnReceiptService = inject(RemissionReturnReceiptService);
/**
* Required input for the receipt item to display.
* Contains product information and quantity details.
@@ -51,6 +64,10 @@ export class RemissionReturnReceiptDetailsItemComponent {
*/
item = input.required<ReceiptItem>();
receiptId = input.required<number>();
returnId = input.required<number>();
removeable = input<boolean>(false);
productGroupResource = productGroupResource();
@@ -66,4 +83,26 @@ export class RemissionReturnReceiptDetailsItemComponent {
?.value || ''
);
});
removing = signal(false);
removed = output<ReceiptItem>();
async remove() {
if (this.removing()) {
return;
}
this.removing.set(true);
try {
await this.#returnReceiptService.removeReturnItemFromReturnReceipt({
receiptId: this.receiptId(),
returnId: this.returnId(),
receiptItemId: this.item().id,
});
this.removed.emit(this.item());
} catch (error) {
this.#logger.error('Failed to remove item', error);
}
this.removing.set(false);
}
}

View File

@@ -43,6 +43,9 @@
<remi-remission-return-receipt-details-item
[item]="item.data"
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
(removed)="returnResource.reload()"
></remi-remission-return-receipt-details-item>
@if (!last) {
<hr class="border-isa-neutral-300" />
@@ -50,3 +53,22 @@
}
</div>
}
@if (!returnResource.isLoading() && !returnResource.value()?.completed) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(click)="completeReturn()"
[(state)]="completeReturnState"
defaultContent="Wanne abschließen"
defaultWidth="13rem"
[errorContent]="completeReturnError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
(action)="completeReturn()"
successContent="Wanne abgeschlossen"
successWidth="20rem"
[pending]="completingReturn()"
size="large"
color="brand"
>
</ui-stateful-button>
}

View File

@@ -6,7 +6,7 @@ import { Location } from '@angular/common';
import { RemissionReturnReceiptDetailsComponent } from './remission-return-receipt-details.component';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Receipt } from '@isa/remission/data-access';
import { Receipt, RemissionReturnReceiptService } from '@isa/remission/data-access';
// Mock the resource function
vi.mock('./resources/remission-return-receipt.resource', () => ({
@@ -29,11 +29,19 @@ describe('RemissionReturnReceiptDetailsComponent', () => {
created: new Date('2024-01-15T10:30:00Z'),
} as Receipt;
const mockRemissionReturnReceiptService = {
completeReturnReceiptAndReturn: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsComponent],
providers: [
MockProvider(Location, { back: vi.fn() }),
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
})
.overrideComponent(RemissionReturnReceiptDetailsComponent, {
@@ -157,4 +165,115 @@ describe('RemissionReturnReceiptDetailsComponent', () => {
expect(component.returnResource.isLoading()).toBe(false);
});
});
describe('canRemoveItems computed signal', () => {
it('should return true when receipt is not completed', () => {
const incompleteReceipt = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(incompleteReceipt);
expect(component.canRemoveItems()).toBe(true);
});
it('should return false when receipt is completed', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(mockReceipt);
expect(component.canRemoveItems()).toBe(false);
});
it('should return false when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(null);
expect(component.canRemoveItems()).toBe(false);
});
});
describe('completeReturn functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).reload = vi.fn();
});
it('should initialize completion state signals', () => {
expect(component.completeReturnState()).toBe('default');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
});
it('should complete return successfully', async () => {
const mockCompletedReturn = { ...mockReceipt, completed: true };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockCompletedReturn,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('success');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
expect(component.returnResource.reload).toHaveBeenCalled();
});
it('should handle completion error', async () => {
const mockError = new Error('Completion failed');
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
mockError,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('error');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe('Completion failed');
expect(component.returnResource.reload).not.toHaveBeenCalled();
});
it('should handle non-Error objects', async () => {
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
'String error',
);
await component.completeReturn();
expect(component.completeReturnState()).toBe('error');
expect(component.completeReturnError()).toBe(
'Wanne konnte nicht abgeschlossen werden',
);
});
it('should not process if already completing', async () => {
component.completingReturn.set(true);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,15 +4,23 @@ import {
computed,
inject,
input,
signal,
} from '@angular/core';
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
import {
ButtonComponent,
IconButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaLoading } from '@isa/icons';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Location } from '@angular/common';
import { createRemissionReturnReceiptResource } from './resources/remission-return-receipt.resource';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { logger } from '@isa/core/logging';
/**
* Component for displaying detailed information about a remission return receipt.
@@ -40,10 +48,17 @@ import { createRemissionReturnReceiptResource } from './resources/remission-retu
NgIcon,
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
StatefulButtonComponent,
],
providers: [provideIcons({ isaActionChevronLeft, isaLoading })],
})
export class RemissionReturnReceiptDetailsComponent {
#logger = logger(() => ({
component: 'RemissionReturnReceiptDetailsComponent',
}));
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Angular Location service for navigation */
location = inject(Location);
@@ -94,4 +109,32 @@ export class RemissionReturnReceiptDetailsComponent {
const ret = this.returnResource.value();
return !ret?.completed;
});
completeReturnState = signal<StatefulButtonState>('default');
completingReturn = signal(false);
completeReturnError = signal<string | null>(null);
async completeReturn() {
if (this.completingReturn()) {
return;
}
this.completingReturn.set(true);
try {
await this.#remissionReturnReceiptService.completeReturnReceiptAndReturn({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
this.completeReturnState.set('success');
this.returnResource.reload();
} catch (error) {
this.#logger.error('Failed to complete return', error);
this.completeReturnError.set(
error instanceof Error
? error.message
: 'Wanne konnte nicht abgeschlossen werden',
);
this.completeReturnState.set('error');
}
this.completingReturn.set(false);
}
}

View File

@@ -75,7 +75,7 @@ export class StatefulButtonComponent implements OnDestroy {
errorWidth = input<string>('100%');
// Optional dismiss timeout in milliseconds
dismiss = input<number>();
dismiss = input<number>(5000);
// Button properties
color = input<ButtonColor>('primary');

56666
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff