From 820c69704f0b8438e6e8ecccc55228ea289e248c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:50:50 +0200 Subject: [PATCH] feat(api): add inventory API (GET /api/inventory) --- apps/api/src/app.module.ts | 2 + .../api/src/inventory/inventory.controller.ts | 13 +++ apps/api/src/inventory/inventory.module.ts | 13 +++ .../src/inventory/inventory.service.spec.ts | 97 +++++++++++++++++++ apps/api/src/inventory/inventory.service.ts | 71 ++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 apps/api/src/inventory/inventory.controller.ts create mode 100644 apps/api/src/inventory/inventory.module.ts create mode 100644 apps/api/src/inventory/inventory.service.spec.ts create mode 100644 apps/api/src/inventory/inventory.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 46bf13b..79d5f80 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { DatabaseModule } from './database/database.module'; import { EquipmentModule } from './equipment/equipment.module'; import { HealthModule } from './health/health.module'; import { HuntingModule } from './hunting/hunting.module'; +import { InventoryModule } from './inventory/inventory.module'; import { TravelModule } from './travel/travel.module'; import { WorldModule } from './world/world.module'; @@ -18,6 +19,7 @@ import { WorldModule } from './world/world.module'; HuntingModule, CombatModule, EquipmentModule, + InventoryModule, ], }) export class AppModule {} diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..781e91c --- /dev/null +++ b/apps/api/src/inventory/inventory.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { InventoryResponseDto, InventoryService } from './inventory.service'; + +@Controller('inventory') +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + @Get() + getInventory(): Promise { + return this.inventoryService.getInventory(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/inventory/inventory.module.ts b/apps/api/src/inventory/inventory.module.ts new file mode 100644 index 0000000..5092c22 --- /dev/null +++ b/apps/api/src/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { InventoryController } from './inventory.controller'; +import { InventoryService } from './inventory.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])], + controllers: [InventoryController], + providers: [InventoryService], +}) +export class InventoryModule {} diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts new file mode 100644 index 0000000..2d39c00 --- /dev/null +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -0,0 +1,97 @@ +import { Repository } from 'typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { InventoryService } from './inventory.service'; + +const CHARACTER_ID = 'character-1'; + +function characterItem(overrides: Partial = {}): CharacterItem { + return { + id: 'item-1', + characterId: CHARACTER_ID, + itemDefinitionId: 'def-1', + quantity: 1, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + itemDefinition: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: ItemRarity.COMMON, + type: ItemType.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + ...overrides, + } as CharacterItem; +} + +describe('InventoryService', () => { + it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => { + const items = [ + characterItem({ id: 'item-1', quantity: 1 }), + characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }), + ]; + const characterItems = { + find: jest.fn().mockResolvedValue(items), + } as unknown as Repository; + const equipment = { + find: jest.fn().mockResolvedValue([ + { characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment, + ]), + } as unknown as Repository; + const service = new InventoryService(characterItems, equipment); + + const result = await service.getInventory(CHARACTER_ID); + + expect(characterItems.find).toHaveBeenCalledWith({ + where: { characterId: CHARACTER_ID }, + relations: { itemDefinition: true }, + order: { createdAt: 'ASC' }, + }); + expect(result.items).toEqual([ + { + id: 'item-1', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + }, + { + id: 'item-2', + quantity: 3, + equipped: false, + item: expect.objectContaining({ key: 'worn-short-sword' }), + }, + ]); + }); + + it('returns an empty list when the character owns nothing', async () => { + const characterItems = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const equipment = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const service = new InventoryService(characterItems, equipment); + + await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] }); + }); +}); diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts new file mode 100644 index 0000000..3eb9a3f --- /dev/null +++ b/apps/api/src/inventory/inventory.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; + +export interface InventoryItemDto { + id: string; + quantity: number; + equipped: boolean; + item: { + key: string; + name: string; + rarity: ItemRarity; + equipmentSlot: EquipmentSlot | null; + requiredLevel: number; + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; + iconPath: string; + }; +} + +export interface InventoryResponseDto { + items: InventoryItemDto[]; +} + +@Injectable() +export class InventoryService { + constructor( + @InjectRepository(CharacterItem) + private readonly characterItems: Repository, + @InjectRepository(CharacterEquipment) + private readonly equipment: Repository, + ) {} + + async getInventory(characterId: string): Promise { + const [items, equipped] = await Promise.all([ + this.characterItems.find({ + where: { characterId }, + relations: { itemDefinition: true }, + order: { createdAt: 'ASC' }, + }), + this.equipment.find({ where: { characterId } }), + ]); + const equippedIds = new Set(equipped.map((row) => row.characterItemId)); + + return { + items: items.map((characterItem) => ({ + id: characterItem.id, + quantity: characterItem.quantity, + equipped: equippedIds.has(characterItem.id), + item: { + key: characterItem.itemDefinition.key, + name: characterItem.itemDefinition.name, + rarity: characterItem.itemDefinition.rarity, + equipmentSlot: characterItem.itemDefinition.equipmentSlot, + requiredLevel: characterItem.itemDefinition.requiredLevel, + weaponDamage: characterItem.itemDefinition.weaponDamage, + bonusAttack: characterItem.itemDefinition.bonusAttack, + bonusHp: characterItem.itemDefinition.bonusHp, + bonusArmor: characterItem.itemDefinition.bonusArmor, + iconPath: characterItem.itemDefinition.iconPath, + }, + })), + }; + } +}