1
21import type { Server } from "node:http";
22import { describe, expect, it } from "vitest";
23import { createHealthServer, type SearchIndexLagResult } from "../health/server.js";
24
25interface LagBody extends SearchIndexLagResult {}
26
27interface Started {
28 server: Server;
29 base: string;
30}
31
32
33async function start(deps: Parameters<typeof createHealthServer>[0]): Promise<Started> {
34 const server = createHealthServer(deps);
35 await new Promise<void>((resolve, reject) => {
36 server.once("error", reject);
37 server.listen(0, "127.0.0.1", () => resolve());
38 });
39 const addr = server.address();
40 if (addr === null || typeof addr === "string") throw new Error("no ephemeral port");
41 return { server, base: `http://127.0.0.1:${addr.port}` };
42}
43
44async function stop(s: Started): Promise<void> {
45 await new Promise<void>((resolve) => {
46 s.server.closeAllConnections?.();
47 s.server.close(() => resolve());
48 });
49}
50
51async function getLag(base: string): Promise<{ status: number; body: LagBody }> {
52 const res = await fetch(`${base}/health/search-index:lag`);
53 return { status: res.status, body: (await res.json()) as LagBody };
54}
55
56async function getHealth(base: string): Promise<number> {
57 const res = await fetch(`${base}/health`);
58 return res.status;
59}
60
61const pingOk = async (): Promise<boolean> => true;
62
63describe("GET /health/search-index:lag (TASK-0100 S4)", () => {
64 it("wired probe → 200 available:true with backlog = pending + lagEntries", async () => {
65 const s = await start({
66 pingRedis: pingOk,
67 checkSearchIndexLag: async () => ({ pending: 3, lagEntries: 2 }),
68 });
69 try {
70 const { status, body } = await getLag(s.base);
71 expect(status).toBe(200);
72 expect(body.metric).toBe("search-index:lag");
73 expect(body.stream).toBe("factory:events");
74 expect(body.group).toBe("worker-search-index");
75 expect(body.available).toBe(true);
76 expect(body.pending).toBe(3);
77 expect(body.lagEntries).toBe(2);
78 expect(body.backlog).toBe(5);
79 expect(typeof body.timestamp).toBe("string");
80
81 expect(await getHealth(s.base)).toBe(200);
82 } finally {
83 await stop(s);
84 }
85 });
86
87 it("probe absent → 200 available:false with zeroed counts (callers skip)", async () => {
88 const s = await start({ pingRedis: pingOk });
89 try {
90 const { status, body } = await getLag(s.base);
91 expect(status).toBe(200);
92 expect(body.available).toBe(false);
93 expect(body.pending).toBe(0);
94 expect(body.lagEntries).toBe(0);
95 expect(body.backlog).toBe(0);
96 expect(await getHealth(s.base)).toBe(200);
97 } finally {
98 await stop(s);
99 }
100 });
101
102 it("probe throws → 200 available:false with zeroed counts (soft-fail, never 5xx)", async () => {
103 const s = await start({
104 pingRedis: pingOk,
105 checkSearchIndexLag: async () => {
106 throw new Error("redis down");
107 },
108 });
109 try {
110 const { status, body } = await getLag(s.base);
111 expect(status).toBe(200);
112 expect(body.available).toBe(false);
113 expect(body.pending).toBe(0);
114 expect(body.lagEntries).toBe(0);
115 expect(body.backlog).toBe(0);
116 expect(await getHealth(s.base)).toBe(200);
117 } finally {
118 await stop(s);
119 }
120 });
121
122 it("hanging probe (past the 1.5 s timeout) → 200 available:false (soft-fail)", async () => {
123 const s = await start({
124 pingRedis: pingOk,
125 checkSearchIndexLag: () => new Promise(() => {}),
126 });
127 try {
128 const t0 = Date.now();
129 const { status, body } = await getLag(s.base);
130
131 expect(status).toBe(200);
132 expect(body.available).toBe(false);
133 expect(body.backlog).toBe(0);
134 expect(Date.now() - t0).toBeLessThan(4_000);
135 } finally {
136 await stop(s);
137 }
138 });
139});
140