import { describe, expect, it } from 'vitest' import { CORE_SEED_STATE_DIR, decideSeedDeltaGate, parseScriptHeader, parseStateHash, type SeedScriptCandidate, type SeedStateChange, } from '../seed-delta-gate.js' describe('parseStateHash', () => { it('extracts specHash from a state file', () => { expect(parseStateHash(JSON.stringify({ $schema: 'smartstack/core-seed-state', specHash: 'abc123' }))).toBe('abc123') }) it('returns null on garbage, foreign JSON, or missing hash', () => { expect(parseStateHash('not json')).toBeNull() expect(parseStateHash('{}')).toBeNull() expect(parseStateHash(JSON.stringify({ specHash: 42 }))).toBeNull() }) }) describe('parseScriptHeader', () => { it('extracts both hashes from the derive-seed-delta header', () => { const sql = [ '-- ============================================================', "-- core-seed delta — app 'crm' — version 5.10.0", '-- baseRef: origin/main', '-- baseHash: 1111aaaa', '-- newHash: 2222bbbb', '-- ============================================================', 'SELECT 1;', ].join('\n') expect(parseScriptHeader(sql)).toEqual({ baseHash: '1111aaaa', newHash: '2222bbbb' }) }) it('returns nulls when the header lines are absent', () => { expect(parseScriptHeader('SELECT 1;')).toEqual({ baseHash: null, newHash: null }) }) }) describe('decideSeedDeltaGate — pure decision', () => { const change = (over: Partial = {}): SeedStateChange => ({ app: 'crm', baseHash: 'base1', headHash: 'head1', ...over, }) const script = (over: Partial = {}): SeedScriptCandidate => ({ path: 'src/X.Infrastructure/Persistence/Seeding/Scripts/5.10.0_crm.sql', baseHash: 'base1', newHash: 'head1', ...over, }) it('passes when every changed state has a script bridging exactly base → head', () => { const result = decideSeedDeltaGate([change()], [script()]) expect(result.ok).toBe(true) expect(result.covered).toEqual(['crm']) }) it('BLOCKS a changed state with no script at all', () => { const result = decideSeedDeltaGate([change()], []) expect(result.ok).toBe(false) expect(result.error).toContain("'crm'") expect(result.error).toContain('derive-seed-delta') }) it('BLOCKS when the committed script bridges the wrong hashes (stale script)', () => { const result = decideSeedDeltaGate([change()], [script({ newHash: 'stale' })]) expect(result.ok).toBe(false) }) it('exempts a baseline (no state at the PR target) with a warning', () => { const result = decideSeedDeltaGate([change({ baseHash: null })], []) expect(result.ok).toBe(true) expect(result.warnings.some((w) => w.includes('baseline'))).toBe(true) }) it('exempts an unchanged hash (formatting-only state diff)', () => { const result = decideSeedDeltaGate([change({ headHash: 'base1' })], []) expect(result.ok).toBe(true) expect(result.warnings).toEqual([]) }) it('warns (never blocks) on a removed state file — whole-app removal is manual', () => { const result = decideSeedDeltaGate([change({ headHash: null })], []) expect(result.ok).toBe(true) expect(result.warnings.some((w) => w.includes('NOT reconciled'))).toBe(true) }) it('checks each app independently (one covered, one missing → block names only the missing one)', () => { const result = decideSeedDeltaGate( [change(), change({ app: 'budgeting', baseHash: 'b2', headHash: 'h2' })], [script()], ) expect(result.ok).toBe(false) expect(result.covered).toEqual(['crm']) expect(result.error).toContain("'budgeting'") expect(result.error).not.toContain("'crm'") }) it('is inert with no changed state files', () => { expect(decideSeedDeltaGate([], []).ok).toBe(true) }) }) describe('contract lockstep with derive-seed-delta (repo-side drift guard)', () => { it('shares the state dir + specHash field + header format with the core-seed skill', async () => { // Imported ONLY in this test — the runtime gate never depends on another // skill's modules. If either side moves, this test breaks in the repo. const state = await import( '../../../../development/backend/core-seed/cli/scaffold-core-seed/state.js' ) const sqlgen = await import( '../../../../development/backend/core-seed/cli/derive-seed-delta/generate-sql.js' ) expect(state.CORE_SEED_STATE_DIR).toBe(CORE_SEED_STATE_DIR) // A rendered script's header must round-trip through the gate parser. const script = sqlgen.renderDeltaScript( { app: 'crm', baseHash: 'base1', newHash: 'head1', baseline: false, navRenames: [], navUpdates: [], navAdditions: [], navDeactivations: [], removedApplications: [], roleRenames: [], roleUpdates: [], roleAdditions: [], removedRoles: [], permissionRenames: [], permissionUpdates: [], permissionAdditions: [], permissionDeletions: [], rolePermissionAdditions: [], rolePermissionRevocations: [], ambiguous: [], }, { version: '9.9.9', baseRef: 'origin/main' }, ) expect(script).not.toBeNull() expect(parseScriptHeader(script!)).toEqual({ baseHash: 'base1', newHash: 'head1' }) // State v2 (multi-grain permission floor) is the shape this gate expects — // a version bump on either side must break lockstep HERE, in the repo. expect(state.CORE_SEED_STATE_VERSION).toBe(2) // A rendered state file's hash must round-trip through the gate parser — // exercised WITH floor-style multi-grain permission rows (not an empty // permissions array), pinning that specHash stays a root-level field. const built = state.buildCoreSeedState({ code: 'crm', modules: ['contacts'], navigation: [ { level: 'application', code: 'crm', label: 'CRM', icon: 'B', iconType: 'Lucide', route: '/crm', displayOrder: 1 }, { level: 'module', code: 'contacts', parentCode: 'crm', label: 'Contacts', icon: 'B', iconType: 'Lucide', route: '/crm/contacts', displayOrder: 1 }, { level: 'section', code: 'directory', parentCode: 'contacts', label: 'Annuaire', icon: 'B', iconType: 'Lucide', route: '/crm/contacts/directory', displayOrder: 1 }, ], roles: [], permissions: [ { path: 'crm.access', action: 'access' }, { path: 'crm.contacts.access', action: 'access' }, { path: 'crm.contacts.directory.read', action: 'read', sectionCode: 'directory' }, ], rolePermissions: [], testUsers: [], }) expect(built.version).toBe(2) expect(built.permissions.every((p: { level: string; nodeCode: string }) => p.level && p.nodeCode)).toBe(true) const rendered = state.renderCoreSeedStateJson(built) expect(parseStateHash(rendered)).toMatch(/^[0-9a-f]{32}$/) expect(parseStateHash(rendered)).toBe(built.specHash) }) })