/** * Unit tests for gateway-doctor.ts (mosaic gateway doctor). * * All external I/O is mocked — no live services required. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { TierHealthReport } from '@mosaicstack/storage'; /* ------------------------------------------------------------------ */ /* Shared mock state */ /* ------------------------------------------------------------------ */ const mocks = vi.hoisted(() => { const mockLoadConfig = vi.fn(); const mockProbeServiceHealth = vi.fn(); const mockExistsSync = vi.fn(); return { mockLoadConfig, mockProbeServiceHealth, mockExistsSync }; }); /* ------------------------------------------------------------------ */ /* Module mocks */ /* ------------------------------------------------------------------ */ vi.mock('@mosaicstack/config', () => ({ loadConfig: mocks.mockLoadConfig, })); vi.mock('@mosaicstack/storage', () => ({ probeServiceHealth: mocks.mockProbeServiceHealth, })); vi.mock('node:fs', () => ({ existsSync: mocks.mockExistsSync, })); /* ------------------------------------------------------------------ */ /* Import SUT */ /* ------------------------------------------------------------------ */ import { runGatewayDoctor } from './gateway-doctor.js'; import type { MosaicConfig } from '@mosaicstack/config'; /* ------------------------------------------------------------------ */ /* Fixtures */ /* ------------------------------------------------------------------ */ const STANDALONE_CONFIG: MosaicConfig = { tier: 'standalone', storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' }, queue: { type: 'bullmq', url: 'redis://localhost:6380' }, memory: { type: 'keyword' }, }; const GREEN_REPORT: TierHealthReport = { tier: 'standalone', configPath: '/some/mosaic.config.json', overall: 'green', services: [ { name: 'postgres', status: 'ok', host: 'localhost', port: 5432, durationMs: 42 }, { name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 10 }, { name: 'pgvector', status: 'skipped', durationMs: 0 }, ], }; const RED_REPORT: TierHealthReport = { tier: 'standalone', configPath: '/some/mosaic.config.json', overall: 'red', services: [ { name: 'postgres', status: 'fail', host: 'localhost', port: 5432, durationMs: 5001, error: { message: 'connection refused', remediation: 'Start Postgres: `docker compose ...`', }, }, { name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 8 }, { name: 'pgvector', status: 'skipped', durationMs: 0 }, ], }; const FEDERATED_GREEN_REPORT: TierHealthReport = { tier: 'federated', configPath: '/some/mosaic.config.json', overall: 'green', services: [ { name: 'postgres', status: 'ok', host: 'localhost', port: 5433, durationMs: 30 }, { name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 5 }, { name: 'pgvector', status: 'ok', host: 'localhost', port: 5433, durationMs: 25 }, ], }; /* ------------------------------------------------------------------ */ /* Process helpers */ /* ------------------------------------------------------------------ */ let stdoutCapture = ''; let exitCode: number | undefined; function captureOutput(): void { stdoutCapture = ''; exitCode = undefined; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutCapture += typeof chunk === 'string' ? chunk : chunk.toString(); return true; }); vi.spyOn(process.stderr, 'write').mockImplementation(() => true); vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { exitCode = typeof code === 'number' ? code : code != null ? Number(code) : undefined; throw new Error(`process.exit(${String(code)})`); }); vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { stdoutCapture += args.join(' ') + '\n'; }); } /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe('runGatewayDoctor', () => { beforeEach(() => { vi.clearAllMocks(); captureOutput(); // By default: no config file on disk (env-detection path) mocks.mockExistsSync.mockReturnValue(false); mocks.mockLoadConfig.mockReturnValue(STANDALONE_CONFIG); mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); }); afterEach(() => { vi.restoreAllMocks(); }); /* ---------------------------------------------------------------- */ /* 1. JSON mode: parseable JSON matching the schema */ /* ---------------------------------------------------------------- */ it('JSON mode emits parseable JSON matching TierHealthReport schema', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); await runGatewayDoctor({ json: true }); const parsed = JSON.parse(stdoutCapture) as TierHealthReport; expect(parsed.tier).toBe('standalone'); expect(parsed.overall).toBe('green'); expect(Array.isArray(parsed.services)).toBe(true); expect(parsed.services).toHaveLength(3); // Validate shape of each service check for (const svc of parsed.services) { expect(['postgres', 'valkey', 'pgvector']).toContain(svc.name); expect(['ok', 'fail', 'skipped']).toContain(svc.status); expect(typeof svc.durationMs).toBe('number'); } // JSON mode must be silent on console.log — output goes to process.stdout only. expect(console.log).not.toHaveBeenCalled(); }); it('JSON mode for federated with 3 ok services', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(FEDERATED_GREEN_REPORT); await runGatewayDoctor({ json: true }); const parsed = JSON.parse(stdoutCapture) as TierHealthReport; expect(parsed.tier).toBe('federated'); expect(parsed.overall).toBe('green'); expect(parsed.services.every((s) => s.status === 'ok')).toBe(true); // JSON mode must be silent on console.log — output goes to process.stdout only. expect(console.log).not.toHaveBeenCalled(); }); /* ---------------------------------------------------------------- */ /* 2. Plain text mode: service lines and overall verdict */ /* ---------------------------------------------------------------- */ it('plain text mode includes service lines for each service', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); await runGatewayDoctor({}); expect(stdoutCapture).toContain('postgres'); expect(stdoutCapture).toContain('valkey'); expect(stdoutCapture).toContain('pgvector'); }); it('plain text mode includes Overall verdict', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); await runGatewayDoctor({}); expect(stdoutCapture).toContain('Overall: GREEN'); }); it('plain text mode shows tier and config path in header', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); await runGatewayDoctor({}); expect(stdoutCapture).toContain('Tier: standalone'); }); it('plain text mode shows remediation for failed services', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT); try { await runGatewayDoctor({}); } catch { // process.exit throws in test } expect(stdoutCapture).toContain('Remediations:'); expect(stdoutCapture).toContain('Start Postgres'); }); /* ---------------------------------------------------------------- */ /* 3. Exit codes */ /* ---------------------------------------------------------------- */ it('exits with code 1 when overall is red', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT); await expect(runGatewayDoctor({})).rejects.toThrow('process.exit(1)'); expect(exitCode).toBe(1); }); it('exits with code 0 (no exit call) when overall is green', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT); await runGatewayDoctor({}); // process.exit should NOT have been called for green. expect(exitCode).toBeUndefined(); }); it('JSON mode exits with code 1 when overall is red', async () => { mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT); await expect(runGatewayDoctor({ json: true })).rejects.toThrow('process.exit(1)'); expect(exitCode).toBe(1); }); /* ---------------------------------------------------------------- */ /* 4. --config path override is honored */ /* ---------------------------------------------------------------- */ it('passes --config path to loadConfig when provided', async () => { const customPath = '/custom/path/mosaic.config.json'; await runGatewayDoctor({ config: customPath }); // loadConfig should have been called with the resolved custom path. expect(mocks.mockLoadConfig).toHaveBeenCalledWith( expect.stringContaining('mosaic.config.json'), ); // The exact call should include the custom path (resolved). const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined]; expect(calledPath).toContain('custom/path/mosaic.config.json'); }); it('calls loadConfig without path when no --config and no file on disk', async () => { mocks.mockExistsSync.mockReturnValue(false); await runGatewayDoctor({}); const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined]; // When no file found, resolveConfigPath returns undefined, so loadConfig is called with undefined expect(calledPath).toBeUndefined(); }); it('finds config from cwd when mosaic.config.json exists there', async () => { // First candidate (cwd/mosaic.config.json) exists. mocks.mockExistsSync.mockImplementation((p: unknown) => { return typeof p === 'string' && p.endsWith('mosaic.config.json'); }); await runGatewayDoctor({}); const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined]; expect(calledPath).toBeDefined(); expect(typeof calledPath).toBe('string'); expect(calledPath!.endsWith('mosaic.config.json')).toBe(true); }); });