import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { tableRule } from '@beehexa/hexasync-template-validate'; import { ValidationContext } from '@beehexa/hexasync-template-validate'; let tmp = ''; afterEach(() => { if (tmp && fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true }); tmp = ''; }); function write(p: string, content: string) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); } const ctx = (tokenForm: any): ValidationContext => ({ tokenForm }) as ValidationContext; const run = (tf: any) => tableRule.run(ctx(tf)); const ids = (tf: any) => run(tf).map((i) => i.ruleId); // Wrap a table so it is bound as a SYNCED object's backing table (so SOURCE_ID/KEYS // invariants TBL-1..4 apply). Structural checks (TBL-5/6/7) don't need this. const synced = (table: any) => ({ tables: [table], objectAssociations: { TASK1: { table: { id: table.id } } }, }); describe('tableRule — SOURCE_ID/KEYS invariants (synced tables only)', () => { it('a well-formed synced table yields no issues', () => { expect( run( synced({ id: 'T', columns: { source_id: { indexGroups: ['SOURCE_ID', 'KEYS'] }, name: { indexGroups: ['TRACK_FOR_CHANGES'] }, }, }), ), ).toEqual([]); }); it('TBL-1 zero SOURCE_ID + TBL-3 zero KEYS (critical)', () => { const byId = Object.fromEntries( run(synced({ id: 'T', columns: { a: { indexGroups: [] } } })).map((i) => [ i.ruleId, i, ]), ); expect(byId['TBL-1'].severity).toBe('critical'); expect(byId['TBL-3'].severity).toBe('critical'); expect(byId['TBL-1'].parentKey).toBe('tables'); expect(byId['TBL-1'].idOrKey).toBe('T'); }); it('TBL-2 more than one SOURCE_ID column (high)', () => { const dup = run( synced({ id: 'T', columns: { a: { indexGroups: ['SOURCE_ID', 'KEYS'] }, b: { indexGroups: ['SOURCE_ID'] }, }, }), ).find((i) => i.ruleId === 'TBL-2'); expect(dup?.severity).toBe('high'); }); it('TBL-4 SOURCE_ID column not also KEYS (medium)', () => { const i = run( synced({ id: 'T', columns: { sid: { indexGroups: ['SOURCE_ID'] }, k: { indexGroups: ['KEYS'] }, }, }), ).find((x) => x.ruleId === 'TBL-4'); expect(i?.severity).toBe('medium'); expect(i?.locateValue).toBe('sid'); }); it('F1: a NON-synced table (e.g. a report table) is NOT flagged for missing SOURCE_ID/KEYS', () => { // table exists but no objectAssociation binds it → not source-synced const tf = { tables: [ { id: 'REPORT_T', columns: { id: { indexGroups: ['KEYS'] }, value: { indexGroups: ['TRACK_FOR_CHANGES'] }, }, }, ], }; expect(ids(tf)).not.toContain('TBL-1'); expect(ids(tf)).not.toContain('TBL-3'); }); it('F2: a column listing SOURCE_ID twice counts as ONE column (no false TBL-2)', () => { const tf = synced({ id: 'T', columns: { refid: { indexGroups: ['SOURCE_ID', 'SOURCE_ID', 'KEYS'] } }, }); expect(ids(tf)).not.toContain('TBL-2'); expect(ids(tf)).toEqual([]); // exactly one SOURCE_ID, also KEYS }); }); describe('tableRule — structural checks (all tables)', () => { it('TBL-5 column uses an undeclared custom index group (critical)', () => { const i = run({ tables: [ { id: 'T', columns: { source_id: { indexGroups: ['SOURCE_ID', 'CUSTOM_X'] } }, }, ], }).find((x) => x.ruleId === 'TBL-5'); expect(i?.severity).toBe('critical'); expect(i?.locateValue).toBe('CUSTOM_X'); }); it('a declared custom group used by a column is fine; TBL-6 when declared-but-unused', () => { const used = { tables: [ { id: 'T', indexGroups: { CUSTOM_X: { type: 'UNIQUE' } }, columns: { source_id: { indexGroups: ['CUSTOM_X'] } }, }, ], }; expect(ids(used)).not.toContain('TBL-5'); expect(ids(used)).not.toContain('TBL-6'); const unused = { tables: [ { id: 'T', indexGroups: { CUSTOM_X: { type: 'UNIQUE' } }, columns: { source_id: { indexGroups: ['SOURCE_ID'] } }, }, ], }; const i = run(unused).find((x) => x.ruleId === 'TBL-6'); expect(i?.severity).toBe('medium'); expect(i?.locateValue).toBe('CUSTOM_X'); }); it('F4: a reserved group declared on the table but unused does NOT trigger TBL-6', () => { const tf = { tables: [ { id: 'T', indexGroups: { TRACK_FOR_CHANGES: {} }, columns: { a: { indexGroups: ['SOURCE_ID'] } }, }, ], }; expect(ids(tf)).not.toContain('TBL-6'); }); it('TBL-7 bad column name (high)', () => { const i = run({ tables: [ { id: 'T', columns: { 'Bad Name': { indexGroups: ['SOURCE_ID', 'KEYS'] } }, }, ], }).find((x) => x.ruleId === 'TBL-7'); expect(i?.severity).toBe('high'); expect(i?.locateValue).toBe('Bad Name'); }); it('skips tables without a columns definition and non-object tables', () => { expect(run({ tables: [{ id: 'T' }, null, 'x'] })).toEqual([]); }); }); describe('composeProfile — TBL integrated into the report', () => { it('flags a synced table missing SOURCE_ID as CRITICAL', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tbl-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/T.yaml'), `objectAssociations:\n TASK1:\n table:\n id: T\ntables:\n - id: T\n columns:\n k:\n indexGroups:\n - KEYS\n`, ); await composeProfile(path.join(tmp, 'proj'), false); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); expect(md).toContain('CRITICAL'); expect(md).toContain('TBL-1'); }); });