import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { checkDatabaseSchema, SCHEMA_FAIL_REMEDIATION, type SchemaCheckDeps, } from './schema-check.js'; /* ------------------------------------------------------------------ */ /* Fixture config */ /* ------------------------------------------------------------------ */ const tmpDirs: string[] = []; function writeConfig(cfg: Record): string { const dir = mkdtempSync(join(tmpdir(), 'schema-check-')); tmpDirs.push(dir); const path = join(dir, 'mosaic.config.json'); writeFileSync(path, JSON.stringify(cfg)); return path; } const STANDALONE_CFG = { tier: 'standalone', storage: { type: 'postgres', url: 'postgresql://u:p@localhost:5434/x' }, queue: { type: 'bullmq', url: 'redis://localhost:6380' }, memory: { type: 'keyword' }, }; const LOCAL_CFG = { tier: 'local', storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' }, queue: { type: 'local', dataDir: '.mosaic/queue' }, memory: { type: 'keyword' }, }; function deps(overrides: Partial = {}): SchemaCheckDeps { return { runMigrations: vi.fn().mockResolvedValue(undefined), getMigrationStatus: vi.fn().mockResolvedValue({ appliedCount: 17, expectedCount: 17, expectedLastTag: '0016_x', complete: true, }), ...overrides, }; } /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe('checkDatabaseSchema', () => { beforeEach(() => { process.env['MOSAIC_CONFIG'] = ''; vi.stubEnv('DATABASE_URL', 'postgresql://env:env@localhost:9999/env'); }); afterEach(() => { vi.unstubAllEnvs(); for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); it('passes when the ledger matches the shipped journal', async () => { const cfg = writeConfig(STANDALONE_CFG); const d = deps(); const result = await checkDatabaseSchema(d, cfg); expect(result.status).toBe('ok'); expect(result.detail).toContain('17/17'); expect(d.runMigrations).toHaveBeenCalledWith(STANDALONE_CFG.storage.url); expect(d.getMigrationStatus).toHaveBeenCalledWith(STANDALONE_CFG.storage.url); }); it('FAILS when the ledger is incomplete — the #1389 empty-database signature', async () => { const cfg = writeConfig(STANDALONE_CFG); const d = deps({ getMigrationStatus: vi.fn().mockResolvedValue({ appliedCount: 0, expectedCount: 17, expectedLastTag: '0016_x', complete: false, }), }); const result = await checkDatabaseSchema(d, cfg); expect(result.status).toBe('fail'); if (result.status !== 'fail') throw new Error('expected fail'); expect(result.detail).toContain('0/17'); expect(result.remediation).toBe(SCHEMA_FAIL_REMEDIATION); expect(result.remediation).toContain('#1389'); }); it('FAILS when the ledger is only PARTIALLY migrated (#1402 upgrade case)', async () => { const cfg = writeConfig(STANDALONE_CFG); const d = deps({ getMigrationStatus: vi.fn().mockResolvedValue({ appliedCount: 9, expectedCount: 17, expectedLastTag: '0016_x', complete: false, }), }); const result = await checkDatabaseSchema(d, cfg); expect(result.status).toBe('fail'); expect(result.detail).toContain('9/17'); }); it('FAILS (never crashes) when the migration run itself throws', async () => { const cfg = writeConfig(STANDALONE_CFG); const d = deps({ runMigrations: vi.fn().mockRejectedValue(new Error('connection refused')), }); const result = await checkDatabaseSchema(d, cfg); expect(result.status).toBe('fail'); expect(result.detail).toContain('connection refused'); }); it('skips the local tier (gateway migrates its own PGlite at startup)', async () => { const cfg = writeConfig(LOCAL_CFG); const d = deps(); const result = await checkDatabaseSchema(d, cfg); expect(result.status).toBe('skipped'); expect(d.runMigrations).not.toHaveBeenCalled(); }); }); /* ------------------------------------------------------------------ */ /* Config resolution priority */ /* ------------------------------------------------------------------ */ describe('resolveSchemaCheckConfigPath', () => { it('prefers the daemon-written gateway config over cwd copies', async () => { const { resolveSchemaCheckConfigPath } = await import('./schema-check.js'); const cwdCfg = writeConfig(STANDALONE_CFG); // in a temp dir const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-daemon-')); tmpDirs.push(daemonDir); const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway'); mkdirSync(daemonHome, { recursive: true }); writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG)); const prevHome = process.env['HOME']; vi.stubEnv('HOME', daemonDir); vi.stubEnv('MOSAIC_CONFIG', ''); try { const resolved = resolveSchemaCheckConfigPath(); // Must NOT pick the cwd copy (cwd is the vitest project dir, not our // temp dir — so the only resolvable candidates are daemon + $HOME/.mosaic). expect(resolved).toBe(join(daemonHome, 'mosaic.config.json')); void cwdCfg; } finally { if (prevHome !== undefined) vi.stubEnv('HOME', prevHome); } }); it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => { const { resolveSchemaCheckConfigPath } = await import('./schema-check.js'); const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-')); tmpDirs.push(daemonDir); const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway'); mkdirSync(daemonHome, { recursive: true }); writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG)); // A stale env var pointing at a DIFFERENT file must be ignored entirely: const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-')); tmpDirs.push(decoyDir); const decoyPath = join(decoyDir, 'mosaic.config.json'); writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG)); const prevHome = process.env['HOME']; vi.stubEnv('HOME', daemonDir); vi.stubEnv('MOSAIC_CONFIG', decoyPath); try { const resolved = resolveSchemaCheckConfigPath(); expect(resolved).toBe(join(daemonHome, 'mosaic.config.json')); expect(resolved).not.toBe(decoyPath); } finally { if (prevHome !== undefined) vi.stubEnv('HOME', prevHome); } }); });