@checkstack/healthcheck-http-backend 0.0.3 → 0.1.1

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,246 @@
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
- });
25
-
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);
34
- });
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
-
43
- const config: HttpHealthCheckConfig = {
44
- ...defaultConfig,
45
- assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
46
- };
47
-
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
- };
11
+ describe("createClient", () => {
12
+ it("should return a connected client", async () => {
13
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
76
14
 
77
- const result = await strategy.execute(config);
78
- expect(result.status).toBe("healthy");
15
+ expect(connectedClient.client).toBeDefined();
16
+ expect(connectedClient.client.exec).toBeDefined();
17
+ expect(connectedClient.close).toBeDefined();
79
18
  });
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
19
 
88
- const config: HttpHealthCheckConfig = {
89
- ...defaultConfig,
90
- assertions: [
91
- { field: "responseTime", operator: "lessThan", value: 10000 },
92
- ],
93
- };
20
+ it("should allow closing the client", async () => {
21
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
94
22
 
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
- };
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
+ });
115
43
 
116
- const result = await strategy.execute(config);
117
- expect(result.status).toBe("healthy");
118
- });
119
- });
44
+ expect(result.statusCode).toBe(200);
45
+ expect(result.statusText).toBe("OK");
46
+ expect(result.contentType).toContain("application/json");
120
47
 
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
- );
129
-
130
- const config: HttpHealthCheckConfig = {
131
- ...defaultConfig,
132
- assertions: [
133
- { field: "header", headerName: "X-Request-Id", operator: "exists" },
134
- ],
135
- };
136
-
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
- };
152
-
153
- const result = await strategy.execute(config);
154
- expect(result.status).toBe("unhealthy");
155
- expect(result.message).toContain("X-Missing");
156
- });
157
-
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
- );
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
+ });
165
62
 
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
- };
63
+ expect(result.statusCode).toBe(404);
177
64
 
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
- };
203
-
204
- const result = await strategy.execute(config);
205
- expect(result.status).toBe("healthy");
206
- });
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);
207
77
 
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
- );
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
+ });
215
88
 
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
- };
247
-
248
- const result = await strategy.execute(config);
249
- expect(result.status).toBe("healthy");
250
- });
251
-
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
- );
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
+ });
259
111
 
260
- const config: HttpHealthCheckConfig = {
261
- ...defaultConfig,
262
- assertions: [
263
- { field: "jsonPath", path: "$.missing", operator: "exists" },
264
- ],
265
- };
112
+ expect(result.body).toBe(JSON.stringify(responseBody));
266
113
 
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
- };
290
-
291
- const result = await strategy.execute(config);
292
- expect(result.status).toBe("healthy");
293
- });
294
-
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
- );
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
+ });
302
131
 
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
- };
132
+ expect(result.body).toBe("Hello World");
314
133
 
315
- const result = await strategy.execute(config);
316
- expect(result.status).toBe("healthy");
134
+ connectedClient.close();
317
135
  });
318
136
 
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
- );
326
-
327
- const config: HttpHealthCheckConfig = {
328
- ...defaultConfig,
329
- assertions: [{ field: "jsonPath", path: "$.id", operator: "exists" }],
330
- };
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);
331
146
 
332
- const result = await strategy.execute(config);
333
- expect(result.status).toBe("unhealthy");
334
- expect(result.message).toContain("not valid JSON");
335
- });
336
- });
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
+ });
337
154
 
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",
346
- },
347
- })
348
- );
155
+ expect(capturedBody).toBe('{"name":"test"}');
349
156
 
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",
362
- },
363
- ],
364
- };
365
-
366
- const result = await strategy.execute(config);
367
- expect(result.status).toBe("healthy");
368
- expect(result.message).toContain("5 assertion");
157
+ connectedClient.close();
369
158
  });
370
- });
371
159
 
372
- describe("custom request options", () => {
373
- it("should send custom headers with request", async () => {
374
- let capturedHeaders: Record<string, string> | undefined;
160
+ it("should use correct HTTP method", async () => {
161
+ let capturedMethod: string | undefined;
375
162
  spyOn(globalThis, "fetch").mockImplementation((async (
376
163
  _url: RequestInfo | URL,
377
164
  options?: RequestInit
378
165
  ) => {
379
- capturedHeaders = options?.headers as Record<string, string>;
166
+ capturedMethod = options?.method;
380
167
  return new Response(null, { status: 200 });
381
168
  }) as unknown as typeof fetch);
382
169
 
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
- };
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
+ });
390
176
 
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");
177
+ expect(capturedMethod).toBe("DELETE");
178
+
179
+ connectedClient.close();
180
+ });
181
+ });
182
+
183
+ describe("aggregateResult", () => {
184
+ it("should count errors correctly", () => {
185
+ const runs = [
186
+ {
187
+ id: "1",
188
+ status: "unhealthy" as const,
189
+ latencyMs: 100,
190
+ checkId: "c1",
191
+ timestamp: new Date(),
192
+ metadata: {
193
+ error: "Connection refused",
194
+ },
195
+ },
196
+ {
197
+ id: "2",
198
+ status: "healthy" as const,
199
+ latencyMs: 150,
200
+ checkId: "c1",
201
+ timestamp: new Date(),
202
+ metadata: {},
203
+ },
204
+ {
205
+ id: "3",
206
+ status: "unhealthy" as const,
207
+ latencyMs: 120,
208
+ checkId: "c1",
209
+ timestamp: new Date(),
210
+ metadata: {
211
+ error: "Timeout",
212
+ },
213
+ },
214
+ ];
215
+
216
+ const aggregated = strategy.aggregateResult(runs);
217
+
218
+ expect(aggregated.errorCount).toBe(2);
219
+ });
220
+
221
+ it("should return zero errors when all runs succeed", () => {
222
+ const runs = [
223
+ {
224
+ id: "1",
225
+ status: "healthy" as const,
226
+ latencyMs: 100,
227
+ checkId: "c1",
228
+ timestamp: new Date(),
229
+ metadata: {},
230
+ },
231
+ {
232
+ id: "2",
233
+ status: "healthy" as const,
234
+ latencyMs: 150,
235
+ checkId: "c1",
236
+ timestamp: new Date(),
237
+ metadata: {},
238
+ },
239
+ ];
240
+
241
+ const aggregated = strategy.aggregateResult(runs);
242
+
243
+ expect(aggregated.errorCount).toBe(0);
396
244
  });
397
245
  });
398
246
  });