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 <noreply@anthropic.com>
148 lines
6.6 KiB
TypeScript
148 lines
6.6 KiB
TypeScript
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"',
|
|
]),
|
|
);
|
|
});
|
|
});
|