295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|