@thebookingkit/server 0.1.2 → 0.1.3
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.
- package/README.md +2 -2
- package/dist/adapters/calendar-adapter.d.ts +47 -0
- package/dist/adapters/calendar-adapter.d.ts.map +1 -0
- package/dist/adapters/calendar-adapter.js +2 -0
- package/dist/adapters/calendar-adapter.js.map +1 -0
- package/dist/adapters/email-adapter.d.ts +65 -0
- package/dist/adapters/email-adapter.d.ts.map +1 -0
- package/dist/adapters/email-adapter.js +40 -0
- package/dist/adapters/email-adapter.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +3 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/job-adapter.d.ts +26 -0
- package/dist/adapters/job-adapter.d.ts.map +1 -0
- package/dist/adapters/job-adapter.js +13 -0
- package/dist/adapters/job-adapter.js.map +1 -0
- package/dist/adapters/payment-adapter.d.ts +106 -0
- package/dist/adapters/payment-adapter.d.ts.map +1 -0
- package/dist/adapters/payment-adapter.js +8 -0
- package/dist/adapters/payment-adapter.js.map +1 -0
- package/dist/adapters/sms-adapter.d.ts +33 -0
- package/dist/adapters/sms-adapter.d.ts.map +1 -0
- package/dist/adapters/sms-adapter.js +8 -0
- package/dist/adapters/sms-adapter.js.map +1 -0
- package/{src/adapters/storage-adapter.ts → dist/adapters/storage-adapter.d.ts} +5 -4
- package/dist/adapters/storage-adapter.d.ts.map +1 -0
- package/dist/adapters/storage-adapter.js +2 -0
- package/dist/adapters/storage-adapter.js.map +1 -0
- package/dist/api.d.ts +223 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +71 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +81 -0
- package/dist/auth.js.map +1 -0
- package/dist/booking-tokens.d.ts +23 -0
- package/dist/booking-tokens.d.ts.map +1 -0
- package/dist/booking-tokens.js +52 -0
- package/dist/booking-tokens.js.map +1 -0
- package/dist/email-templates.d.ts +36 -0
- package/dist/email-templates.d.ts.map +1 -0
- package/{src/email-templates.ts → dist/email-templates.js} +6 -34
- package/dist/email-templates.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/multi-tenancy.d.ts +132 -0
- package/dist/multi-tenancy.d.ts.map +1 -0
- package/dist/multi-tenancy.js +188 -0
- package/dist/multi-tenancy.js.map +1 -0
- package/dist/notification-jobs.d.ts +143 -0
- package/dist/notification-jobs.d.ts.map +1 -0
- package/dist/notification-jobs.js +278 -0
- package/dist/notification-jobs.js.map +1 -0
- package/dist/serialization-retry.d.ts +28 -0
- package/dist/serialization-retry.d.ts.map +1 -0
- package/dist/serialization-retry.js +71 -0
- package/dist/serialization-retry.js.map +1 -0
- package/dist/webhooks.d.ts +164 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +228 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/workflows.d.ts +169 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +251 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +21 -2
- package/CHANGELOG.md +0 -9
- package/src/__tests__/api.test.ts +0 -354
- package/src/__tests__/auth.test.ts +0 -111
- package/src/__tests__/concurrent-booking.test.ts +0 -170
- package/src/__tests__/multi-tenancy.test.ts +0 -267
- package/src/__tests__/serialization-retry.test.ts +0 -76
- package/src/__tests__/webhooks.test.ts +0 -412
- package/src/__tests__/workflows.test.ts +0 -422
- package/src/adapters/calendar-adapter.ts +0 -49
- package/src/adapters/email-adapter.ts +0 -108
- package/src/adapters/index.ts +0 -36
- package/src/adapters/job-adapter.ts +0 -26
- package/src/adapters/payment-adapter.ts +0 -118
- package/src/adapters/sms-adapter.ts +0 -35
- package/src/api.ts +0 -446
- package/src/auth.ts +0 -146
- package/src/booking-tokens.ts +0 -61
- package/src/index.ts +0 -192
- package/src/multi-tenancy.ts +0 -301
- package/src/notification-jobs.ts +0 -428
- package/src/serialization-retry.ts +0 -94
- package/src/webhooks.ts +0 -378
- package/src/workflows.ts +0 -441
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -7
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow automation engine.
|
|
3
|
+
*
|
|
4
|
+
* Trigger-condition-action framework that automates tasks
|
|
5
|
+
* based on booking lifecycle events.
|
|
6
|
+
*/
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Errors
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** Error thrown when workflow validation fails */
|
|
11
|
+
export class WorkflowValidationError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkflowValidationError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Template Variables
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Standard template variables available in workflow messages */
|
|
21
|
+
export const TEMPLATE_VARIABLES = [
|
|
22
|
+
"{booking.title}",
|
|
23
|
+
"{booking.startTime}",
|
|
24
|
+
"{booking.endTime}",
|
|
25
|
+
"{booking.date}",
|
|
26
|
+
"{attendee.name}",
|
|
27
|
+
"{attendee.email}",
|
|
28
|
+
"{host.name}",
|
|
29
|
+
"{event.location}",
|
|
30
|
+
"{event.duration}",
|
|
31
|
+
"{booking.managementUrl}",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Resolve template variables in a string using workflow context.
|
|
35
|
+
*
|
|
36
|
+
* Missing variables are replaced with empty strings.
|
|
37
|
+
*
|
|
38
|
+
* @param template - The template string with `{variable}` placeholders
|
|
39
|
+
* @param context - The workflow context with booking/event data
|
|
40
|
+
* @returns The resolved string
|
|
41
|
+
*/
|
|
42
|
+
export function resolveTemplateVariables(template, context) {
|
|
43
|
+
const vars = {
|
|
44
|
+
"{booking.title}": context.eventTitle ?? "",
|
|
45
|
+
"{booking.startTime}": context.startsAt
|
|
46
|
+
? formatTime(context.startsAt)
|
|
47
|
+
: "",
|
|
48
|
+
"{booking.endTime}": context.endsAt ? formatTime(context.endsAt) : "",
|
|
49
|
+
"{booking.date}": context.startsAt ? formatDate(context.startsAt) : "",
|
|
50
|
+
"{attendee.name}": context.customerName ?? "",
|
|
51
|
+
"{attendee.email}": context.customerEmail ?? "",
|
|
52
|
+
"{host.name}": context.hostName ?? "",
|
|
53
|
+
"{event.location}": context.eventLocation ?? "",
|
|
54
|
+
"{event.duration}": context.eventDuration
|
|
55
|
+
? `${context.eventDuration} minutes`
|
|
56
|
+
: "",
|
|
57
|
+
"{booking.managementUrl}": context.managementUrl ?? "",
|
|
58
|
+
};
|
|
59
|
+
let result = template;
|
|
60
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
61
|
+
result = result.replaceAll(key, value);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
function formatTime(date) {
|
|
66
|
+
return date.toLocaleTimeString("en-US", {
|
|
67
|
+
hour: "numeric",
|
|
68
|
+
minute: "2-digit",
|
|
69
|
+
hour12: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function formatDate(date) {
|
|
73
|
+
return date.toLocaleDateString("en-US", {
|
|
74
|
+
weekday: "long",
|
|
75
|
+
year: "numeric",
|
|
76
|
+
month: "long",
|
|
77
|
+
day: "numeric",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Default Templates
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/** Default workflow templates for common scenarios */
|
|
84
|
+
export const DEFAULT_TEMPLATES = {
|
|
85
|
+
confirmation: {
|
|
86
|
+
subject: "Booking Confirmed: {booking.title}",
|
|
87
|
+
body: "Hi {attendee.name},\n\nYour booking for {booking.title} on {booking.date} at {booking.startTime} has been confirmed.\n\nDuration: {event.duration}\nLocation: {event.location}\n\nManage your booking: {booking.managementUrl}\n\nBest regards,\n{host.name}",
|
|
88
|
+
},
|
|
89
|
+
reminder_24h: {
|
|
90
|
+
subject: "Reminder: {booking.title} tomorrow",
|
|
91
|
+
body: "Hi {attendee.name},\n\nThis is a reminder that you have a booking for {booking.title} tomorrow at {booking.startTime}.\n\nLocation: {event.location}\n\nManage your booking: {booking.managementUrl}\n\nSee you soon,\n{host.name}",
|
|
92
|
+
},
|
|
93
|
+
reminder_1h: {
|
|
94
|
+
subject: "Reminder: {booking.title} in 1 hour",
|
|
95
|
+
body: "Hi {attendee.name},\n\nYour booking for {booking.title} starts in 1 hour at {booking.startTime}.\n\nLocation: {event.location}\n\nSee you soon,\n{host.name}",
|
|
96
|
+
},
|
|
97
|
+
cancellation: {
|
|
98
|
+
subject: "Booking Cancelled: {booking.title}",
|
|
99
|
+
body: "Hi {attendee.name},\n\nYour booking for {booking.title} on {booking.date} at {booking.startTime} has been cancelled.\n\nIf you'd like to rebook, please visit our booking page.\n\nBest regards,\n{host.name}",
|
|
100
|
+
},
|
|
101
|
+
followup: {
|
|
102
|
+
subject: "How was your {booking.title}?",
|
|
103
|
+
body: "Hi {attendee.name},\n\nThank you for your recent {booking.title} with {host.name}.\n\nWe hope you had a great experience! If you'd like to book again, we'd love to see you.\n\nBest regards,\n{host.name}",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Condition Evaluation
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/**
|
|
110
|
+
* Evaluate whether a workflow's conditions are met for the given context.
|
|
111
|
+
*
|
|
112
|
+
* If no conditions are defined, returns true (unconditional trigger).
|
|
113
|
+
* All conditions must match (AND logic).
|
|
114
|
+
*
|
|
115
|
+
* @param conditions - Array of workflow conditions
|
|
116
|
+
* @param context - The workflow context data
|
|
117
|
+
* @returns Whether all conditions are satisfied
|
|
118
|
+
*/
|
|
119
|
+
export function evaluateConditions(conditions, context) {
|
|
120
|
+
if (conditions.length === 0)
|
|
121
|
+
return true;
|
|
122
|
+
return conditions.every((condition) => {
|
|
123
|
+
const fieldValue = String(context[condition.field] ?? "");
|
|
124
|
+
switch (condition.operator) {
|
|
125
|
+
case "equals":
|
|
126
|
+
return fieldValue === String(condition.value);
|
|
127
|
+
case "not_equals":
|
|
128
|
+
return fieldValue !== String(condition.value);
|
|
129
|
+
case "contains":
|
|
130
|
+
return fieldValue
|
|
131
|
+
.toLowerCase()
|
|
132
|
+
.includes(String(condition.value).toLowerCase());
|
|
133
|
+
case "in": {
|
|
134
|
+
const values = Array.isArray(condition.value)
|
|
135
|
+
? condition.value
|
|
136
|
+
: [condition.value];
|
|
137
|
+
return values.includes(fieldValue);
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Workflow Validation
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
/** Valid triggers for workflows */
|
|
148
|
+
const VALID_TRIGGERS = [
|
|
149
|
+
"booking_created",
|
|
150
|
+
"booking_confirmed",
|
|
151
|
+
"booking_cancelled",
|
|
152
|
+
"booking_rescheduled",
|
|
153
|
+
"before_event",
|
|
154
|
+
"after_event",
|
|
155
|
+
"payment_received",
|
|
156
|
+
"payment_failed",
|
|
157
|
+
"no_show_confirmed",
|
|
158
|
+
"form_submitted",
|
|
159
|
+
];
|
|
160
|
+
/** Valid action types */
|
|
161
|
+
const VALID_ACTION_TYPES = [
|
|
162
|
+
"send_email",
|
|
163
|
+
"send_sms",
|
|
164
|
+
"fire_webhook",
|
|
165
|
+
"update_status",
|
|
166
|
+
"create_calendar_event",
|
|
167
|
+
];
|
|
168
|
+
/**
|
|
169
|
+
* Validate a workflow definition.
|
|
170
|
+
*
|
|
171
|
+
* @param workflow - The workflow to validate
|
|
172
|
+
* @throws {WorkflowValidationError} If the workflow is invalid
|
|
173
|
+
*/
|
|
174
|
+
export function validateWorkflow(workflow) {
|
|
175
|
+
if (!workflow.name || workflow.name.trim().length === 0) {
|
|
176
|
+
throw new WorkflowValidationError("Workflow name is required");
|
|
177
|
+
}
|
|
178
|
+
if (!VALID_TRIGGERS.includes(workflow.trigger)) {
|
|
179
|
+
throw new WorkflowValidationError(`Invalid trigger: "${workflow.trigger}". Must be one of: ${VALID_TRIGGERS.join(", ")}`);
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(workflow.actions) || workflow.actions.length === 0) {
|
|
182
|
+
throw new WorkflowValidationError("Workflow must have at least one action");
|
|
183
|
+
}
|
|
184
|
+
for (const action of workflow.actions) {
|
|
185
|
+
if (!VALID_ACTION_TYPES.includes(action.type)) {
|
|
186
|
+
throw new WorkflowValidationError(`Invalid action type: "${action.type}"`);
|
|
187
|
+
}
|
|
188
|
+
validateAction(action);
|
|
189
|
+
}
|
|
190
|
+
for (const condition of workflow.conditions) {
|
|
191
|
+
if (!condition.field || condition.field.trim().length === 0) {
|
|
192
|
+
throw new WorkflowValidationError("Condition field is required");
|
|
193
|
+
}
|
|
194
|
+
if (!["equals", "not_equals", "contains", "in"].includes(condition.operator)) {
|
|
195
|
+
throw new WorkflowValidationError(`Invalid condition operator: "${condition.operator}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function validateAction(action) {
|
|
200
|
+
switch (action.type) {
|
|
201
|
+
case "send_email":
|
|
202
|
+
if (!action.to) {
|
|
203
|
+
throw new WorkflowValidationError("Email action requires 'to' field");
|
|
204
|
+
}
|
|
205
|
+
if (!action.subject) {
|
|
206
|
+
throw new WorkflowValidationError("Email action requires 'subject' field");
|
|
207
|
+
}
|
|
208
|
+
if (!action.body) {
|
|
209
|
+
throw new WorkflowValidationError("Email action requires 'body' field");
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case "send_sms":
|
|
213
|
+
if (!action.to) {
|
|
214
|
+
throw new WorkflowValidationError("SMS action requires 'to' field");
|
|
215
|
+
}
|
|
216
|
+
if (!action.body) {
|
|
217
|
+
throw new WorkflowValidationError("SMS action requires 'body' field");
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case "fire_webhook":
|
|
221
|
+
if (!action.url) {
|
|
222
|
+
throw new WorkflowValidationError("Webhook action requires 'url' field");
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "update_status":
|
|
226
|
+
if (!action.status) {
|
|
227
|
+
throw new WorkflowValidationError("Status update action requires 'status' field");
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
case "create_calendar_event":
|
|
231
|
+
// No required fields
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Workflow Matching
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/**
|
|
239
|
+
* Find all active workflows that match a given trigger and context.
|
|
240
|
+
*
|
|
241
|
+
* @param workflows - All available workflows
|
|
242
|
+
* @param trigger - The trigger event that occurred
|
|
243
|
+
* @param context - The workflow context data
|
|
244
|
+
* @returns Workflows that should be executed
|
|
245
|
+
*/
|
|
246
|
+
export function matchWorkflows(workflows, trigger, context) {
|
|
247
|
+
return workflows.filter((w) => w.isActive &&
|
|
248
|
+
w.trigger === trigger &&
|
|
249
|
+
evaluateConditions(w.conditions, context));
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=workflows.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thebookingkit/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Backend APIs, auth middleware, webhooks, notifications, and multi-tenant utilities for The Booking Kit",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/thebookingkit/thebookingkit.git",
|
|
9
|
+
"directory": "packages/server"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://thebookingkit.com",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"thebookingkit",
|
|
14
|
+
"booking",
|
|
15
|
+
"scheduling",
|
|
16
|
+
"server",
|
|
17
|
+
"api"
|
|
18
|
+
],
|
|
4
19
|
"publishConfig": {
|
|
5
20
|
"access": "public"
|
|
6
21
|
},
|
|
@@ -14,6 +29,10 @@
|
|
|
14
29
|
"types": "./dist/index.d.ts"
|
|
15
30
|
}
|
|
16
31
|
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"!dist/__tests__"
|
|
35
|
+
],
|
|
17
36
|
"scripts": {
|
|
18
37
|
"build": "tsc",
|
|
19
38
|
"prepublishOnly": "npm run build",
|
|
@@ -22,7 +41,7 @@
|
|
|
22
41
|
"test:watch": "vitest"
|
|
23
42
|
},
|
|
24
43
|
"dependencies": {
|
|
25
|
-
"@thebookingkit/core": "^0.1.
|
|
44
|
+
"@thebookingkit/core": "^0.1.3"
|
|
26
45
|
},
|
|
27
46
|
"devDependencies": {
|
|
28
47
|
"@types/node": "^25.3.5",
|
package/CHANGELOG.md
DELETED
|
@@ -1,354 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
createErrorResponse,
|
|
4
|
-
createSuccessResponse,
|
|
5
|
-
createPaginatedResponse,
|
|
6
|
-
generateApiKey,
|
|
7
|
-
hashApiKey,
|
|
8
|
-
verifyApiKey,
|
|
9
|
-
hasScope,
|
|
10
|
-
isKeyExpired,
|
|
11
|
-
checkRateLimit,
|
|
12
|
-
encodeCursor,
|
|
13
|
-
decodeCursor,
|
|
14
|
-
validateSlotQueryParams,
|
|
15
|
-
parseSortParam,
|
|
16
|
-
API_ERROR_CODES,
|
|
17
|
-
type ApiKeyScope,
|
|
18
|
-
} from "../api.js";
|
|
19
|
-
|
|
20
|
-
// Set the required env var for API key hashing in tests
|
|
21
|
-
process.env.THEBOOKINGKIT_API_KEY_SECRET = "test-secret-for-unit-tests-only";
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Response Helpers
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
describe("createErrorResponse", () => {
|
|
28
|
-
it("creates a standard error envelope", () => {
|
|
29
|
-
const response = createErrorResponse("NOT_FOUND", "Booking not found");
|
|
30
|
-
expect(response).toEqual({
|
|
31
|
-
error: { code: "NOT_FOUND", message: "Booking not found" },
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("includes details when provided", () => {
|
|
36
|
-
const response = createErrorResponse(
|
|
37
|
-
"VALIDATION_ERROR",
|
|
38
|
-
"Invalid input",
|
|
39
|
-
{ field: "email" },
|
|
40
|
-
);
|
|
41
|
-
expect(response.error.details).toEqual({ field: "email" });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("omits details when not provided", () => {
|
|
45
|
-
const response = createErrorResponse("UNAUTHORIZED", "Invalid key");
|
|
46
|
-
expect(response.error.details).toBeUndefined();
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("createSuccessResponse", () => {
|
|
51
|
-
it("wraps data in a success envelope", () => {
|
|
52
|
-
const response = createSuccessResponse({ id: "bk-1" });
|
|
53
|
-
expect(response).toEqual({ data: { id: "bk-1" } });
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("includes meta when provided", () => {
|
|
57
|
-
const response = createSuccessResponse([], {
|
|
58
|
-
nextCursor: "abc",
|
|
59
|
-
hasMore: true,
|
|
60
|
-
});
|
|
61
|
-
expect(response.meta?.nextCursor).toBe("abc");
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe("createPaginatedResponse", () => {
|
|
66
|
-
it("creates paginated response with cursor", () => {
|
|
67
|
-
const response = createPaginatedResponse(["a", "b"], "cursor123", 10);
|
|
68
|
-
expect(response.data).toEqual(["a", "b"]);
|
|
69
|
-
expect(response.meta.nextCursor).toBe("cursor123");
|
|
70
|
-
expect(response.meta.hasMore).toBe(true);
|
|
71
|
-
expect(response.meta.total).toBe(10);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("marks hasMore false when nextCursor is null", () => {
|
|
75
|
-
const response = createPaginatedResponse(["a"], null);
|
|
76
|
-
expect(response.meta.hasMore).toBe(false);
|
|
77
|
-
expect(response.meta.nextCursor).toBeNull();
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// API Key Management
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
describe("generateApiKey", () => {
|
|
86
|
-
it("generates a key with the given prefix", () => {
|
|
87
|
-
const { key } = generateApiKey("sk_live_");
|
|
88
|
-
expect(key).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("generates a display prefix", () => {
|
|
92
|
-
const { prefix } = generateApiKey("sk_live_");
|
|
93
|
-
expect(prefix).toContain("...");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("generates a 64-char hex hash", () => {
|
|
97
|
-
const { hash } = generateApiKey();
|
|
98
|
-
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("generates unique keys each time", () => {
|
|
102
|
-
const { key: k1 } = generateApiKey();
|
|
103
|
-
const { key: k2 } = generateApiKey();
|
|
104
|
-
expect(k1).not.toBe(k2);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("hashApiKey", () => {
|
|
109
|
-
it("throws when THEBOOKINGKIT_API_KEY_SECRET is missing", () => {
|
|
110
|
-
const original = process.env.THEBOOKINGKIT_API_KEY_SECRET;
|
|
111
|
-
delete process.env.THEBOOKINGKIT_API_KEY_SECRET;
|
|
112
|
-
try {
|
|
113
|
-
expect(() => hashApiKey("sk_live_test")).toThrow(
|
|
114
|
-
"THEBOOKINGKIT_API_KEY_SECRET environment variable is required",
|
|
115
|
-
);
|
|
116
|
-
} finally {
|
|
117
|
-
process.env.THEBOOKINGKIT_API_KEY_SECRET = original;
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("accepts an explicit secret parameter", () => {
|
|
122
|
-
const hash = hashApiKey("sk_live_test", "my-explicit-secret");
|
|
123
|
-
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe("verifyApiKey", () => {
|
|
128
|
-
it("returns true for correct key", () => {
|
|
129
|
-
const { key, hash } = generateApiKey();
|
|
130
|
-
expect(verifyApiKey(key, hash)).toBe(true);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("returns false for tampered key", () => {
|
|
134
|
-
const { hash } = generateApiKey();
|
|
135
|
-
expect(verifyApiKey("sk_live_wrong", hash)).toBe(false);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("returns false for wrong hash", () => {
|
|
139
|
-
const { key } = generateApiKey();
|
|
140
|
-
const { hash: otherHash } = generateApiKey();
|
|
141
|
-
expect(verifyApiKey(key, otherHash)).toBe(false);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe("hasScope", () => {
|
|
146
|
-
it("returns true when scope is present", () => {
|
|
147
|
-
const scopes: ApiKeyScope[] = ["read:bookings", "write:bookings"];
|
|
148
|
-
expect(hasScope(scopes, "read:bookings")).toBe(true);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("returns false when scope is absent", () => {
|
|
152
|
-
const scopes: ApiKeyScope[] = ["read:bookings"];
|
|
153
|
-
expect(hasScope(scopes, "write:bookings")).toBe(false);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("admin scope grants all permissions", () => {
|
|
157
|
-
const scopes: ApiKeyScope[] = ["admin"];
|
|
158
|
-
expect(hasScope(scopes, "read:bookings")).toBe(true);
|
|
159
|
-
expect(hasScope(scopes, "write:event-types")).toBe(true);
|
|
160
|
-
expect(hasScope(scopes, "write:webhooks")).toBe(true);
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("isKeyExpired", () => {
|
|
165
|
-
it("returns false for no expiry", () => {
|
|
166
|
-
expect(isKeyExpired(undefined)).toBe(false);
|
|
167
|
-
expect(isKeyExpired(null)).toBe(false);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("returns true for past expiry", () => {
|
|
171
|
-
expect(isKeyExpired(new Date(Date.now() - 1000))).toBe(true);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("returns false for future expiry", () => {
|
|
175
|
-
expect(isKeyExpired(new Date(Date.now() + 86400000))).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
// Rate Limiting
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
describe("checkRateLimit", () => {
|
|
184
|
-
beforeEach(() => {
|
|
185
|
-
vi.useFakeTimers();
|
|
186
|
-
vi.setSystemTime(new Date("2026-03-15T14:00:00.000Z"));
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
afterEach(() => {
|
|
190
|
-
vi.useRealTimers();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("allows first request", () => {
|
|
194
|
-
const { result } = checkRateLimit(null, 120);
|
|
195
|
-
expect(result.allowed).toBe(true);
|
|
196
|
-
expect(result.remaining).toBe(119);
|
|
197
|
-
expect(result.limit).toBe(120);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("tracks requests within a window", () => {
|
|
201
|
-
const { newState } = checkRateLimit(null, 120);
|
|
202
|
-
const { result } = checkRateLimit(newState, 120);
|
|
203
|
-
expect(result.allowed).toBe(true);
|
|
204
|
-
expect(result.remaining).toBe(118);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("blocks requests when limit exceeded", () => {
|
|
208
|
-
let state = checkRateLimit(null, 2).newState;
|
|
209
|
-
state = checkRateLimit(state, 2).newState;
|
|
210
|
-
const { result } = checkRateLimit(state, 2);
|
|
211
|
-
expect(result.allowed).toBe(false);
|
|
212
|
-
expect(result.remaining).toBe(0);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("resets counter in new window", () => {
|
|
216
|
-
let state = checkRateLimit(null, 2).newState;
|
|
217
|
-
state = checkRateLimit(state, 2).newState;
|
|
218
|
-
// Advance to next minute
|
|
219
|
-
vi.advanceTimersByTime(60 * 1000);
|
|
220
|
-
const { result } = checkRateLimit(state, 2);
|
|
221
|
-
expect(result.allowed).toBe(true);
|
|
222
|
-
expect(result.remaining).toBe(1);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// Cursor Pagination
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
describe("encodeCursor / decodeCursor", () => {
|
|
231
|
-
it("round-trips cursor data", () => {
|
|
232
|
-
const data = { id: "bk-1", createdAt: "2026-03-15T14:00:00Z" };
|
|
233
|
-
const cursor = encodeCursor(data);
|
|
234
|
-
const decoded = decodeCursor(cursor);
|
|
235
|
-
expect(decoded).toEqual(data);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("returns null for invalid cursor", () => {
|
|
239
|
-
expect(decodeCursor("not-valid-base64!!!")).toBeNull();
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("returns null for non-JSON cursor", () => {
|
|
243
|
-
const cursor = Buffer.from("not json").toString("base64url");
|
|
244
|
-
expect(decodeCursor(cursor)).toBeNull();
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// ---------------------------------------------------------------------------
|
|
249
|
-
// validateSlotQueryParams
|
|
250
|
-
// ---------------------------------------------------------------------------
|
|
251
|
-
|
|
252
|
-
describe("validateSlotQueryParams", () => {
|
|
253
|
-
const validParams = {
|
|
254
|
-
providerId: "prov-1",
|
|
255
|
-
start: "2026-03-15",
|
|
256
|
-
end: "2026-04-15",
|
|
257
|
-
timezone: "America/New_York",
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
it("accepts valid params", () => {
|
|
261
|
-
const result = validateSlotQueryParams(validParams);
|
|
262
|
-
expect(result.valid).toBe(true);
|
|
263
|
-
expect(result.errors).toHaveLength(0);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("accepts teamId instead of providerId", () => {
|
|
267
|
-
const result = validateSlotQueryParams({
|
|
268
|
-
teamId: "team-1",
|
|
269
|
-
start: "2026-03-15",
|
|
270
|
-
end: "2026-04-15",
|
|
271
|
-
});
|
|
272
|
-
expect(result.valid).toBe(true);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("rejects missing providerId and teamId", () => {
|
|
276
|
-
const result = validateSlotQueryParams({
|
|
277
|
-
start: "2026-03-15",
|
|
278
|
-
end: "2026-04-15",
|
|
279
|
-
});
|
|
280
|
-
expect(result.valid).toBe(false);
|
|
281
|
-
expect(result.errors[0].field).toBe("providerId");
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("rejects missing start date", () => {
|
|
285
|
-
const result = validateSlotQueryParams({
|
|
286
|
-
providerId: "p1",
|
|
287
|
-
end: "2026-04-15",
|
|
288
|
-
});
|
|
289
|
-
expect(result.valid).toBe(false);
|
|
290
|
-
expect(result.errors.some((e) => e.field === "start")).toBe(true);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it("rejects invalid start date", () => {
|
|
294
|
-
const result = validateSlotQueryParams({
|
|
295
|
-
providerId: "p1",
|
|
296
|
-
start: "not-a-date",
|
|
297
|
-
end: "2026-04-15",
|
|
298
|
-
});
|
|
299
|
-
expect(result.valid).toBe(false);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it("rejects end before start", () => {
|
|
303
|
-
const result = validateSlotQueryParams({
|
|
304
|
-
providerId: "p1",
|
|
305
|
-
start: "2026-04-15",
|
|
306
|
-
end: "2026-03-15",
|
|
307
|
-
});
|
|
308
|
-
expect(result.valid).toBe(false);
|
|
309
|
-
expect(result.errors.some((e) => e.message.includes("after start"))).toBe(
|
|
310
|
-
true,
|
|
311
|
-
);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
// parseSortParam
|
|
317
|
-
// ---------------------------------------------------------------------------
|
|
318
|
-
|
|
319
|
-
describe("parseSortParam", () => {
|
|
320
|
-
const allowedFields = ["createdAt", "startsAt", "status"];
|
|
321
|
-
|
|
322
|
-
it("parses ascending sort", () => {
|
|
323
|
-
const result = parseSortParam("startsAt", allowedFields);
|
|
324
|
-
expect(result).toEqual({ field: "startsAt", direction: "asc" });
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("parses descending sort with leading minus", () => {
|
|
328
|
-
const result = parseSortParam("-createdAt", allowedFields);
|
|
329
|
-
expect(result).toEqual({ field: "createdAt", direction: "desc" });
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("returns null for invalid field", () => {
|
|
333
|
-
expect(parseSortParam("unknownField", allowedFields)).toBeNull();
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it("returns null for undefined input", () => {
|
|
337
|
-
expect(parseSortParam(undefined, allowedFields)).toBeNull();
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
// API_ERROR_CODES
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
|
|
345
|
-
describe("API_ERROR_CODES", () => {
|
|
346
|
-
it("exports all standard error codes", () => {
|
|
347
|
-
expect(API_ERROR_CODES.NOT_FOUND).toBe("NOT_FOUND");
|
|
348
|
-
expect(API_ERROR_CODES.UNAUTHORIZED).toBe("UNAUTHORIZED");
|
|
349
|
-
expect(API_ERROR_CODES.FORBIDDEN).toBe("FORBIDDEN");
|
|
350
|
-
expect(API_ERROR_CODES.VALIDATION_ERROR).toBe("VALIDATION_ERROR");
|
|
351
|
-
expect(API_ERROR_CODES.RATE_LIMITED).toBe("RATE_LIMITED");
|
|
352
|
-
expect(API_ERROR_CODES.CONFLICT).toBe("CONFLICT");
|
|
353
|
-
});
|
|
354
|
-
});
|