import { DataSource, QueryRunner } from 'typeorm'; import { CategoryEntity } from '../../backend/src/database/entities/category.entity'; import { RepairCategorySchemaAndSystemCategories1700000000400, CANONICAL_SYSTEM_CATEGORY_KEYS, CANONICAL_SYSTEM_CATEGORIES, } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories'; import { v4 as uuid } from 'uuid'; async function bootstrapLegacyCategoryTable(queryRunner: QueryRunner): Promise { await queryRunner.query(`PRAGMA foreign_keys = ON;`); await queryRunner.query(` CREATE TABLE "category" ( "id" TEXT PRIMARY KEY, "system_key" TEXT, "name" TEXT NOT NULL, "abbreviation" TEXT NOT NULL, "description" TEXT NOT NULL DEFAULT '', "icon_path" TEXT NOT NULL DEFAULT '' ); `); await queryRunner.query( `CREATE UNIQUE INDEX "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`, ); } async function seedLegacyRows(queryRunner: QueryRunner): Promise { const legacy = [ { key: 'CRY', name: 'Cryptography', abbr: 'CRY', desc: 'Crypto challenges', icon: '/uploads/icons/crypto.svg' }, { key: 'FOR', name: 'Forensics', abbr: 'FOR', desc: 'Forensics challenges', icon: '/uploads/icons/forensics.svg' }, { key: 'MSC', name: 'Misc', abbr: 'MSC', desc: 'Miscellaneous', icon: '/uploads/icons/misc.svg' }, { key: 'OSI', name: 'OSINT', abbr: 'OSI', desc: 'Open-source intelligence', icon: '/uploads/icons/osint.svg' }, { key: 'PWN', name: 'Pwn', abbr: 'PWN', desc: 'Binary exploitation', icon: '/uploads/icons/pwn.svg' }, { key: 'WEB', name: 'Web', abbr: 'WEB', desc: 'Web exploitation', icon: '/uploads/icons/web.svg' }, ]; for (const r of legacy) { await queryRunner.query( `INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`, [uuid(), r.key, r.name, r.abbr, r.desc, r.icon], ); } } async function createDataSource(): Promise { return new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [CategoryEntity], migrations: [RepairCategorySchemaAndSystemCategories1700000000400], migrationsRun: false, synchronize: false, logging: false, }); } function expectCanonicalContents(rows: any[]): void { const sortedByLowerAbbrev = [...rows].sort((a, b) => a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()), ); const abbrevs = sortedByLowerAbbrev.map((r) => r.abbreviation); expect(abbrevs).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']); const byKey = new Map(sortedByLowerAbbrev.map((r) => [r.systemKey ?? r.system_key, r])); expect(byKey.size).toBe(CANONICAL_SYSTEM_CATEGORY_KEYS.length); for (const canonical of CANONICAL_SYSTEM_CATEGORIES) { const row = byKey.get(canonical.key); expect(row).toBeDefined(); expect(row.name).toBe(canonical.name); expect(row.abbreviation).toBe(canonical.abbreviation); expect(row.description).toBe(canonical.description); expect(row.iconPath ?? row.icon_path).toBe(canonical.iconPath); const createdAt = row.createdAt ?? row.created_at; const updatedAt = row.updatedAt ?? row.updated_at; expect(typeof createdAt).toBe('string'); expect(createdAt.length).toBeGreaterThan(0); expect(typeof updatedAt).toBe('string'); expect(updatedAt.length).toBeGreaterThan(0); } } describe('RepairCategorySchemaAndSystemCategories1700000000400', () => { it('adds missing created_at/updated_at columns and reconciles canonical system rows', async () => { const ds = await createDataSource(); await ds.initialize(); try { const queryRunner = ds.createQueryRunner(); try { await queryRunner.query(`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`); await bootstrapLegacyCategoryTable(queryRunner); await seedLegacyRows(queryRunner); const beforeCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`); const beforeNames = new Set(beforeCols.map((c: any) => c.name)); expect(beforeNames.has('created_at')).toBe(false); expect(beforeNames.has('updated_at')).toBe(false); const migration = new RepairCategorySchemaAndSystemCategories1700000000400(); await migration.up(queryRunner); const afterCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`); const afterNames = new Set(afterCols.map((c: any) => c.name)); expect(afterNames.has('created_at')).toBe(true); expect(afterNames.has('updated_at')).toBe(true); const rows: any[] = await queryRunner.query( `SELECT system_key, name, abbreviation, description, icon_path, created_at, updated_at FROM "category" WHERE "system_key" IS NOT NULL`, ); expectCanonicalContents(rows); const repo = ds.getRepository(CategoryEntity); const repoRows = await repo .createQueryBuilder('c') .orderBy('LOWER(c.abbreviation)', 'ASC') .addOrderBy('c.abbreviation', 'ASC') .getMany(); expectCanonicalContents(repoRows); const hasFor = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'FOR'`); expect(hasFor.length).toBe(0); const hasOsi = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'OSI'`); expect(hasOsi.length).toBe(0); } finally { await queryRunner.release(); } } finally { await ds.destroy(); } }); it('is idempotent — running it twice leaves exactly the canonical six system rows', async () => { const ds = await createDataSource(); await ds.initialize(); try { const queryRunner = ds.createQueryRunner(); try { await queryRunner.query( `CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`, ); await bootstrapLegacyCategoryTable(queryRunner); await seedLegacyRows(queryRunner); const migration = new RepairCategorySchemaAndSystemCategories1700000000400(); await migration.up(queryRunner); await migration.up(queryRunner); const rows: any[] = await queryRunner.query( `SELECT system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL ORDER BY abbreviation`, ); const systemKeys = rows.map((r) => r.system_key); const unique = new Set(systemKeys); expect(rows.length).toBe(6); expect(unique.size).toBe(6); expect(systemKeys.sort()).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'].sort()); } finally { await queryRunner.release(); } } finally { await ds.destroy(); } }); it('preserves user-created rows (system_key IS NULL)', async () => { const ds = await createDataSource(); await ds.initialize(); try { const queryRunner = ds.createQueryRunner(); try { await queryRunner.query( `CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`, ); await bootstrapLegacyCategoryTable(queryRunner); await queryRunner.query( `INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?, NULL, ?, ?, ?, ?)`, [uuid(), 'Community', 'CC', 'Community challenges', '/uploads/icons/community.svg'], ); const migration = new RepairCategorySchemaAndSystemCategories1700000000400(); await migration.up(queryRunner); const userRows: any[] = await queryRunner.query( `SELECT name, abbreviation FROM "category" WHERE "system_key" IS NULL`, ); expect(userRows.length).toBe(1); expect(userRows[0].name).toBe('Community'); expect(userRows[0].abbreviation).toBe('CC'); } finally { await queryRunner.release(); } } finally { await ds.destroy(); } }); });