/** * 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; (sql as unknown as Record)['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; (sql as unknown as Record)['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; (sql as unknown as Record)['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); }); });