Files
stack/packages/mosaic/src/commands/gateway/schema-check.spec.ts
T

182 lines
6.8 KiB
TypeScript

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, unknown>): 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> = {}): 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);
}
});
});