import 'reflect-metadata'; import { QueryRunner } from 'typeorm'; import { CreateQuestSystem1797000000000 } from './1797000000000-CreateQuestSystem'; /** * The migration writes multi-line SQL, so every assertion below reads it with * runs of whitespace collapsed. Otherwise a statement would have to be quoted * back with its exact indentation, and reindenting the migration would break * tests that still describe the right schema. */ function collapse(statements: string[]): string { return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); } async function runUp(): Promise { const query = jest.fn().mockResolvedValue(undefined); const queryRunner = { query } as unknown as QueryRunner; await new CreateQuestSystem1797000000000().up(queryRunner); return collapse(query.mock.calls.map(([sql]) => sql as string)); } async function runDown(): Promise { const query = jest.fn().mockResolvedValue(undefined); const queryRunner = { query } as unknown as QueryRunner; const migration = new CreateQuestSystem1797000000000(); await migration.up(queryRunner); const upCount = query.mock.calls.length; await migration.down(queryRunner); return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); } describe('CreateQuestSystem1797000000000', () => { it('creates the four quest tables', async () => { const joined = await runUp(); expect(joined).toContain('CREATE TABLE "quest_definitions"'); expect(joined).toContain('CREATE TABLE "quest_objectives"'); expect(joined).toContain('CREATE TABLE "npc_quest_assignments"'); expect(joined).toContain('CREATE TABLE "character_quests"'); }); it('creates the three quest enum types', async () => { const joined = await runUp(); expect(joined).toContain('CREATE TYPE "quest_objective_type_enum"'); expect(joined).toContain('CREATE TYPE "npc_quest_role_enum"'); expect(joined).toContain('CREATE TYPE "character_quest_status_enum"'); }); it('makes a quest acceptable only once per character', async () => { const joined = await runUp(); // The database, not a disabled button, is what guarantees this // (AGENTS.md §30). expect(joined).toContain( 'CREATE UNIQUE INDEX "IDX_character_quests_character_quest" ON "character_quests" ("character_id", "quest_id")', ); }); it('keeps objective keys and order unique within a quest', async () => { const joined = await runUp(); expect(joined).toContain( 'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_key" ON "quest_objectives" ("quest_id", "key")', ); expect(joined).toContain( 'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_order" ON "quest_objectives" ("quest_id", "order_index")', ); }); it('lets one quest use several NPCs in different roles', async () => { const joined = await runUp(); // The unique triple includes the role, so the same NPC can both offer and // receive a quest (NPC spec §14). expect(joined).toContain( 'CREATE UNIQUE INDEX "IDX_npc_quest_assignments_npc_quest_role" ON "npc_quest_assignments" ("npc_id", "quest_id", "role")', ); }); it('stores step effects as content columns', async () => { const joined = await runUp(); expect(joined).toContain('"advance_when_blocked" boolean'); expect(joined).toContain('"consume_on_complete" boolean'); expect(joined).toContain('"grants_loot_bag_key" character varying'); expect(joined).toContain('"sets_flag_key" character varying'); expect(joined).toContain('"sets_flag_npc_key" character varying'); expect(joined).toContain('"npc_line" text'); expect(joined).toContain('"hint_text" text'); }); it('rejects nonsensical quantities and indexes', async () => { const joined = await runUp(); expect(joined).toContain('CHK_quest_objectives_required_quantity'); expect(joined).toContain('CHK_character_quests_objective_index'); }); it('cascades objectives and assignments but never deletes a quest in use', async () => { const joined = await runUp(); expect(joined).toContain('FK_quest_objectives_quest'); expect(joined).toContain('FK_npc_quest_assignments_npc'); expect(joined).toContain('FK_npc_quest_assignments_quest'); expect(joined).toContain('FK_character_quests_character'); // A quest a character is on must not vanish underneath them. expect(joined).toContain( 'FOREIGN KEY ("quest_id") REFERENCES "quest_definitions"("id") ON DELETE RESTRICT', ); }); it('removes the demo character stopgap hide bag', async () => { const joined = await runUp(); // Slice 0.9 D5: the seed handed this bag over for free so the hunting loop // stayed playable between 0.8.5 and 0.9. Leaving it would hide the default // HIDE capacity of 1, which is the whole premise of this slice. expect(joined).toContain('DELETE FROM "character_loot_bags"'); expect(joined).toContain('10000000-0000-4000-8000-000000000001'); expect(joined).toContain('a0000000-0000-4000-8000-000000000001'); }); it('drops every table and type it created on down', async () => { const joined = await runDown(); expect(joined).toContain('DROP TABLE "character_quests"'); expect(joined).toContain('DROP TABLE "npc_quest_assignments"'); expect(joined).toContain('DROP TABLE "quest_objectives"'); expect(joined).toContain('DROP TABLE "quest_definitions"'); expect(joined).toContain('DROP TYPE "character_quest_status_enum"'); expect(joined).toContain('DROP TYPE "npc_quest_role_enum"'); expect(joined).toContain('DROP TYPE "quest_objective_type_enum"'); }); it('does not resurrect the deleted hide bag on down', async () => { const joined = await runDown(); // A rollback cannot tell the seeded stopgap from a bag the player earned, // so it restores neither. expect(joined).not.toContain('INSERT INTO "character_loot_bags"'); }); });