import { describe, expect, it } from 'vitest' import { decideMigrationParityGate, migrationKey, migrationKeysOf, } from '../migration-parity-gate.js' // Repo-side drift lock: gitflow duplicates this logic on purpose (it must not // import another skill at runtime), so the test — which DOES run from the repo, // where both skills are present — asserts the two implementations agree. import { findMissingReferenceMigrations, migrationKey as efcoreMigrationKey, } from '../../../../efcore/cli/lib/squash-integrity.js' const DIR = 'src/App.Infrastructure/Persistence/Migrations' const mig = (name: string) => `${DIR}/${name}.cs` const designer = (name: string) => `${DIR}/${name}.Designer.cs` const SNAPSHOT = `${DIR}/ExtensionsDbContextModelSnapshot.cs` const HOTFIX = 'Ext_v5.10.1_001_FixTaxRate' const R1 = 'Ext_v5.10.0_001_AddOrders' const R2 = 'Ext_v5.11.0_001_AddInvoices' describe('migrationKey', () => { it('collapses a migration and its Designer sidecar to one key', () => { expect(migrationKey(mig(R1))).toBe(R1) expect(migrationKey(designer(R1))).toBe(R1) }) it('is null for the snapshot and for non-.cs files', () => { expect(migrationKey(SNAPSHOT)).toBeNull() expect(migrationKey(`${DIR}/README.md`)).toBeNull() }) it('normalises Windows separators', () => { expect(migrationKey(`src\\App\\Migrations\\${R1}.cs`)).toBe(R1) }) }) describe('migrationKeysOf', () => { it('keeps only .cs files living under a Migrations/ folder', () => { const keys = migrationKeysOf([ mig(R1), designer(R1), SNAPSHOT, 'src/App.Domain/Entities/Order.cs', // real .cs, but not a migration 'src/App.Api/Migrations.md', ]) expect(keys).toEqual([R1]) }) it('matches the singular Migration/ folder variant and a repo-root folder', () => { expect(migrationKeysOf([`src/Infra/Migration/${R1}.cs`])).toEqual([R1]) expect(migrationKeysOf([`Migrations/${R1}.cs`])).toEqual([R1]) }) it('de-duplicates and sorts', () => { expect(migrationKeysOf([mig(R2), designer(R2), mig(R1)])).toEqual([R1, R2]) }) }) describe('decideMigrationParityGate — the release→main prod-regression gate', () => { it('BLOCKS when a hotfix migration on main is absent from the release branch', () => { const reference = [mig(R1), designer(R1), mig(HOTFIX), designer(HOTFIX), SNAPSHOT] const head = [mig(R1), designer(R1), mig(R2), designer(R2), SNAPSHOT] const gate = decideMigrationParityGate(reference, head, 'main') expect(gate.ok).toBe(false) expect(gate.missing).toEqual([HOTFIX]) expect(gate.preserved).toBe(1) expect(gate.error).toContain(HOTFIX) expect(gate.error).toContain('ModelSnapshot.cs') // The remediation must be actionable, and the escape hatch discoverable. expect(gate.error).toContain('/gitflow update') expect(gate.error).toContain('confirmRebaseline') }) it('PASSES the nominal release — main’s migrations all present, plus new ones', () => { const reference = [mig(R1), designer(R1), SNAPSHOT] const head = [mig(R1), designer(R1), mig(R2), designer(R2), SNAPSHOT] const gate = decideMigrationParityGate(reference, head, 'main') expect(gate.ok).toBe(true) expect(gate.missing).toEqual([]) expect(gate.preserved).toBe(1) expect(gate.warnings).toEqual([]) }) it('PASSES when a reference migration merely MOVED to another folder', () => { const reference = [mig(R1), SNAPSHOT] const head = [`src/App.Infrastructure/Migrations/Extensions/${R1}.cs`, SNAPSHOT] expect(decideMigrationParityGate(reference, head, 'main').ok).toBe(true) }) it('PASSES when only the Designer sidecar of a reference migration survives', () => { // The pair collapses to one key — an asymmetric sidecar is not a prod loss. const gate = decideMigrationParityGate([mig(R1), designer(R1)], [designer(R1)], 'main') expect(gate.ok).toBe(true) }) it('is INERT for a repo holding no migrations at all', () => { const gate = decideMigrationParityGate( ['src/index.ts', 'package.json'], ['src/index.ts'], 'main', ) expect(gate).toEqual({ ok: true, warnings: [], missing: [], preserved: 0 }) }) it('does not treat a dropped ModelSnapshot as a missing migration', () => { const gate = decideMigrationParityGate([mig(R1), SNAPSHOT], [mig(R1)], 'main') expect(gate.ok).toBe(true) expect(gate.missing).toEqual([]) }) it('confirmRebaseline downgrades the block to a LOUD warning, never silence', () => { const gate = decideMigrationParityGate([mig(R1), mig(HOTFIX)], [mig(R1)], 'main', true) expect(gate.ok).toBe(true) expect(gate.missing).toEqual([HOTFIX]) expect(gate.error).toBeUndefined() expect(gate.warnings).toHaveLength(1) expect(gate.warnings[0]).toContain(HOTFIX) expect(gate.warnings[0]).toContain('re-baselined') }) it('reports EVERY missing migration, sorted', () => { const gate = decideMigrationParityGate([mig(R2), mig(R1), mig(HOTFIX)], [], 'main') expect(gate.missing).toEqual([R1, HOTFIX, R2].sort()) expect(gate.error).toContain('3 migration(s)') }) it('names the target branch it protected', () => { const gate = decideMigrationParityGate([mig(R1)], [], 'production') expect(gate.error).toContain("'production'") }) }) describe('drift lock vs efcore/cli/lib/squash-integrity.ts', () => { const CORPUS = [ mig(R1), designer(R1), SNAPSHOT, `${DIR}/20260101120000_${R2}.cs`, `src\\App\\Migrations\\${HOTFIX}.Designer.cs`, `${DIR}/README.md`, 'src/App.Domain/Entities/Order.cs', ] it('migrationKey agrees with the efcore SSOT on every corpus path', () => { for (const p of CORPUS) { expect(migrationKey(p), p).toEqual(efcoreMigrationKey(p)) } }) it('the gate flags exactly what findMissingReferenceMigrations flags', () => { const reference = [mig(R1), designer(R1), mig(HOTFIX), SNAPSHOT] const head = [mig(R1), designer(R1), SNAPSHOT] expect(decideMigrationParityGate(reference, head, 'main').missing).toEqual( findMissingReferenceMigrations(reference, head), ) }) })