From 347529d358247335344ef2d0240a23f0f889f7ac Mon Sep 17 00:00:00 2001 From: namdamdoi68-oss Date: Thu, 30 Jul 2026 21:26:21 +0700 Subject: [PATCH] test(health): add response shape contract tests for healthy and degraded states Cover response shape contract matching exact keys for healthy (200), indexer lagging (503), DB disconnected (503), and cold-start (200) states. Closes #1051 Signed-off-by: namdamdoi68-oss --- backend/tests/health.test.ts | 203 +++++++++++++++++++++++++++-------- 1 file changed, 160 insertions(+), 43 deletions(-) diff --git a/backend/tests/health.test.ts b/backend/tests/health.test.ts index e2cfb06f..432102ee 100644 --- a/backend/tests/health.test.ts +++ b/backend/tests/health.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import request from 'supertest'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; // Prisma mock — replaced per test to simulate DB up/down and indexer state. // Defined via vi.hoisted so it exists when the hoisted vi.mock factory runs. @@ -12,12 +12,12 @@ const { prismaMock } = vi.hoisted(() => ({ }, })); -vi.mock('../src/lib/prisma.js', () => ({ +vi.mock("../src/lib/prisma.js", () => ({ prisma: prismaMock, default: prismaMock, })); -vi.mock('../src/workers/soroban-event-worker.js', () => ({ +vi.mock("../src/workers/soroban-event-worker.js", () => ({ sorobanEventWorker: { getEventCounters: vi.fn().mockReturnValue({ eventsProcessed: 0, @@ -29,18 +29,18 @@ vi.mock('../src/workers/soroban-event-worker.js', () => ({ SorobanEventWorker: vi.fn(), })); -import app from '../src/app.js'; -import { sorobanEventWorker } from '../src/workers/soroban-event-worker.js'; +import app from "../src/app.js"; +import { sorobanEventWorker } from "../src/workers/soroban-event-worker.js"; function makeState(lagSeconds: number) { const updatedAt = new Date(Date.now() - lagSeconds * 1000); - return { id: 'singleton', updatedAt }; + return { id: "singleton", updatedAt }; } -describe('GET /health', () => { +describe("GET /health", () => { beforeEach(() => { vi.unstubAllEnvs(); - prismaMock.$queryRaw.mockResolvedValue([{ '?column?': 1n }]); + prismaMock.$queryRaw.mockResolvedValue([{ "?column?": 1n }]); prismaMock.indexerState.findUnique.mockResolvedValue(null); vi.mocked(sorobanEventWorker.getEventCounters).mockReturnValue({ eventsProcessed: 0, @@ -54,13 +54,13 @@ describe('GET /health', () => { vi.restoreAllMocks(); }); - it('returns 200 when DB is up and indexer is disabled (no STREAM_CONTRACT_ID)', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', ''); + it("returns 200 when DB is up and indexer is disabled (no STREAM_CONTRACT_ID)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", ""); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); - expect(res.body.db).toBe('connected'); + expect(res.body.status).toBe("ok"); + expect(res.body.db).toBe("connected"); expect(res.body.indexerEnabled).toBe(false); expect(res.body.indexerLag).toBeNull(); expect(res.body.eventsProcessed).toBe(0); @@ -69,75 +69,192 @@ describe('GET /health', () => { expect(res.body.indexerDegraded).toBe(false); }); - it('returns 200 when DB is up and indexer is enabled but has no state row yet (cold start)', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', 'CSOME_CONTRACT_ADDRESS'); + it("returns 200 when DB is up and indexer is enabled but has no state row yet (cold start)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); prismaMock.indexerState.findUnique.mockResolvedValue(null); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); + expect(res.body.status).toBe("ok"); expect(res.body.indexerEnabled).toBe(true); expect(res.body.indexerLag).toBeNull(); }); - it('returns 200 when DB is up, indexer enabled, and lag is within threshold', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', 'CSOME_CONTRACT_ADDRESS'); + it("returns 200 when DB is up, indexer enabled, and lag is within threshold", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); prismaMock.indexerState.findUnique.mockResolvedValue(makeState(30)); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); + expect(res.body.status).toBe("ok"); expect(res.body.indexerLag).toBeGreaterThanOrEqual(0); expect(res.body.indexerLag).toBeLessThanOrEqual(60); }); - it('returns 503 when DB is up, indexer enabled, and lag exceeds 60 s', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', 'CSOME_CONTRACT_ADDRESS'); + it("returns 503 when DB is up, indexer enabled, and lag exceeds 60 s", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); prismaMock.indexerState.findUnique.mockResolvedValue(makeState(120)); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(503); - expect(res.body.status).toBe('degraded'); + expect(res.body.status).toBe("degraded"); expect(res.body.indexerLag).toBeGreaterThan(60); }); - it('returns 503 when DB is down regardless of indexer state', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', ''); - prismaMock.$queryRaw.mockRejectedValue(new Error('DB connection refused')); + it("returns 503 when DB is down regardless of indexer state", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", ""); + prismaMock.$queryRaw.mockRejectedValue(new Error("DB connection refused")); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(503); - expect(res.body.status).toBe('degraded'); - expect(res.body.db).toBe('disconnected'); + expect(res.body.status).toBe("degraded"); + expect(res.body.db).toBe("disconnected"); }); - it('returns 200 with indexerLag in body for observability', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', 'CSOME_CONTRACT_ADDRESS'); + it("returns 200 with indexerLag in body for observability", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); prismaMock.indexerState.findUnique.mockResolvedValue(makeState(10)); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(typeof res.body.indexerLag).toBe('number'); - expect(typeof res.body.uptime).toBe('number'); + expect(typeof res.body.indexerLag).toBe("number"); + expect(typeof res.body.uptime).toBe("number"); }); - it('returns 503 when indexer is enabled and event-processing failures spike (#844)', async () => { - vi.stubEnv('STREAM_CONTRACT_ID', 'CSOME_CONTRACT_ADDRESS'); + it("returns 503 when indexer is enabled and event-processing failures spike (#844)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); prismaMock.indexerState.findUnique.mockResolvedValue(makeState(5)); vi.mocked(sorobanEventWorker.getEventCounters).mockReturnValue({ eventsProcessed: 1, eventsFailed: 10, - lastErrorAt: '2026-07-27T08:00:00.000Z', + lastErrorAt: "2026-07-27T08:00:00.000Z", degraded: true, }); - const res = await request(app).get('/health'); + const res = await request(app).get("/health"); expect(res.status).toBe(503); - expect(res.body.status).toBe('degraded'); + expect(res.body.status).toBe("degraded"); expect(res.body.indexerLag).toBeLessThanOrEqual(60); expect(res.body.eventsProcessed).toBe(1); expect(res.body.eventsFailed).toBe(10); - expect(res.body.lastErrorAt).toBe('2026-07-27T08:00:00.000Z'); + expect(res.body.lastErrorAt).toBe("2026-07-27T08:00:00.000Z"); expect(res.body.indexerDegraded).toBe(true); }); + + describe("response shape contract assertions", () => { + const EXPECTED_HEALTH_KEYS = [ + "db", + "eventsFailed", + "eventsProcessed", + "indexerDegraded", + "indexerEnabled", + "indexerLag", + "lastErrorAt", + "status", + "uptime", + ]; + + it("matches exact response shape contract in healthy state (200 OK)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); + prismaMock.indexerState.findUnique.mockResolvedValue(makeState(15)); + + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + + // Verify exact keys set + expect(Object.keys(res.body).sort()).toEqual(EXPECTED_HEALTH_KEYS); + + // Verify type & contract values + expect(res.body).toEqual({ + status: "ok", + db: "connected", + indexerEnabled: true, + indexerLag: expect.any(Number), + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + indexerDegraded: false, + uptime: expect.any(Number), + }); + expect(res.body.indexerLag).toBe(15); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it("matches exact response shape contract in degraded state (indexer lagging > 60s -> 503)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); + prismaMock.indexerState.findUnique.mockResolvedValue(makeState(120)); + + const res = await request(app).get("/health"); + expect(res.status).toBe(503); + + // Verify exact keys set + expect(Object.keys(res.body).sort()).toEqual(EXPECTED_HEALTH_KEYS); + + // Verify type & contract values + expect(res.body).toEqual({ + status: "degraded", + db: "connected", + indexerEnabled: true, + indexerLag: expect.any(Number), + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + indexerDegraded: false, + uptime: expect.any(Number), + }); + expect(res.body.indexerLag).toBe(120); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it("matches exact response shape contract in degraded state (DB disconnected -> 503)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); + prismaMock.$queryRaw.mockRejectedValue(new Error("DB Connection Error")); + prismaMock.indexerState.findUnique.mockResolvedValue(makeState(5)); + + const res = await request(app).get("/health"); + expect(res.status).toBe(503); + + // Verify exact keys set + expect(Object.keys(res.body).sort()).toEqual(EXPECTED_HEALTH_KEYS); + + // Verify type & contract values + expect(res.body).toEqual({ + status: "degraded", + db: "disconnected", + indexerEnabled: true, + indexerLag: 5, + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + indexerDegraded: false, + uptime: expect.any(Number), + }); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it("matches exact response shape contract in cold-start state (indexerLag is null -> 200 OK)", async () => { + vi.stubEnv("STREAM_CONTRACT_ID", "CSOME_CONTRACT_ADDRESS"); + prismaMock.indexerState.findUnique.mockResolvedValue(null); + + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + + // Verify exact keys set + expect(Object.keys(res.body).sort()).toEqual(EXPECTED_HEALTH_KEYS); + + // Verify type & contract values + expect(res.body).toEqual({ + status: "ok", + db: "connected", + indexerEnabled: true, + indexerLag: null, + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + indexerDegraded: false, + uptime: expect.any(Number), + }); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + }); });