From 048cf80e785b04f0f33e4e1883f832a80c588580 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 10:25:26 +0200 Subject: [PATCH] test(slice-0.4): assert migration SQL and top-bar silver/XP rendering Adds a real-SQL-inspection test for CreateLootAndRewards1788600000000 (mirroring the visible-vertical-slice pattern) so the one-reward-per-combat unique index and other DB-level invariants can't be silently deleted without failing a test, and asserts the TopBar renders character.silver and character.experience values already present in the app.spec.ts fixture. Co-Authored-By: Claude Opus 5 --- .../loot-and-rewards.migration.spec.ts | 52 ++++++++++++++++++- apps/web/src/app/app.spec.ts | 2 + 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts index aceba34..35b369e 100644 --- a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts +++ b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts @@ -1,5 +1,6 @@ import 'reflect-metadata'; -import { getMetadataArgsStorage } from 'typeorm'; +import { getMetadataArgsStorage, QueryRunner } from 'typeorm'; +import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards'; import { Character } from '../../characters/entities/character.entity'; import { CharacterItem } from '../../items/entities/character-item.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; @@ -94,4 +95,53 @@ describe('loot and rewards schema', () => { expect(dropChance?.options.precision).toBe(5); expect(dropChance?.options.scale).toBe(4); }); + + it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => { + // synchronize: false means entity decorators never touch the real database - + // only the raw SQL emitted by the migration itself does. Assert on that SQL + // directly so deleting a constraint here would fail this test. + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateLootAndRewards1788600000000(); + + await migration.up(queryRunner); + + const upQueries = query.mock.calls.map(([sql]) => sql as string); + + expect(upQueries).toEqual( + expect.arrayContaining([ + // The database half of the "one reward per combat" invariant (spec §7, §37). + expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'), + expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'), + expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'), + ]), + ); + + const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK (')); + expect(checkConstraints.length).toBeGreaterThan(0); + expect( + checkConstraints.some( + (sql) => + sql.includes('CHK_loot_table_entries_drop_chance') || + sql.includes('CHK_character_items_quantity'), + ), + ).toBe(true); + + await migration.down(queryRunner); + + const downQueries = query.mock.calls + .slice(upQueries.length) + .map(([sql]) => sql as string); + + // Proves down() is real and reverses the up() migration, not a no-op. + expect(downQueries).toEqual( + expect.arrayContaining([ + expect.stringContaining('DROP TABLE "combat_rewards"'), + expect.stringContaining('DROP TABLE "character_items"'), + 'ALTER TABLE "characters" DROP COLUMN "silver"', + ]), + ); + }); }); diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 12ca2ca..4dd3f38 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -100,6 +100,8 @@ describe('App', () => { ); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); }); it('redirects root and unknown routes to the world shell', () => {