From c35859a4de70a838e5d558da5db110f5b37cdc82 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 18 Aug 2026 19:40:13 +0200 Subject: [PATCH] fix: preserve seeded location identities --- .../seeds/vertical-slice.seed.spec.ts | 123 +++++++++++++++--- .../src/database/seeds/vertical-slice.seed.ts | 93 +++++++------ 2 files changed, 159 insertions(+), 57 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 0773eca..d90c554 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -1,4 +1,7 @@ import { DataSource } from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationConnection } from '../../world/entities/location-connection.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -39,6 +42,41 @@ class InMemoryRepository { readonly insert = jest.fn(async (value: Row) => { this.rows.push({ ...value }); }); + readonly update = jest.fn(async (criteria: string | Row, value: Row) => { + const row = this.rows.find((candidate) => + typeof criteria === 'string' + ? candidate.id === criteria + : Object.entries(criteria).every( + ([key, expected]) => candidate[key] === expected, + ), + ); + + if (row) { + Object.assign(row, value); + } + }); +} + +function createDataSource( + locationRepository: InMemoryRepository, + connectionRepository: InMemoryRepository, + characterRepository: InMemoryRepository, +): DataSource { + return { + getRepository: jest.fn((entity: unknown) => { + if (entity === LocationDefinition) { + return locationRepository; + } + if (entity === LocationConnection) { + return connectionRepository; + } + if (entity === Character) { + return characterRepository; + } + + throw new Error('Unexpected repository'); + }), + } as unknown as DataSource; } describe('seedVisibleVerticalSlice', () => { @@ -46,27 +84,20 @@ describe('seedVisibleVerticalSlice', () => { const locationRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository(); const characterRepository = new InMemoryRepository(); - const dataSource = { - getRepository: jest - .fn() - .mockReturnValueOnce(locationRepository) - .mockReturnValueOnce(connectionRepository) - .mockReturnValueOnce(characterRepository) - .mockReturnValueOnce(locationRepository) - .mockReturnValueOnce(connectionRepository) - .mockReturnValueOnce(characterRepository), - } as unknown as DataSource; - - await seedVisibleVerticalSlice(dataSource); - await seedVisibleVerticalSlice(dataSource); - - expect(locationRepository.upsert).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ id: SOUTH_GATE_ID, key: 'south-gate' }), - expect.objectContaining({ id: BURNED_ROAD_ID, key: 'burned-road' }), - ]), - ['key'], + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, ); + + await seedVisibleVerticalSlice(dataSource); + Object.assign(characterRepository.rows[0], { + currentLocationId: BURNED_ROAD_ID, + currentHp: 57, + experience: 39, + }); + await seedVisibleVerticalSlice(dataSource); + expect(connectionRepository.upsert).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ @@ -86,5 +117,57 @@ describe('seedVisibleVerticalSlice', () => { expect(locationRepository.rows).toHaveLength(2); expect(connectionRepository.rows).toHaveLength(2); expect(characterRepository.rows).toHaveLength(1); + expect(characterRepository.rows[0]).toEqual( + expect.objectContaining({ + currentLocationId: BURNED_ROAD_ID, + currentHp: 57, + experience: 39, + }), + ); + }); + + it('preserves existing location IDs and uses them for the directed connections', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const persistedSouthGateId = '30000000-0000-4000-8000-000000000001'; + locationRepository.rows.push({ + id: persistedSouthGateId, + key: 'south-gate', + name: 'Veraltetes Südtor', + }); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + + expect(locationRepository.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: persistedSouthGateId, + key: 'south-gate', + name: 'Südtor von Graufurt', + }), + expect.objectContaining({ + id: BURNED_ROAD_ID, + key: 'burned-road', + }), + ]), + ); + expect(connectionRepository.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fromLocationId: persistedSouthGateId, + toLocationId: BURNED_ROAD_ID, + }), + expect.objectContaining({ + fromLocationId: BURNED_ROAD_ID, + toLocationId: persistedSouthGateId, + }), + ]), + ); }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 9b54917..54319a7 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -12,52 +12,71 @@ export async function seedVisibleVerticalSlice( const connectionRepository = dataSource.getRepository(LocationConnection); const characterRepository = dataSource.getRepository(Character); - await locationRepository.upsert( - [ - { - id: SOUTH_GATE_ID, - key: 'south-gate', - name: 'Südtor von Graufurt', - description: - 'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 1, - dangerLevel: 0, - isSafe: true, - huntingEnabled: false, - artworkPath: '/assets/locations/south-gate.webp', - }, - { - id: BURNED_ROAD_ID, - key: 'burned-road', - name: 'Verbrannte Straße', - description: - 'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 2, - dangerLevel: 1, - isSafe: false, - huntingEnabled: true, - artworkPath: '/assets/locations/burned-road.webp', - }, - ], - ['key'], - ); + const locations = [ + { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + description: + 'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/assets/locations/south-gate.webp', + }, + { + id: BURNED_ROAD_ID, + key: 'burned-road', + name: 'Verbrannte Straße', + description: + 'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 2, + dangerLevel: 1, + isSafe: false, + huntingEnabled: true, + artworkPath: '/assets/locations/burned-road.webp', + }, + ]; + let southGateId = SOUTH_GATE_ID; + let burnedRoadId = BURNED_ROAD_ID; + + for (const location of locations) { + const existing = await locationRepository.findOneBy({ + key: location.key, + }); + const { id, key, ...definition } = location; + const persistedId = existing?.id ?? id; + + if (existing) { + await locationRepository.update(existing.id, definition); + } else { + await locationRepository.insert(location); + } + + if (key === 'south-gate') { + southGateId = persistedId; + } else { + burnedRoadId = persistedId; + } + } await connectionRepository.upsert( [ { - fromLocationId: SOUTH_GATE_ID, - toLocationId: BURNED_ROAD_ID, + fromLocationId: southGateId, + toLocationId: burnedRoadId, travelDurationSeconds: 10, ambushChance: '0.0500', enabled: true, }, { - fromLocationId: BURNED_ROAD_ID, - toLocationId: SOUTH_GATE_ID, + fromLocationId: burnedRoadId, + toLocationId: southGateId, travelDurationSeconds: 10, ambushChance: '0.0500', enabled: true,