From 90094ba1ae4319c8efb2c2993d58176b66750317 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:23:45 +0200 Subject: [PATCH] fix(api): key starting-sword seed idempotency on its real unique index The CharacterItem idempotency check was looking up by the seed's own literal id instead of the (characterId, itemDefinitionId) unique index that CharacterItem actually enforces. If the demo character had already looted a worn-short-sword naturally, re-running the seed would miss that row and try to insert a colliding duplicate, breaking seeding instead of being a safe no-op. Look up by the real domain key and reuse whatever id is found when wiring up the CharacterEquipment row. --- .../seeds/vertical-slice.seed.spec.ts | 45 +++++++++++++++++++ .../src/database/seeds/vertical-slice.seed.ts | 7 ++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index df32221..102f561 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -419,4 +419,49 @@ describe('seedVisibleVerticalSlice', () => { ); expect(characterItemRepository.rows).toHaveLength(1); }); + + it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + // Simulate the demo character having already looted a worn-short-sword + // naturally, under a DB-generated id that differs from the seed's + // stable literal constant. + const naturallyLootedItemId = 'naturally-looted-sword-item-id'; + characterItemRepository.rows.push({ + id: naturallyLootedItemId, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }); + + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: naturallyLootedItemId, + }), + ); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 8af98bc..febeb07 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -218,8 +218,11 @@ export async function seedVisibleVerticalSlice( const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment); const existingStartingSword = await characterItemRepository.findOneBy({ - id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], }); + const startingSwordItemId = + existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID; if (!existingStartingSword) { await characterItemRepository.insert({ id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, @@ -238,7 +241,7 @@ export async function seedVisibleVerticalSlice( id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, characterId: DEMO_CHARACTER_ID, slot: EquipmentSlot.WEAPON, - characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterItemId: startingSwordItemId, }); } }