@thebookingkit/server 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.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/.turbo/turbo-test.log +20 -0
  3. package/CHANGELOG.md +9 -0
  4. package/dist/__tests__/api.test.d.ts +2 -0
  5. package/dist/__tests__/api.test.d.ts.map +1 -0
  6. package/dist/__tests__/api.test.js +280 -0
  7. package/dist/__tests__/api.test.js.map +1 -0
  8. package/dist/__tests__/auth.test.d.ts +2 -0
  9. package/dist/__tests__/auth.test.d.ts.map +1 -0
  10. package/dist/__tests__/auth.test.js +78 -0
  11. package/dist/__tests__/auth.test.js.map +1 -0
  12. package/dist/__tests__/concurrent-booking.test.d.ts +2 -0
  13. package/dist/__tests__/concurrent-booking.test.d.ts.map +1 -0
  14. package/dist/__tests__/concurrent-booking.test.js +111 -0
  15. package/dist/__tests__/concurrent-booking.test.js.map +1 -0
  16. package/dist/__tests__/multi-tenancy.test.d.ts +2 -0
  17. package/dist/__tests__/multi-tenancy.test.d.ts.map +1 -0
  18. package/dist/__tests__/multi-tenancy.test.js +196 -0
  19. package/dist/__tests__/multi-tenancy.test.js.map +1 -0
  20. package/dist/__tests__/serialization-retry.test.d.ts +2 -0
  21. package/dist/__tests__/serialization-retry.test.d.ts.map +1 -0
  22. package/dist/__tests__/serialization-retry.test.js +53 -0
  23. package/dist/__tests__/serialization-retry.test.js.map +1 -0
  24. package/dist/__tests__/webhooks.test.d.ts +2 -0
  25. package/dist/__tests__/webhooks.test.d.ts.map +1 -0
  26. package/dist/__tests__/webhooks.test.js +286 -0
  27. package/dist/__tests__/webhooks.test.js.map +1 -0
  28. package/dist/__tests__/workflows.test.d.ts +2 -0
  29. package/dist/__tests__/workflows.test.d.ts.map +1 -0
  30. package/dist/__tests__/workflows.test.js +299 -0
  31. package/dist/__tests__/workflows.test.js.map +1 -0
  32. package/dist/adapters/calendar-adapter.d.ts +47 -0
  33. package/dist/adapters/calendar-adapter.d.ts.map +1 -0
  34. package/dist/adapters/calendar-adapter.js +2 -0
  35. package/dist/adapters/calendar-adapter.js.map +1 -0
  36. package/dist/adapters/email-adapter.d.ts +65 -0
  37. package/dist/adapters/email-adapter.d.ts.map +1 -0
  38. package/dist/adapters/email-adapter.js +40 -0
  39. package/dist/adapters/email-adapter.js.map +1 -0
  40. package/dist/adapters/index.d.ts +9 -0
  41. package/dist/adapters/index.d.ts.map +1 -0
  42. package/dist/adapters/index.js +3 -0
  43. package/dist/adapters/index.js.map +1 -0
  44. package/dist/adapters/job-adapter.d.ts +26 -0
  45. package/dist/adapters/job-adapter.d.ts.map +1 -0
  46. package/dist/adapters/job-adapter.js +13 -0
  47. package/dist/adapters/job-adapter.js.map +1 -0
  48. package/dist/adapters/payment-adapter.d.ts +106 -0
  49. package/dist/adapters/payment-adapter.d.ts.map +1 -0
  50. package/dist/adapters/payment-adapter.js +8 -0
  51. package/dist/adapters/payment-adapter.js.map +1 -0
  52. package/dist/adapters/sms-adapter.d.ts +33 -0
  53. package/dist/adapters/sms-adapter.d.ts.map +1 -0
  54. package/dist/adapters/sms-adapter.js +8 -0
  55. package/dist/adapters/sms-adapter.js.map +1 -0
  56. package/dist/adapters/storage-adapter.d.ts +12 -0
  57. package/dist/adapters/storage-adapter.d.ts.map +1 -0
  58. package/dist/adapters/storage-adapter.js +2 -0
  59. package/dist/adapters/storage-adapter.js.map +1 -0
  60. package/dist/api.d.ts +223 -0
  61. package/dist/api.d.ts.map +1 -0
  62. package/dist/api.js +271 -0
  63. package/dist/api.js.map +1 -0
  64. package/dist/auth.d.ts +71 -0
  65. package/dist/auth.d.ts.map +1 -0
  66. package/dist/auth.js +81 -0
  67. package/dist/auth.js.map +1 -0
  68. package/dist/booking-tokens.d.ts +23 -0
  69. package/dist/booking-tokens.d.ts.map +1 -0
  70. package/dist/booking-tokens.js +52 -0
  71. package/dist/booking-tokens.js.map +1 -0
  72. package/dist/email-templates.d.ts +36 -0
  73. package/dist/email-templates.d.ts.map +1 -0
  74. package/dist/email-templates.js +112 -0
  75. package/dist/email-templates.js.map +1 -0
  76. package/dist/index.d.ts +13 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +22 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/multi-tenancy.d.ts +132 -0
  81. package/dist/multi-tenancy.d.ts.map +1 -0
  82. package/dist/multi-tenancy.js +188 -0
  83. package/dist/multi-tenancy.js.map +1 -0
  84. package/dist/notification-jobs.d.ts +143 -0
  85. package/dist/notification-jobs.d.ts.map +1 -0
  86. package/dist/notification-jobs.js +278 -0
  87. package/dist/notification-jobs.js.map +1 -0
  88. package/dist/serialization-retry.d.ts +28 -0
  89. package/dist/serialization-retry.d.ts.map +1 -0
  90. package/dist/serialization-retry.js +71 -0
  91. package/dist/serialization-retry.js.map +1 -0
  92. package/dist/webhooks.d.ts +164 -0
  93. package/dist/webhooks.d.ts.map +1 -0
  94. package/dist/webhooks.js +228 -0
  95. package/dist/webhooks.js.map +1 -0
  96. package/dist/workflows.d.ts +169 -0
  97. package/dist/workflows.d.ts.map +1 -0
  98. package/dist/workflows.js +251 -0
  99. package/dist/workflows.js.map +1 -0
  100. package/package.json +32 -0
  101. package/src/__tests__/api.test.ts +354 -0
  102. package/src/__tests__/auth.test.ts +111 -0
  103. package/src/__tests__/concurrent-booking.test.ts +170 -0
  104. package/src/__tests__/multi-tenancy.test.ts +267 -0
  105. package/src/__tests__/serialization-retry.test.ts +76 -0
  106. package/src/__tests__/webhooks.test.ts +412 -0
  107. package/src/__tests__/workflows.test.ts +422 -0
  108. package/src/adapters/calendar-adapter.ts +49 -0
  109. package/src/adapters/email-adapter.ts +108 -0
  110. package/src/adapters/index.ts +36 -0
  111. package/src/adapters/job-adapter.ts +26 -0
  112. package/src/adapters/payment-adapter.ts +118 -0
  113. package/src/adapters/sms-adapter.ts +35 -0
  114. package/src/adapters/storage-adapter.ts +11 -0
  115. package/src/api.ts +446 -0
  116. package/src/auth.ts +146 -0
  117. package/src/booking-tokens.ts +61 -0
  118. package/src/email-templates.ts +140 -0
  119. package/src/index.ts +192 -0
  120. package/src/multi-tenancy.ts +301 -0
  121. package/src/notification-jobs.ts +428 -0
  122. package/src/serialization-retry.ts +94 -0
  123. package/src/webhooks.ts +378 -0
  124. package/src/workflows.ts +441 -0
  125. package/tsconfig.json +9 -0
  126. package/vitest.config.ts +7 -0
@@ -0,0 +1,412 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ signWebhookPayload,
4
+ verifyWebhookSignature,
5
+ createWebhookEnvelope,
6
+ resolvePayloadTemplate,
7
+ matchWebhookSubscriptions,
8
+ getRetryDelay,
9
+ isSuccessResponse,
10
+ validateWebhookSubscription,
11
+ WebhookValidationError,
12
+ DEFAULT_RETRY_CONFIG,
13
+ WEBHOOK_TRIGGERS,
14
+ type WebhookSubscription,
15
+ type WebhookPayload,
16
+ type WebhookTrigger,
17
+ } from "../webhooks.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Fixtures
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const testSecret = "whsec_test_secret_key_123";
24
+ const testPayload = '{"triggerEvent":"BOOKING_CREATED","payload":{}}';
25
+
26
+ const samplePayload: WebhookPayload = {
27
+ bookingId: "bk-1",
28
+ eventType: "consultation",
29
+ startTime: "2026-03-15T14:00:00.000Z",
30
+ endTime: "2026-03-15T14:30:00.000Z",
31
+ organizer: { name: "Dr. Smith", email: "smith@example.com" },
32
+ attendees: [{ email: "jane@example.com", name: "Jane Doe" }],
33
+ status: "confirmed",
34
+ };
35
+
36
+ function makeSub(
37
+ overrides?: Partial<WebhookSubscription>,
38
+ ): WebhookSubscription {
39
+ return {
40
+ id: "wh-1",
41
+ subscriberUrl: "https://api.example.com/webhooks",
42
+ triggers: ["BOOKING_CREATED"],
43
+ isActive: true,
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // signWebhookPayload
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("signWebhookPayload", () => {
53
+ it("produces a hex-encoded HMAC-SHA256 signature", () => {
54
+ const sig = signWebhookPayload(testPayload, testSecret, 1710000000);
55
+ expect(sig).toMatch(/^[0-9a-f]{64}$/);
56
+ });
57
+
58
+ it("produces different signatures for different payloads", () => {
59
+ const sig1 = signWebhookPayload("body1", testSecret, 1710000000);
60
+ const sig2 = signWebhookPayload("body2", testSecret, 1710000000);
61
+ expect(sig1).not.toBe(sig2);
62
+ });
63
+
64
+ it("produces different signatures for different timestamps", () => {
65
+ const sig1 = signWebhookPayload(testPayload, testSecret, 1710000000);
66
+ const sig2 = signWebhookPayload(testPayload, testSecret, 1710000001);
67
+ expect(sig1).not.toBe(sig2);
68
+ });
69
+
70
+ it("produces different signatures for different secrets", () => {
71
+ const sig1 = signWebhookPayload(testPayload, "secret1", 1710000000);
72
+ const sig2 = signWebhookPayload(testPayload, "secret2", 1710000000);
73
+ expect(sig1).not.toBe(sig2);
74
+ });
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // verifyWebhookSignature
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe("verifyWebhookSignature", () => {
82
+ beforeEach(() => {
83
+ vi.useFakeTimers();
84
+ });
85
+
86
+ afterEach(() => {
87
+ vi.useRealTimers();
88
+ });
89
+
90
+ it("returns valid for a correct signature within tolerance", () => {
91
+ const now = 1710000000;
92
+ vi.setSystemTime(now * 1000);
93
+ const sig = signWebhookPayload(testPayload, testSecret, now);
94
+
95
+ const result = verifyWebhookSignature(
96
+ testPayload,
97
+ sig,
98
+ now,
99
+ testSecret,
100
+ );
101
+ expect(result).toEqual({ valid: true });
102
+ });
103
+
104
+ it("returns signature_mismatch for tampered payload", () => {
105
+ const now = 1710000000;
106
+ vi.setSystemTime(now * 1000);
107
+ const sig = signWebhookPayload(testPayload, testSecret, now);
108
+
109
+ const result = verifyWebhookSignature(
110
+ "tampered_body",
111
+ sig,
112
+ now,
113
+ testSecret,
114
+ );
115
+ expect(result).toEqual({ valid: false, reason: "signature_mismatch" });
116
+ });
117
+
118
+ it("returns signature_mismatch for wrong secret", () => {
119
+ const now = 1710000000;
120
+ vi.setSystemTime(now * 1000);
121
+ const sig = signWebhookPayload(testPayload, testSecret, now);
122
+
123
+ const result = verifyWebhookSignature(
124
+ testPayload,
125
+ sig,
126
+ now,
127
+ "wrong_secret",
128
+ );
129
+ expect(result).toEqual({ valid: false, reason: "signature_mismatch" });
130
+ });
131
+
132
+ it("returns timestamp_expired for old timestamp (>5 min)", () => {
133
+ const now = 1710000600; // 10 min later
134
+ vi.setSystemTime(now * 1000);
135
+ const oldTimestamp = now - 301; // 5 min 1 sec ago
136
+ const sig = signWebhookPayload(testPayload, testSecret, oldTimestamp);
137
+
138
+ const result = verifyWebhookSignature(
139
+ testPayload,
140
+ sig,
141
+ oldTimestamp,
142
+ testSecret,
143
+ );
144
+ expect(result).toEqual({ valid: false, reason: "timestamp_expired" });
145
+ });
146
+
147
+ it("accepts timestamp within tolerance", () => {
148
+ const now = 1710000000;
149
+ vi.setSystemTime(now * 1000);
150
+ const recentTimestamp = now - 299; // 4 min 59 sec ago
151
+ const sig = signWebhookPayload(testPayload, testSecret, recentTimestamp);
152
+
153
+ const result = verifyWebhookSignature(
154
+ testPayload,
155
+ sig,
156
+ recentTimestamp,
157
+ testSecret,
158
+ );
159
+ expect(result).toEqual({ valid: true });
160
+ });
161
+
162
+ it("supports custom tolerance", () => {
163
+ const now = 1710000000;
164
+ vi.setSystemTime(now * 1000);
165
+ const oldTimestamp = now - 61;
166
+ const sig = signWebhookPayload(testPayload, testSecret, oldTimestamp);
167
+
168
+ const result = verifyWebhookSignature(
169
+ testPayload,
170
+ sig,
171
+ oldTimestamp,
172
+ testSecret,
173
+ { toleranceSeconds: 60 },
174
+ );
175
+ expect(result).toEqual({ valid: false, reason: "timestamp_expired" });
176
+ });
177
+
178
+ it("rejects future timestamps beyond tolerance", () => {
179
+ const now = 1710000000;
180
+ vi.setSystemTime(now * 1000);
181
+ const futureTimestamp = now + 400;
182
+ const sig = signWebhookPayload(testPayload, testSecret, futureTimestamp);
183
+
184
+ const result = verifyWebhookSignature(
185
+ testPayload,
186
+ sig,
187
+ futureTimestamp,
188
+ testSecret,
189
+ );
190
+ expect(result).toEqual({ valid: false, reason: "timestamp_expired" });
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // createWebhookEnvelope
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe("createWebhookEnvelope", () => {
199
+ it("creates a valid envelope with ISO 8601 timestamp", () => {
200
+ const envelope = createWebhookEnvelope("BOOKING_CREATED", samplePayload);
201
+
202
+ expect(envelope.triggerEvent).toBe("BOOKING_CREATED");
203
+ expect(envelope.payload).toBe(samplePayload);
204
+ expect(new Date(envelope.createdAt).toISOString()).toBe(
205
+ envelope.createdAt,
206
+ );
207
+ });
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // resolvePayloadTemplate
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe("resolvePayloadTemplate", () => {
215
+ const envelope = createWebhookEnvelope("BOOKING_CREATED", samplePayload);
216
+
217
+ it("resolves all standard template variables", () => {
218
+ const template = '{"event":"{{triggerEvent}}","booking":"{{bookingId}}","type":"{{eventType}}","start":"{{startTime}}","end":"{{endTime}}","status":"{{status}}","org":"{{organizerName}}","email":"{{organizerEmail}}"}';
219
+ const result = resolvePayloadTemplate(template, envelope);
220
+ const parsed = JSON.parse(result);
221
+
222
+ expect(parsed.event).toBe("BOOKING_CREATED");
223
+ expect(parsed.booking).toBe("bk-1");
224
+ expect(parsed.type).toBe("consultation");
225
+ expect(parsed.start).toBe("2026-03-15T14:00:00.000Z");
226
+ expect(parsed.status).toBe("confirmed");
227
+ expect(parsed.org).toBe("Dr. Smith");
228
+ expect(parsed.email).toBe("smith@example.com");
229
+ });
230
+
231
+ it("handles template with no variables", () => {
232
+ const result = resolvePayloadTemplate('{"static":"value"}', envelope);
233
+ expect(result).toBe('{"static":"value"}');
234
+ });
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // matchWebhookSubscriptions
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe("matchWebhookSubscriptions", () => {
242
+ const subs: WebhookSubscription[] = [
243
+ makeSub({ id: "wh-1", triggers: ["BOOKING_CREATED", "BOOKING_CANCELLED"] }),
244
+ makeSub({ id: "wh-2", triggers: ["BOOKING_CREATED"], isActive: false }),
245
+ makeSub({
246
+ id: "wh-3",
247
+ triggers: ["BOOKING_CREATED"],
248
+ eventTypeId: "evt-1",
249
+ }),
250
+ makeSub({
251
+ id: "wh-4",
252
+ triggers: ["BOOKING_CREATED"],
253
+ teamId: "team-1",
254
+ }),
255
+ ];
256
+
257
+ it("matches active webhooks with matching trigger", () => {
258
+ const matched = matchWebhookSubscriptions(subs, "BOOKING_CREATED");
259
+ expect(matched.map((s) => s.id)).toContain("wh-1");
260
+ });
261
+
262
+ it("excludes inactive webhooks", () => {
263
+ const matched = matchWebhookSubscriptions(subs, "BOOKING_CREATED");
264
+ expect(matched.find((s) => s.id === "wh-2")).toBeUndefined();
265
+ });
266
+
267
+ it("matches scoped webhooks when scope matches", () => {
268
+ const matched = matchWebhookSubscriptions(subs, "BOOKING_CREATED", {
269
+ eventTypeId: "evt-1",
270
+ });
271
+ expect(matched.find((s) => s.id === "wh-3")).toBeDefined();
272
+ });
273
+
274
+ it("excludes scoped webhooks when scope doesn't match", () => {
275
+ const matched = matchWebhookSubscriptions(subs, "BOOKING_CREATED", {
276
+ eventTypeId: "evt-99",
277
+ });
278
+ expect(matched.find((s) => s.id === "wh-3")).toBeUndefined();
279
+ });
280
+
281
+ it("matches team-scoped webhooks", () => {
282
+ const matched = matchWebhookSubscriptions(subs, "BOOKING_CREATED", {
283
+ teamId: "team-1",
284
+ });
285
+ expect(matched.find((s) => s.id === "wh-4")).toBeDefined();
286
+ });
287
+
288
+ it("returns empty for unsubscribed trigger", () => {
289
+ const matched = matchWebhookSubscriptions(subs, "OOO_CREATED");
290
+ expect(matched).toHaveLength(0);
291
+ });
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // getRetryDelay
296
+ // ---------------------------------------------------------------------------
297
+
298
+ describe("getRetryDelay", () => {
299
+ it("returns correct delays for default config", () => {
300
+ expect(getRetryDelay(0)).toBe(10);
301
+ expect(getRetryDelay(1)).toBe(60);
302
+ expect(getRetryDelay(2)).toBe(300);
303
+ });
304
+
305
+ it("returns null when max retries exceeded", () => {
306
+ expect(getRetryDelay(3)).toBeNull();
307
+ expect(getRetryDelay(10)).toBeNull();
308
+ });
309
+
310
+ it("uses custom config", () => {
311
+ const config = { maxRetries: 2, backoffSeconds: [5, 30] };
312
+ expect(getRetryDelay(0, config)).toBe(5);
313
+ expect(getRetryDelay(1, config)).toBe(30);
314
+ expect(getRetryDelay(2, config)).toBeNull();
315
+ });
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // isSuccessResponse
320
+ // ---------------------------------------------------------------------------
321
+
322
+ describe("isSuccessResponse", () => {
323
+ it("returns true for 200", () => {
324
+ expect(isSuccessResponse(200)).toBe(true);
325
+ });
326
+
327
+ it("returns true for 201", () => {
328
+ expect(isSuccessResponse(201)).toBe(true);
329
+ });
330
+
331
+ it("returns true for 204", () => {
332
+ expect(isSuccessResponse(204)).toBe(true);
333
+ });
334
+
335
+ it("returns false for 400", () => {
336
+ expect(isSuccessResponse(400)).toBe(false);
337
+ });
338
+
339
+ it("returns false for 500", () => {
340
+ expect(isSuccessResponse(500)).toBe(false);
341
+ });
342
+
343
+ it("returns false for 301 redirect", () => {
344
+ expect(isSuccessResponse(301)).toBe(false);
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // validateWebhookSubscription
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe("validateWebhookSubscription", () => {
353
+ it("accepts valid subscription", () => {
354
+ expect(() =>
355
+ validateWebhookSubscription({
356
+ subscriberUrl: "https://example.com/webhook",
357
+ triggers: ["BOOKING_CREATED"],
358
+ isActive: true,
359
+ }),
360
+ ).not.toThrow();
361
+ });
362
+
363
+ it("rejects missing URL", () => {
364
+ expect(() =>
365
+ validateWebhookSubscription({
366
+ subscriberUrl: "",
367
+ triggers: ["BOOKING_CREATED"],
368
+ isActive: true,
369
+ }),
370
+ ).toThrow("Subscriber URL is required");
371
+ });
372
+
373
+ it("rejects invalid URL", () => {
374
+ expect(() =>
375
+ validateWebhookSubscription({
376
+ subscriberUrl: "not-a-url",
377
+ triggers: ["BOOKING_CREATED"],
378
+ isActive: true,
379
+ }),
380
+ ).toThrow("Invalid subscriber URL");
381
+ });
382
+
383
+ it("rejects empty triggers array", () => {
384
+ expect(() =>
385
+ validateWebhookSubscription({
386
+ subscriberUrl: "https://example.com/webhook",
387
+ triggers: [],
388
+ isActive: true,
389
+ }),
390
+ ).toThrow("At least one trigger");
391
+ });
392
+
393
+ it("rejects invalid trigger", () => {
394
+ expect(() =>
395
+ validateWebhookSubscription({
396
+ subscriberUrl: "https://example.com/webhook",
397
+ triggers: ["INVALID_TRIGGER" as WebhookTrigger],
398
+ isActive: true,
399
+ }),
400
+ ).toThrow('Invalid trigger: "INVALID_TRIGGER"');
401
+ });
402
+
403
+ it("accepts all valid triggers", () => {
404
+ expect(() =>
405
+ validateWebhookSubscription({
406
+ subscriberUrl: "https://example.com/webhook",
407
+ triggers: [...WEBHOOK_TRIGGERS],
408
+ isActive: true,
409
+ }),
410
+ ).not.toThrow();
411
+ });
412
+ });