Merged PR 2002: fix(checkout): resolve itemType validation error for download items

fix(checkout): resolve itemType validation error for download items

Updates ItemTypeSchema to accept bitwise flag combinations instead of
only individual enum values. The backend returns combined itemType
values (e.g., 20480 = ItemPrice | Download) which were causing Zod
validation errors when adding download/e-book items to cart.

Changes:
- Update ItemTypeSchema to use bitwise validation pattern
- Add comprehensive unit tests (24 tests) covering individual flags,
  combinations, and edge cases
- Follow same pattern as NotificationChannelSchema and CRUDASchema

Closes #5429

Related work items: #5429
This commit is contained in:
Lorenz Hilpert
2025-11-04 15:14:39 +00:00
committed by Nino Righi
parent e1681d8867
commit cc62441f58
3 changed files with 160 additions and 7 deletions

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import { ItemType, ItemTypeSchema } from './item-type.schema';
describe('ItemTypeSchema', () => {
describe('Individual flags', () => {
it('should accept NotSet (0)', () => {
const result = ItemTypeSchema.parse(ItemType.NotSet);
expect(result).toBe(0);
});
it('should accept SingleUnit', () => {
const result = ItemTypeSchema.parse(ItemType.SingleUnit);
expect(result).toBe(1);
});
it('should accept Download', () => {
const result = ItemTypeSchema.parse(ItemType.Download);
expect(result).toBe(4096);
});
it('should accept ItemPrice', () => {
const result = ItemTypeSchema.parse(ItemType.ItemPrice);
expect(result).toBe(16384);
});
it('should accept all defined item type values', () => {
const allTypes = Object.values(ItemType);
for (const type of allTypes) {
expect(() => ItemTypeSchema.parse(type)).not.toThrow();
}
});
});
describe('Bitwise combined flags', () => {
it('should accept Download | ItemPrice (20480)', () => {
const combined = ItemType.Download | ItemType.ItemPrice; // 4096 | 16384 = 20480
const result = ItemTypeSchema.parse(combined);
expect(result).toBe(20480);
});
it('should accept SingleUnit | Download', () => {
const combined = ItemType.SingleUnit | ItemType.Download; // 1 | 4096 = 4097
const result = ItemTypeSchema.parse(combined);
expect(result).toBe(4097);
});
it('should accept Set | Configurable | Discount', () => {
const combined = ItemType.Set | ItemType.Configurable | ItemType.Discount; // 4 | 16 | 32 = 52
const result = ItemTypeSchema.parse(combined);
expect(result).toBe(52);
});
it('should accept multiple combined flags', () => {
const combined =
ItemType.SingleUnit |
ItemType.SalesUnit |
ItemType.Download |
ItemType.Streaming; // 1 | 2 | 4096 | 8192 = 12291
const result = ItemTypeSchema.parse(combined);
expect(result).toBe(12291);
});
it('should accept all flags combined', () => {
const allFlags = Object.values(ItemType).reduce<number>((a, b) => a | b, 0);
const result = ItemTypeSchema.parse(allFlags);
expect(result).toBe(allFlags);
});
});
describe('Invalid values', () => {
it('should reject unknown flag bit (131072)', () => {
const unknownFlag = 131072; // Not a valid ItemType flag
expect(() => ItemTypeSchema.parse(unknownFlag)).toThrow('Invalid ItemType: contains unknown flags');
});
it('should reject value with unknown bits', () => {
const invalidValue = ItemType.Download | 262144; // Valid flag + unknown bit
expect(() => ItemTypeSchema.parse(invalidValue)).toThrow('Invalid ItemType: contains unknown flags');
});
it('should reject negative values', () => {
expect(() => ItemTypeSchema.parse(-1)).toThrow();
});
it('should reject non-integer values', () => {
expect(() => ItemTypeSchema.parse(1.5)).toThrow();
});
it('should reject string values', () => {
expect(() => ItemTypeSchema.parse('Download' as any)).toThrow();
});
it('should reject null', () => {
expect(() => ItemTypeSchema.parse(null as any)).toThrow();
});
it('should reject undefined', () => {
expect(() => ItemTypeSchema.parse(undefined as any)).toThrow();
});
});
describe('Edge cases', () => {
it('should handle 0 (NotSet)', () => {
const result = ItemTypeSchema.parse(0);
expect(result).toBe(0);
});
it('should handle maximum valid combined value', () => {
// All flags OR'd together
const maxValue = Object.values(ItemType).reduce<number>((a, b) => a | b, 0);
const result = ItemTypeSchema.parse(maxValue);
expect(result).toBe(maxValue);
});
it('should handle SingleUnitItemPrice (16385)', () => {
// This is a special case: not a power of 2, but still valid
const result = ItemTypeSchema.parse(ItemType.SingleUnitItemPrice);
expect(result).toBe(16385);
});
it('should allow SingleUnitItemPrice in combinations', () => {
const combined = ItemType.SingleUnitItemPrice | ItemType.Download; // 16385 | 4096 = 20481
const result = ItemTypeSchema.parse(combined);
expect(result).toBe(20481);
});
});
describe('Real-world scenarios', () => {
it('should validate download audiobook item type from bug #5429', () => {
// This is the actual value that caused the bug
const downloadAudiobookType = 20480; // ItemPrice | Download
expect(() => ItemTypeSchema.parse(downloadAudiobookType)).not.toThrow();
const result = ItemTypeSchema.parse(downloadAudiobookType);
expect(result).toBe(20480);
});
it('should validate e-book item type', () => {
const ebookType = ItemType.Download | ItemType.SingleUnit;
expect(() => ItemTypeSchema.parse(ebookType)).not.toThrow();
});
it('should validate physical book with various flags', () => {
const physicalBook = ItemType.SingleUnit | ItemType.SalesUnit;
expect(() => ItemTypeSchema.parse(physicalBook)).not.toThrow();
});
});
});

View File

@@ -22,6 +22,15 @@ export const ItemType = {
CustomPrice: 65536,
} as const;
export const ItemTypeSchema = z.nativeEnum(ItemType).describe('Item type');
const ALL_FLAGS = Object.values(ItemType).reduce<number>((a, b) => a | b, 0);
export const ItemTypeSchema = z
.number()
.int()
.nonnegative()
.refine((val) => (val & ~ALL_FLAGS) === 0, {
message: 'Invalid ItemType: contains unknown flags',
})
.describe('Item type (bitflags)');
export type ItemType = z.infer<typeof ItemTypeSchema>;

View File

@@ -11,11 +11,7 @@ import {
ShoppingCartFacade,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import {
ButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { firstValueFrom } from 'rxjs';
import { Router } from '@angular/router';
@@ -28,7 +24,7 @@ import { NavigationStateService } from '@isa/core/navigation';
templateUrl: './reward-action.component.html',
styleUrl: './reward-action.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent, StatefulButtonComponent],
imports: [StatefulButtonComponent],
})
export class RewardActionComponent {
#router = inject(Router);