feat(gateway,storage): mosaic gateway doctor with tier health JSON (FED-M1-06) (#475)
This commit was merged in pull request #475.
This commit is contained in:
294
packages/mosaic/src/commands/gateway-doctor.spec.ts
Normal file
294
packages/mosaic/src/commands/gateway-doctor.spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user