import 'reflect-metadata'; 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'; import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { CombatReward } from '../../rewards/entities/combat-reward.entity'; import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity'; function uniqueIndexFor(target: unknown, columns: string[]) { const index = getMetadataArgsStorage().indices.find( (candidate) => candidate.target === target && columns.every((column) => candidate.columns?.includes(column)), ); const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean; }; return indexMetadata?.options?.unique ?? indexMetadata?.unique; } describe('loot and rewards schema', () => { it('gives every combat at most one reward record', () => { expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true); }); it('keeps one stack per character per item definition', () => { expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true); }); it('keeps loot-table content keys and entry positions unique', () => { expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true); expect(uniqueIndexFor(LootTable, ['key'])).toBe(true); expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true); expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true); }); it('maps reward and loot relations with the documented onDelete behavior', () => { const relations = getMetadataArgsStorage().relations.filter((relation) => [CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes( relation.target as never, ), ); expect( relations.map((relation) => ({ onDelete: relation.options.onDelete, propertyName: relation.propertyName, target: relation.target, })), ).toEqual( expect.arrayContaining([ expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }), expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }), expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }), expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }), expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }), ]), ); }); it('adds the character silver column and the nullable monster loot table link', () => { const columns = getMetadataArgsStorage().columns; const silver = columns.find( (candidate) => candidate.target === Character && candidate.propertyName === 'silver', ); expect(silver).toBeDefined(); expect(silver?.options.type).toBe('integer'); const lootTableId = columns.find( (candidate) => candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId', ); expect(lootTableId).toBeDefined(); expect(lootTableId?.options.nullable).toBe(true); }); it('stores drop chance as a numeric column so probabilities stay data-driven', () => { const dropChance = getMetadataArgsStorage().columns.find( (candidate) => candidate.target === LootTableEntry && candidate.propertyName === 'dropChance', ); expect(dropChance?.options.type).toBe('numeric'); 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"', ]), ); }); });