feat(gateway,storage): mosaic gateway doctor with tier health JSON (FED-M1-06) (#475)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #475.
This commit is contained in:
2026-04-20 01:00:39 +00:00
parent ccad30dd27
commit 1a4b1ebbf1
11 changed files with 1233 additions and 289 deletions

View File

@@ -0,0 +1,546 @@
/**
* Unit tests for tier-detection.ts.
*
* All external I/O (postgres, ioredis) is mocked — no live services required.
*
* Note on hoisting: vi.mock() factories are hoisted above all imports by vitest.
* Variables referenced inside factory callbacks must be declared via vi.hoisted()
* so they are available at hoist time.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
/* ------------------------------------------------------------------ */
/* Hoist shared mock state so factories can reference it */
/* ------------------------------------------------------------------ */
const mocks = vi.hoisted(() => {
const mockSqlFn = vi.fn();
const mockEnd = vi.fn().mockResolvedValue(undefined);
const mockPostgresConstructor = vi.fn(() => {
const sql = mockSqlFn as ReturnType<typeof mockSqlFn>;
(sql as unknown as Record<string, unknown>)['end'] = mockEnd;
return sql;
});
const mockRedisConnect = vi.fn().mockResolvedValue(undefined);
const mockRedisPing = vi.fn().mockResolvedValue('PONG');
const mockRedisDisconnect = vi.fn();
const MockRedis = vi.fn().mockImplementation(() => ({
connect: mockRedisConnect,
ping: mockRedisPing,
disconnect: mockRedisDisconnect,
}));
return {
mockSqlFn,
mockEnd,
mockPostgresConstructor,
mockRedisConnect,
mockRedisPing,
mockRedisDisconnect,
MockRedis,
};
});
/* ------------------------------------------------------------------ */
/* Module mocks (registered at hoist time) */
/* ------------------------------------------------------------------ */
vi.mock('postgres', () => ({
default: mocks.mockPostgresConstructor,
}));
vi.mock('ioredis', () => ({
Redis: mocks.MockRedis,
}));
/* ------------------------------------------------------------------ */
/* Import SUT after mocks are registered */
/* ------------------------------------------------------------------ */
import { detectAndAssertTier, probeServiceHealth, TierDetectionError } from './tier-detection.js';
import type { TierConfig } from './tier-detection.js';
/* ------------------------------------------------------------------ */
/* Config fixtures */
/* ------------------------------------------------------------------ */
const LOCAL_CONFIG: TierConfig = {
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/pglite' },
queue: { type: 'local' },
};
const STANDALONE_CONFIG: TierConfig = {
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@db-host:5432/mosaic' },
queue: { type: 'bullmq', url: 'redis://valkey-host:6380' },
};
const FEDERATED_CONFIG: TierConfig = {
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://mosaic:mosaic@db-host:5433/mosaic',
enableVector: true,
},
queue: { type: 'bullmq', url: 'redis://valkey-host:6380' },
};
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('detectAndAssertTier', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: all probes succeed.
mocks.mockSqlFn.mockResolvedValue([]);
mocks.mockEnd.mockResolvedValue(undefined);
mocks.mockRedisConnect.mockResolvedValue(undefined);
mocks.mockRedisPing.mockResolvedValue('PONG');
// Re-wire constructor to return a fresh sql-like object each time.
mocks.mockPostgresConstructor.mockImplementation(() => {
const sql = mocks.mockSqlFn as ReturnType<typeof mocks.mockSqlFn>;
(sql as unknown as Record<string, unknown>)['end'] = mocks.mockEnd;
return sql;
});
mocks.MockRedis.mockImplementation(() => ({
connect: mocks.mockRedisConnect,
ping: mocks.mockRedisPing,
disconnect: mocks.mockRedisDisconnect,
}));
});
/* ---------------------------------------------------------------- */
/* 1. local — no-op */
/* ---------------------------------------------------------------- */
it('resolves immediately for tier=local without touching postgres or ioredis', async () => {
await expect(detectAndAssertTier(LOCAL_CONFIG)).resolves.toBeUndefined();
expect(mocks.mockPostgresConstructor).not.toHaveBeenCalled();
expect(mocks.MockRedis).not.toHaveBeenCalled();
});
/* ---------------------------------------------------------------- */
/* 2. standalone — happy path */
/* ---------------------------------------------------------------- */
it('resolves for tier=standalone when postgres and valkey are reachable', async () => {
await expect(detectAndAssertTier(STANDALONE_CONFIG)).resolves.toBeUndefined();
// Postgres was probed (SELECT 1 only — no pgvector check).
expect(mocks.mockPostgresConstructor).toHaveBeenCalledTimes(1);
expect(mocks.mockSqlFn).toHaveBeenCalledTimes(1);
// Valkey was probed.
expect(mocks.MockRedis).toHaveBeenCalledTimes(1);
expect(mocks.mockRedisPing).toHaveBeenCalledTimes(1);
});
/* ---------------------------------------------------------------- */
/* 3. standalone — postgres unreachable */
/* ---------------------------------------------------------------- */
it('throws TierDetectionError with service=postgres when postgres query rejects', async () => {
mocks.mockSqlFn.mockRejectedValueOnce(new Error('connection refused'));
const promise = detectAndAssertTier(STANDALONE_CONFIG);
await expect(promise).rejects.toBeInstanceOf(TierDetectionError);
// Confirm no valkey probe happened (fail fast on first error).
expect(mocks.MockRedis).not.toHaveBeenCalled();
});
it('sets service=postgres on the error when postgres fails', async () => {
mocks.mockSqlFn.mockRejectedValue(new Error('connection refused'));
try {
await detectAndAssertTier(STANDALONE_CONFIG);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('postgres');
expect(typed.remediation).toContain('docker compose');
}
});
/* ---------------------------------------------------------------- */
/* 4. standalone — valkey unreachable */
/* ---------------------------------------------------------------- */
it('throws TierDetectionError with service=valkey when ping fails', async () => {
// Postgres probe succeeds; valkey connect fails.
mocks.mockSqlFn.mockResolvedValue([]);
mocks.mockRedisConnect.mockRejectedValue(new Error('ECONNREFUSED'));
try {
await detectAndAssertTier(STANDALONE_CONFIG);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('valkey');
expect(typed.message).toContain('valkey');
expect(typed.remediation).toContain('valkey-federated');
}
});
/* ---------------------------------------------------------------- */
/* 5. federated — happy path */
/* ---------------------------------------------------------------- */
it('resolves for tier=federated when all three checks pass', async () => {
// SELECT 1 and CREATE EXTENSION both succeed.
mocks.mockSqlFn.mockResolvedValue([]);
await expect(detectAndAssertTier(FEDERATED_CONFIG)).resolves.toBeUndefined();
// postgres probe (SELECT 1) + pgvector probe (CREATE EXTENSION) = 2 postgres constructors.
expect(mocks.mockPostgresConstructor).toHaveBeenCalledTimes(2);
expect(mocks.mockSqlFn).toHaveBeenCalledTimes(2);
// Valkey probed once.
expect(mocks.MockRedis).toHaveBeenCalledTimes(1);
});
/* ---------------------------------------------------------------- */
/* 6. federated — pgvector not installable */
/* ---------------------------------------------------------------- */
it('throws TierDetectionError with service=pgvector when CREATE EXTENSION fails', async () => {
// SELECT 1 succeeds (first call), CREATE EXTENSION fails (second call).
mocks.mockSqlFn
.mockResolvedValueOnce([]) // SELECT 1
.mockRejectedValueOnce(new Error('extension "vector" is not available'));
try {
await detectAndAssertTier(FEDERATED_CONFIG);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('pgvector');
expect(typed.message).toContain('pgvector');
expect(typed.remediation).toContain('pgvector/pgvector');
}
});
/* ---------------------------------------------------------------- */
/* 7. probeValkey honors connectTimeout and lazyConnect */
/* ---------------------------------------------------------------- */
it('constructs the ioredis Redis client with connectTimeout: 5000', async () => {
await detectAndAssertTier(STANDALONE_CONFIG);
expect(mocks.MockRedis).toHaveBeenCalledOnce();
expect(mocks.MockRedis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ connectTimeout: 5000, lazyConnect: true }),
);
});
/* ---------------------------------------------------------------- */
/* 8. probePgvector — library-not-installed remediation */
/* ---------------------------------------------------------------- */
it('includes pgvector/pgvector:pg17 in remediation when pgvector library is missing', async () => {
// SELECT 1 succeeds; CREATE EXTENSION fails with the canonical library-missing message.
mocks.mockSqlFn
.mockResolvedValueOnce([]) // SELECT 1 (probePostgres)
.mockRejectedValueOnce(new Error('extension "vector" is not available')); // probePgvector
try {
await detectAndAssertTier(FEDERATED_CONFIG);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('pgvector');
expect(typed.remediation).toContain('pgvector/pgvector:pg17');
}
});
/* ---------------------------------------------------------------- */
/* 9. probePgvector — permission / other error remediation */
/* ---------------------------------------------------------------- */
it('mentions CREATE permission or superuser in remediation for a generic pgvector error', async () => {
// SELECT 1 succeeds; CREATE EXTENSION fails with a permission error.
mocks.mockSqlFn
.mockResolvedValueOnce([]) // SELECT 1 (probePostgres)
.mockRejectedValueOnce(new Error('permission denied to create extension'));
try {
await detectAndAssertTier(FEDERATED_CONFIG);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('pgvector');
// Must NOT point to the image fix — that's only for the library-missing case.
expect(typed.remediation).not.toContain('pgvector/pgvector:pg17');
// Must mention permissions or superuser.
expect(typed.remediation).toMatch(/CREATE|superuser/i);
}
});
/* ---------------------------------------------------------------- */
/* 10. federated tier rejects non-bullmq queue.type */
/* ---------------------------------------------------------------- */
it('throws TierDetectionError with service=config for federated tier with queue.type !== bullmq', async () => {
const badConfig: TierConfig = {
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://mosaic:mosaic@db-host:5433/mosaic',
enableVector: true,
},
queue: { type: 'local' },
};
try {
await detectAndAssertTier(badConfig);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(TierDetectionError);
const typed = err as TierDetectionError;
expect(typed.service).toBe('config');
expect(typed.remediation).toContain('bullmq');
}
// No network probes should have been attempted.
expect(mocks.mockPostgresConstructor).not.toHaveBeenCalled();
expect(mocks.MockRedis).not.toHaveBeenCalled();
});
/* ---------------------------------------------------------------- */
/* 11. Error fields populated */
/* ---------------------------------------------------------------- */
it('populates host, port, and remediation on a thrown TierDetectionError', async () => {
mocks.mockSqlFn.mockRejectedValue(new Error('connection refused'));
let caught: TierDetectionError | undefined;
try {
await detectAndAssertTier(STANDALONE_CONFIG);
} catch (err) {
caught = err as TierDetectionError;
}
expect(caught).toBeInstanceOf(TierDetectionError);
expect(caught!.service).toBe('postgres');
// Host and port are extracted from the Postgres URL in STANDALONE_CONFIG.
expect(caught!.host).toBe('db-host');
expect(caught!.port).toBe(5432);
expect(caught!.remediation).toMatch(/docker compose/i);
expect(caught!.message).toContain('db-host:5432');
});
});
/* ------------------------------------------------------------------ */
/* probeServiceHealth tests */
/* ------------------------------------------------------------------ */
describe('probeServiceHealth', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.mockSqlFn.mockResolvedValue([]);
mocks.mockEnd.mockResolvedValue(undefined);
mocks.mockRedisConnect.mockResolvedValue(undefined);
mocks.mockRedisPing.mockResolvedValue('PONG');
mocks.mockPostgresConstructor.mockImplementation(() => {
const sql = mocks.mockSqlFn as ReturnType<typeof mocks.mockSqlFn>;
(sql as unknown as Record<string, unknown>)['end'] = mocks.mockEnd;
return sql;
});
mocks.MockRedis.mockImplementation(() => ({
connect: mocks.mockRedisConnect,
ping: mocks.mockRedisPing,
disconnect: mocks.mockRedisDisconnect,
}));
});
/* ---------------------------------------------------------------- */
/* 12. local tier — all skipped, green */
/* ---------------------------------------------------------------- */
it('returns all services as skipped and overall green for local tier', async () => {
const report = await probeServiceHealth(LOCAL_CONFIG);
expect(report.tier).toBe('local');
expect(report.overall).toBe('green');
expect(report.services).toHaveLength(3);
for (const svc of report.services) {
expect(svc.status).toBe('skipped');
}
expect(mocks.mockPostgresConstructor).not.toHaveBeenCalled();
expect(mocks.MockRedis).not.toHaveBeenCalled();
});
/* ---------------------------------------------------------------- */
/* 13. postgres fails, valkey ok → red */
/* ---------------------------------------------------------------- */
it('returns red overall with postgres fail and valkey ok for standalone when postgres fails', async () => {
mocks.mockSqlFn.mockRejectedValue(new Error('connection refused'));
const report = await probeServiceHealth(STANDALONE_CONFIG);
expect(report.overall).toBe('red');
const pgCheck = report.services.find((s) => s.name === 'postgres');
expect(pgCheck?.status).toBe('fail');
expect(pgCheck?.error).toBeDefined();
expect(pgCheck?.error?.remediation).toContain('docker compose');
const vkCheck = report.services.find((s) => s.name === 'valkey');
expect(vkCheck?.status).toBe('ok');
});
/* ---------------------------------------------------------------- */
/* 14. federated all green → 3 services ok */
/* ---------------------------------------------------------------- */
it('returns green overall with all 3 services ok for federated when all pass', async () => {
mocks.mockSqlFn.mockResolvedValue([]);
const report = await probeServiceHealth(FEDERATED_CONFIG);
expect(report.tier).toBe('federated');
expect(report.overall).toBe('green');
expect(report.services).toHaveLength(3);
for (const svc of report.services) {
expect(svc.status).toBe('ok');
}
});
/* ---------------------------------------------------------------- */
/* 15. durationMs is a non-negative number for every service check */
/* ---------------------------------------------------------------- */
it('sets durationMs as a non-negative number for every service check', async () => {
mocks.mockSqlFn.mockResolvedValue([]);
const report = await probeServiceHealth(FEDERATED_CONFIG);
for (const svc of report.services) {
expect(typeof svc.durationMs).toBe('number');
expect(svc.durationMs).toBeGreaterThanOrEqual(0);
}
});
it('sets durationMs >= 0 even for skipped services (local tier)', async () => {
const report = await probeServiceHealth(LOCAL_CONFIG);
for (const svc of report.services) {
expect(typeof svc.durationMs).toBe('number');
expect(svc.durationMs).toBeGreaterThanOrEqual(0);
}
});
/* ---------------------------------------------------------------- */
/* 16. configPath is passed through to the report */
/* ---------------------------------------------------------------- */
it('includes configPath in the report when provided', async () => {
const report = await probeServiceHealth(LOCAL_CONFIG, '/etc/mosaic/mosaic.config.json');
expect(report.configPath).toBe('/etc/mosaic/mosaic.config.json');
});
/* ---------------------------------------------------------------- */
/* 17. standalone — valkey fails, postgres ok → red */
/* ---------------------------------------------------------------- */
it('returns red with valkey fail and postgres ok for standalone when valkey fails', async () => {
mocks.mockSqlFn.mockResolvedValue([]);
mocks.mockRedisConnect.mockRejectedValue(new Error('ECONNREFUSED'));
const report = await probeServiceHealth(STANDALONE_CONFIG);
expect(report.overall).toBe('red');
const pgCheck = report.services.find((s) => s.name === 'postgres');
expect(pgCheck?.status).toBe('ok');
const vkCheck = report.services.find((s) => s.name === 'valkey');
expect(vkCheck?.status).toBe('fail');
expect(vkCheck?.error).toBeDefined();
});
/* ---------------------------------------------------------------- */
/* 18. federated — pgvector fails → red with remediation */
/* ---------------------------------------------------------------- */
it('returns red with pgvector fail for federated when pgvector probe fails', async () => {
mocks.mockSqlFn
.mockResolvedValueOnce([]) // postgres SELECT 1
.mockRejectedValueOnce(new Error('extension "vector" is not available')); // pgvector
const report = await probeServiceHealth(FEDERATED_CONFIG);
expect(report.overall).toBe('red');
const pvCheck = report.services.find((s) => s.name === 'pgvector');
expect(pvCheck?.status).toBe('fail');
expect(pvCheck?.error?.remediation).toContain('pgvector/pgvector:pg17');
});
/* ---------------------------------------------------------------- */
/* 19. federated — non-bullmq queue → red config error, no network */
/* ---------------------------------------------------------------- */
it('returns red overall with config error when federated tier has non-bullmq queue (no network call)', async () => {
const federatedBadQueueConfig: TierConfig = {
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://mosaic:mosaic@db-host:5433/mosaic',
enableVector: true,
},
queue: { type: 'local' },
};
const report = await probeServiceHealth(federatedBadQueueConfig);
expect(report.overall).toBe('red');
const valkey = report.services.find((s) => s.name === 'valkey');
expect(valkey?.status).toBe('fail');
expect(valkey?.error?.remediation).toMatch(/bullmq/i);
// Critically: no network call was made — MockRedis constructor must NOT have been called.
expect(mocks.MockRedis).not.toHaveBeenCalled();
});
/* ---------------------------------------------------------------- */
/* 20. durationMs actually measures real elapsed time */
/* ---------------------------------------------------------------- */
it('measures real elapsed time for service probes', async () => {
const DELAY_MS = 25;
// Make the postgres mock introduce a real wall-clock delay.
mocks.mockSqlFn.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => {
resolve([]);
}, DELAY_MS),
),
);
const report = await probeServiceHealth(STANDALONE_CONFIG);
const pgCheck = report.services.find((s) => s.name === 'postgres');
expect(pgCheck).toBeDefined();
// Must be >= 20ms (small slack for jitter). Would be 0 if timer were stubbed.
expect(pgCheck!.durationMs).toBeGreaterThanOrEqual(20);
});
});