@checkstack/healthcheck-http-backend 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,398 +1,256 @@
1
1
  import { describe, expect, it, spyOn, afterEach } from "bun:test";
2
- import { HttpHealthCheckStrategy, HttpHealthCheckConfig } from "./strategy";
2
+ import { HttpHealthCheckStrategy } from "./strategy";
3
3
 
4
4
  describe("HttpHealthCheckStrategy", () => {
5
5
  const strategy = new HttpHealthCheckStrategy();
6
- const defaultConfig: HttpHealthCheckConfig = {
7
- url: "https://example.com/api",
8
- method: "GET",
9
- timeout: 5000,
10
- };
11
6
 
12
7
  afterEach(() => {
13
8
  spyOn(globalThis, "fetch").mockRestore();
14
9
  });
15
10
 
16
- describe("basic execution", () => {
17
- it("should return healthy for successful response without assertions", async () => {
18
- spyOn(globalThis, "fetch").mockResolvedValue(
19
- new Response(null, { status: 200 })
20
- );
21
- const result = await strategy.execute(defaultConfig);
22
- expect(result.status).toBe("healthy");
23
- expect(result.metadata?.statusCode).toBe(200);
24
- });
11
+ describe("createClient", () => {
12
+ it("should return a connected client", async () => {
13
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
25
14
 
26
- it("should return healthy for any status without status assertion", async () => {
27
- spyOn(globalThis, "fetch").mockResolvedValue(
28
- new Response(null, { status: 404 })
29
- );
30
- const result = await strategy.execute(defaultConfig);
31
- // Without assertions, any response is "healthy" if reachable
32
- expect(result.status).toBe("healthy");
33
- expect(result.metadata?.statusCode).toBe(404);
15
+ expect(connectedClient.client).toBeDefined();
16
+ expect(connectedClient.client.exec).toBeDefined();
17
+ expect(connectedClient.close).toBeDefined();
34
18
  });
35
- });
36
-
37
- describe("statusCode assertions", () => {
38
- it("should pass statusCode equals assertion", async () => {
39
- spyOn(globalThis, "fetch").mockResolvedValue(
40
- new Response(null, { status: 200 })
41
- );
42
19
 
43
- const config: HttpHealthCheckConfig = {
44
- ...defaultConfig,
45
- assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
46
- };
20
+ it("should allow closing the client", async () => {
21
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
47
22
 
48
- const result = await strategy.execute(config);
49
- expect(result.status).toBe("healthy");
50
- });
51
-
52
- it("should fail statusCode equals assertion when mismatch", async () => {
53
- spyOn(globalThis, "fetch").mockResolvedValue(
54
- new Response(null, { status: 404 })
55
- );
56
-
57
- const config: HttpHealthCheckConfig = {
58
- ...defaultConfig,
59
- assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
60
- };
61
-
62
- const result = await strategy.execute(config);
63
- expect(result.status).toBe("unhealthy");
64
- expect(result.message).toContain("statusCode");
65
- });
66
-
67
- it("should pass statusCode lessThan assertion", async () => {
68
- spyOn(globalThis, "fetch").mockResolvedValue(
69
- new Response(null, { status: 201 })
70
- );
71
-
72
- const config: HttpHealthCheckConfig = {
73
- ...defaultConfig,
74
- assertions: [{ field: "statusCode", operator: "lessThan", value: 300 }],
75
- };
76
-
77
- const result = await strategy.execute(config);
78
- expect(result.status).toBe("healthy");
79
- });
80
- });
81
-
82
- describe("responseTime assertions", () => {
83
- it("should pass responseTime assertion when fast", async () => {
84
- spyOn(globalThis, "fetch").mockResolvedValue(
85
- new Response(null, { status: 200 })
86
- );
87
-
88
- const config: HttpHealthCheckConfig = {
89
- ...defaultConfig,
90
- assertions: [
91
- { field: "responseTime", operator: "lessThan", value: 10000 },
92
- ],
93
- };
94
-
95
- const result = await strategy.execute(config);
96
- expect(result.status).toBe("healthy");
23
+ expect(() => connectedClient.close()).not.toThrow();
97
24
  });
98
25
  });
99
26
 
100
- describe("contentType assertions", () => {
101
- it("should pass contentType contains assertion", async () => {
27
+ describe("client.exec", () => {
28
+ it("should return successful response for valid request", async () => {
102
29
  spyOn(globalThis, "fetch").mockResolvedValue(
103
- new Response(JSON.stringify({}), {
30
+ new Response(JSON.stringify({ status: "ok" }), {
104
31
  status: 200,
105
- headers: { "Content-Type": "application/json; charset=utf-8" },
32
+ statusText: "OK",
33
+ headers: { "Content-Type": "application/json" },
106
34
  })
107
35
  );
108
36
 
109
- const config: HttpHealthCheckConfig = {
110
- ...defaultConfig,
111
- assertions: [
112
- { field: "contentType", operator: "contains", value: "json" },
113
- ],
114
- };
115
-
116
- const result = await strategy.execute(config);
117
- expect(result.status).toBe("healthy");
118
- });
119
- });
120
-
121
- describe("header assertions", () => {
122
- it("should pass header exists assertion", async () => {
123
- spyOn(globalThis, "fetch").mockResolvedValue(
124
- new Response(null, {
125
- status: 200,
126
- headers: { "X-Request-Id": "abc123" },
127
- })
128
- );
37
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
38
+ const result = await connectedClient.client.exec({
39
+ url: "https://example.com/api",
40
+ method: "GET",
41
+ timeout: 5000,
42
+ });
129
43
 
130
- const config: HttpHealthCheckConfig = {
131
- ...defaultConfig,
132
- assertions: [
133
- { field: "header", headerName: "X-Request-Id", operator: "exists" },
134
- ],
135
- };
44
+ expect(result.statusCode).toBe(200);
45
+ expect(result.statusText).toBe("OK");
46
+ expect(result.contentType).toContain("application/json");
136
47
 
137
- const result = await strategy.execute(config);
138
- expect(result.status).toBe("healthy");
48
+ connectedClient.close();
139
49
  });
140
50
 
141
- it("should fail header exists assertion when missing", async () => {
51
+ it("should return 404 status for not found", async () => {
142
52
  spyOn(globalThis, "fetch").mockResolvedValue(
143
- new Response(null, { status: 200 })
53
+ new Response(null, { status: 404, statusText: "Not Found" })
144
54
  );
145
55
 
146
- const config: HttpHealthCheckConfig = {
147
- ...defaultConfig,
148
- assertions: [
149
- { field: "header", headerName: "X-Missing", operator: "exists" },
150
- ],
151
- };
56
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
57
+ const result = await connectedClient.client.exec({
58
+ url: "https://example.com/notfound",
59
+ method: "GET",
60
+ timeout: 5000,
61
+ });
152
62
 
153
- const result = await strategy.execute(config);
154
- expect(result.status).toBe("unhealthy");
155
- expect(result.message).toContain("X-Missing");
156
- });
63
+ expect(result.statusCode).toBe(404);
157
64
 
158
- it("should pass header equals assertion", async () => {
159
- spyOn(globalThis, "fetch").mockResolvedValue(
160
- new Response(null, {
161
- status: 200,
162
- headers: { "Cache-Control": "no-cache" },
163
- })
164
- );
165
-
166
- const config: HttpHealthCheckConfig = {
167
- ...defaultConfig,
168
- assertions: [
169
- {
170
- field: "header",
171
- headerName: "Cache-Control",
172
- operator: "equals",
173
- value: "no-cache",
174
- },
175
- ],
176
- };
177
-
178
- const result = await strategy.execute(config);
179
- expect(result.status).toBe("healthy");
65
+ connectedClient.close();
180
66
  });
181
- });
182
67
 
183
- describe("jsonPath assertions", () => {
184
- it("should pass jsonPath equals assertion", async () => {
185
- spyOn(globalThis, "fetch").mockResolvedValue(
186
- new Response(JSON.stringify({ status: "UP" }), {
187
- status: 200,
188
- headers: { "Content-Type": "application/json" },
189
- })
190
- );
191
-
192
- const config: HttpHealthCheckConfig = {
193
- ...defaultConfig,
194
- assertions: [
195
- {
196
- field: "jsonPath",
197
- path: "$.status",
198
- operator: "equals",
199
- value: "UP",
200
- },
201
- ],
202
- };
68
+ it("should send custom headers with request", async () => {
69
+ let capturedHeaders: Record<string, string> | undefined;
70
+ spyOn(globalThis, "fetch").mockImplementation((async (
71
+ _url: RequestInfo | URL,
72
+ options?: RequestInit
73
+ ) => {
74
+ capturedHeaders = options?.headers as Record<string, string>;
75
+ return new Response(null, { status: 200 });
76
+ }) as unknown as typeof fetch);
203
77
 
204
- const result = await strategy.execute(config);
205
- expect(result.status).toBe("healthy");
206
- });
78
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
79
+ await connectedClient.client.exec({
80
+ url: "https://example.com/api",
81
+ method: "GET",
82
+ headers: {
83
+ Authorization: "Bearer my-token",
84
+ "X-Custom-Header": "custom-value",
85
+ },
86
+ timeout: 5000,
87
+ });
207
88
 
208
- it("should fail jsonPath equals assertion", async () => {
209
- spyOn(globalThis, "fetch").mockResolvedValue(
210
- new Response(JSON.stringify({ status: "DOWN" }), {
211
- status: 200,
212
- headers: { "Content-Type": "application/json" },
213
- })
214
- );
215
-
216
- const config: HttpHealthCheckConfig = {
217
- ...defaultConfig,
218
- assertions: [
219
- {
220
- field: "jsonPath",
221
- path: "$.status",
222
- operator: "equals",
223
- value: "UP",
224
- },
225
- ],
226
- };
89
+ expect(capturedHeaders).toBeDefined();
90
+ expect(capturedHeaders?.["Authorization"]).toBe("Bearer my-token");
91
+ expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
227
92
 
228
- const result = await strategy.execute(config);
229
- expect(result.status).toBe("unhealthy");
230
- expect(result.message).toContain("Actual");
93
+ connectedClient.close();
231
94
  });
232
95
 
233
- it("should pass jsonPath exists assertion", async () => {
96
+ it("should return JSON body as string", async () => {
97
+ const responseBody = { foo: "bar", count: 42 };
234
98
  spyOn(globalThis, "fetch").mockResolvedValue(
235
- new Response(JSON.stringify({ version: "1.0.0" }), {
99
+ new Response(JSON.stringify(responseBody), {
236
100
  status: 200,
237
101
  headers: { "Content-Type": "application/json" },
238
102
  })
239
103
  );
240
104
 
241
- const config: HttpHealthCheckConfig = {
242
- ...defaultConfig,
243
- assertions: [
244
- { field: "jsonPath", path: "$.version", operator: "exists" },
245
- ],
246
- };
105
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
106
+ const result = await connectedClient.client.exec({
107
+ url: "https://example.com/api",
108
+ method: "GET",
109
+ timeout: 5000,
110
+ });
247
111
 
248
- const result = await strategy.execute(config);
249
- expect(result.status).toBe("healthy");
250
- });
112
+ expect(result.body).toBe(JSON.stringify(responseBody));
251
113
 
252
- it("should fail jsonPath exists assertion when path not found", async () => {
253
- spyOn(globalThis, "fetch").mockResolvedValue(
254
- new Response(JSON.stringify({ other: "data" }), {
255
- status: 200,
256
- headers: { "Content-Type": "application/json" },
257
- })
258
- );
259
-
260
- const config: HttpHealthCheckConfig = {
261
- ...defaultConfig,
262
- assertions: [
263
- { field: "jsonPath", path: "$.missing", operator: "exists" },
264
- ],
265
- };
266
-
267
- const result = await strategy.execute(config);
268
- expect(result.status).toBe("unhealthy");
114
+ connectedClient.close();
269
115
  });
270
116
 
271
- it("should pass jsonPath contains assertion", async () => {
117
+ it("should handle text body", async () => {
272
118
  spyOn(globalThis, "fetch").mockResolvedValue(
273
- new Response(JSON.stringify({ message: "Hello World" }), {
119
+ new Response("Hello World", {
274
120
  status: 200,
275
- headers: { "Content-Type": "application/json" },
121
+ headers: { "Content-Type": "text/plain" },
276
122
  })
277
123
  );
278
124
 
279
- const config: HttpHealthCheckConfig = {
280
- ...defaultConfig,
281
- assertions: [
282
- {
283
- field: "jsonPath",
284
- path: "$.message",
285
- operator: "contains",
286
- value: "Hello",
287
- },
288
- ],
289
- };
125
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
126
+ const result = await connectedClient.client.exec({
127
+ url: "https://example.com/api",
128
+ method: "GET",
129
+ timeout: 5000,
130
+ });
131
+
132
+ expect(result.body).toBe("Hello World");
290
133
 
291
- const result = await strategy.execute(config);
292
- expect(result.status).toBe("healthy");
134
+ connectedClient.close();
293
135
  });
294
136
 
295
- it("should pass jsonPath matches (regex) assertion", async () => {
296
- spyOn(globalThis, "fetch").mockResolvedValue(
297
- new Response(JSON.stringify({ id: "abc-123" }), {
298
- status: 200,
299
- headers: { "Content-Type": "application/json" },
300
- })
301
- );
137
+ it("should send POST body", async () => {
138
+ let capturedBody: string | undefined;
139
+ spyOn(globalThis, "fetch").mockImplementation((async (
140
+ _url: RequestInfo | URL,
141
+ options?: RequestInit
142
+ ) => {
143
+ capturedBody = options?.body as string;
144
+ return new Response(null, { status: 201 });
145
+ }) as unknown as typeof fetch);
302
146
 
303
- const config: HttpHealthCheckConfig = {
304
- ...defaultConfig,
305
- assertions: [
306
- {
307
- field: "jsonPath",
308
- path: "$.id",
309
- operator: "matches",
310
- value: "^[a-z]{3}-\\d{3}$",
311
- },
312
- ],
313
- };
147
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
148
+ await connectedClient.client.exec({
149
+ url: "https://example.com/api",
150
+ method: "POST",
151
+ body: JSON.stringify({ name: "test" }),
152
+ timeout: 5000,
153
+ });
154
+
155
+ expect(capturedBody).toBe('{"name":"test"}');
314
156
 
315
- const result = await strategy.execute(config);
316
- expect(result.status).toBe("healthy");
157
+ connectedClient.close();
317
158
  });
318
159
 
319
- it("should fail when response is not JSON but jsonPath assertions exist", async () => {
320
- spyOn(globalThis, "fetch").mockResolvedValue(
321
- new Response("Not JSON", {
322
- status: 200,
323
- headers: { "Content-Type": "text/plain" },
324
- })
325
- );
160
+ it("should use correct HTTP method", async () => {
161
+ let capturedMethod: string | undefined;
162
+ spyOn(globalThis, "fetch").mockImplementation((async (
163
+ _url: RequestInfo | URL,
164
+ options?: RequestInit
165
+ ) => {
166
+ capturedMethod = options?.method;
167
+ return new Response(null, { status: 200 });
168
+ }) as unknown as typeof fetch);
169
+
170
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
171
+ await connectedClient.client.exec({
172
+ url: "https://example.com/api",
173
+ method: "DELETE",
174
+ timeout: 5000,
175
+ });
326
176
 
327
- const config: HttpHealthCheckConfig = {
328
- ...defaultConfig,
329
- assertions: [{ field: "jsonPath", path: "$.id", operator: "exists" }],
330
- };
177
+ expect(capturedMethod).toBe("DELETE");
331
178
 
332
- const result = await strategy.execute(config);
333
- expect(result.status).toBe("unhealthy");
334
- expect(result.message).toContain("not valid JSON");
179
+ connectedClient.close();
335
180
  });
336
181
  });
337
182
 
338
- describe("combined assertions", () => {
339
- it("should pass multiple assertion types", async () => {
340
- spyOn(globalThis, "fetch").mockResolvedValue(
341
- new Response(JSON.stringify({ healthy: true }), {
342
- status: 200,
343
- headers: {
344
- "Content-Type": "application/json",
345
- "X-Request-Id": "test-123",
183
+ describe("aggregateResult", () => {
184
+ it("should calculate status code counts correctly", () => {
185
+ const runs = [
186
+ {
187
+ id: "1",
188
+ status: "healthy" as const,
189
+ latencyMs: 100,
190
+ checkId: "c1",
191
+ timestamp: new Date(),
192
+ metadata: {
193
+ statusCode: 200,
346
194
  },
347
- })
348
- );
349
-
350
- const config: HttpHealthCheckConfig = {
351
- ...defaultConfig,
352
- assertions: [
353
- { field: "statusCode", operator: "equals", value: 200 },
354
- { field: "responseTime", operator: "lessThan", value: 10000 },
355
- { field: "contentType", operator: "contains", value: "json" },
356
- { field: "header", headerName: "X-Request-Id", operator: "exists" },
357
- {
358
- field: "jsonPath",
359
- path: "$.healthy",
360
- operator: "equals",
361
- value: "true",
195
+ },
196
+ {
197
+ id: "2",
198
+ status: "healthy" as const,
199
+ latencyMs: 150,
200
+ checkId: "c1",
201
+ timestamp: new Date(),
202
+ metadata: {
203
+ statusCode: 200,
204
+ },
205
+ },
206
+ {
207
+ id: "3",
208
+ status: "healthy" as const,
209
+ latencyMs: 120,
210
+ checkId: "c1",
211
+ timestamp: new Date(),
212
+ metadata: {
213
+ statusCode: 404,
362
214
  },
363
- ],
364
- };
215
+ },
216
+ ];
365
217
 
366
- const result = await strategy.execute(config);
367
- expect(result.status).toBe("healthy");
368
- expect(result.message).toContain("5 assertion");
218
+ const aggregated = strategy.aggregateResult(runs);
219
+
220
+ expect(aggregated.statusCodeCounts["200"]).toBe(2);
221
+ expect(aggregated.statusCodeCounts["404"]).toBe(1);
222
+ expect(aggregated.successRate).toBe(100);
369
223
  });
370
- });
371
224
 
372
- describe("custom request options", () => {
373
- it("should send custom headers with request", async () => {
374
- let capturedHeaders: Record<string, string> | undefined;
375
- spyOn(globalThis, "fetch").mockImplementation((async (
376
- _url: RequestInfo | URL,
377
- options?: RequestInit
378
- ) => {
379
- capturedHeaders = options?.headers as Record<string, string>;
380
- return new Response(null, { status: 200 });
381
- }) as unknown as typeof fetch);
225
+ it("should count errors", () => {
226
+ const runs = [
227
+ {
228
+ id: "1",
229
+ status: "unhealthy" as const,
230
+ latencyMs: 100,
231
+ checkId: "c1",
232
+ timestamp: new Date(),
233
+ metadata: {
234
+ error: "Connection refused",
235
+ },
236
+ },
237
+ {
238
+ id: "2",
239
+ status: "healthy" as const,
240
+ latencyMs: 150,
241
+ checkId: "c1",
242
+ timestamp: new Date(),
243
+ metadata: {
244
+ statusCode: 200,
245
+ responseTime: 100,
246
+ },
247
+ },
248
+ ];
382
249
 
383
- const config: HttpHealthCheckConfig = {
384
- ...defaultConfig,
385
- headers: [
386
- { name: "Authorization", value: "Bearer my-token" },
387
- { name: "X-Custom-Header", value: "custom-value" },
388
- ],
389
- };
250
+ const aggregated = strategy.aggregateResult(runs);
390
251
 
391
- const result = await strategy.execute(config);
392
- expect(result.status).toBe("healthy");
393
- expect(capturedHeaders).toBeDefined();
394
- expect(capturedHeaders?.["Authorization"]).toBe("Bearer my-token");
395
- expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
252
+ expect(aggregated.errorCount).toBe(1);
253
+ expect(aggregated.successRate).toBe(50);
396
254
  });
397
255
  });
398
256
  });