@slotchain/sdk 1.1.3 → 1.2.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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Unit tests for ServiceClient.getServicesByTenant() and getServicesBySlot()
3
+ *
4
+ * Tests cover:
5
+ * - getServicesByTenant() with various parameter combinations
6
+ * - getServicesBySlot() with valid/invalid slot IDs
7
+ * - Type safety for ServiceWithItems vs SlotServiceWithItems
8
+ * - Error handling for all error codes
9
+ */
10
+
11
+ import axios from 'axios';
12
+ import axiosMockAdapter from 'axios-mock-adapter';
13
+ import { ServiceClient } from '../service-client';
14
+ import type { Service, ServiceWithItems, SlotServiceWithItems } from '../../types/api';
15
+
16
+ describe('ServiceClient', () => {
17
+ let mockAdapter: axiosMockAdapter;
18
+ let client: axios.AxiosInstance;
19
+ let serviceClient: ServiceClient;
20
+
21
+ beforeEach(() => {
22
+ // Create a new axios instance for each test
23
+ client = axios.create({
24
+ baseURL: 'https://api.slotly.dev',
25
+ });
26
+ mockAdapter = new axiosMockAdapter(client, { delayResponse: 0 });
27
+ serviceClient = new ServiceClient(client);
28
+ });
29
+
30
+ afterEach(() => {
31
+ mockAdapter.restore();
32
+ });
33
+
34
+ describe('getServicesByTenant()', () => {
35
+ describe('With tenantId', () => {
36
+ it('should return services without items when includeItems is false', async () => {
37
+ const mockServices: Service[] = [
38
+ {
39
+ id: 'service-1',
40
+ tenant_id: 'tenant-123',
41
+ name: 'Consultation',
42
+ description: 'Initial consultation',
43
+ is_active: true,
44
+ created_at: '2024-01-01T00:00:00Z',
45
+ updated_at: '2024-01-01T00:00:00Z',
46
+ },
47
+ ];
48
+
49
+ mockAdapter
50
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123' } })
51
+ .reply(200, {
52
+ success: true,
53
+ data: mockServices,
54
+ });
55
+
56
+ const result = await serviceClient.getServicesByTenant({
57
+ tenantId: 'tenant-123',
58
+ includeItems: false,
59
+ });
60
+
61
+ expect(result.success).toBe(true);
62
+ expect(result.data).toBeDefined();
63
+ expect(Array.isArray(result.data)).toBe(true);
64
+ expect(result.data?.length).toBe(1);
65
+ expect(result.data?.[0].id).toBe('service-1');
66
+ expect('serviceItems' in (result.data?.[0] || {})).toBe(false);
67
+ });
68
+
69
+ it('should return services with items when includeItems is true', async () => {
70
+ const mockServices: ServiceWithItems[] = [
71
+ {
72
+ id: 'service-1',
73
+ tenant_id: 'tenant-123',
74
+ name: 'Consultation',
75
+ description: 'Initial consultation',
76
+ is_active: true,
77
+ created_at: '2024-01-01T00:00:00Z',
78
+ updated_at: '2024-01-01T00:00:00Z',
79
+ serviceItems: [
80
+ {
81
+ id: 'item-1',
82
+ service_id: 'service-1',
83
+ name: 'Standard Consultation',
84
+ price: 100,
85
+ is_available: true,
86
+ created_at: '2024-01-01T00:00:00Z',
87
+ updated_at: '2024-01-01T00:00:00Z',
88
+ },
89
+ ],
90
+ },
91
+ ];
92
+
93
+ mockAdapter
94
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', includeItems: true } })
95
+ .reply(200, {
96
+ success: true,
97
+ data: mockServices,
98
+ });
99
+
100
+ const result = await serviceClient.getServicesByTenant({
101
+ tenantId: 'tenant-123',
102
+ includeItems: true,
103
+ });
104
+
105
+ expect(result.success).toBe(true);
106
+ expect(result.data).toBeDefined();
107
+ expect(Array.isArray(result.data)).toBe(true);
108
+ const firstService = result.data?.[0] as ServiceWithItems;
109
+ expect(firstService.serviceItems).toBeDefined();
110
+ expect(firstService.serviceItems?.length).toBe(1);
111
+ });
112
+
113
+ it('should include pagination metadata when provided', async () => {
114
+ const mockServices: Service[] = [
115
+ {
116
+ id: 'service-1',
117
+ tenant_id: 'tenant-123',
118
+ name: 'Consultation',
119
+ is_active: true,
120
+ created_at: '2024-01-01T00:00:00Z',
121
+ updated_at: '2024-01-01T00:00:00Z',
122
+ },
123
+ ];
124
+
125
+ mockAdapter
126
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', page: 1, limit: 20 } })
127
+ .reply(200, {
128
+ success: true,
129
+ data: mockServices,
130
+ pagination: {
131
+ page: 1,
132
+ limit: 20,
133
+ total: 50,
134
+ totalPages: 3,
135
+ },
136
+ });
137
+
138
+ const result = await serviceClient.getServicesByTenant({
139
+ tenantId: 'tenant-123',
140
+ page: 1,
141
+ limit: 20,
142
+ });
143
+
144
+ expect(result.success).toBe(true);
145
+ expect(result.pagination).toBeDefined();
146
+ expect(result.pagination?.page).toBe(1);
147
+ expect(result.pagination?.total).toBe(50);
148
+ });
149
+
150
+ it('should filter by isActive when provided', async () => {
151
+ const mockServices: Service[] = [
152
+ {
153
+ id: 'service-1',
154
+ tenant_id: 'tenant-123',
155
+ name: 'Active Service',
156
+ is_active: true,
157
+ created_at: '2024-01-01T00:00:00Z',
158
+ updated_at: '2024-01-01T00:00:00Z',
159
+ },
160
+ ];
161
+
162
+ mockAdapter
163
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', is_active: true } })
164
+ .reply(200, {
165
+ success: true,
166
+ data: mockServices,
167
+ });
168
+
169
+ const result = await serviceClient.getServicesByTenant({
170
+ tenantId: 'tenant-123',
171
+ isActive: true,
172
+ });
173
+
174
+ expect(result.success).toBe(true);
175
+ expect(result.data?.length).toBe(1);
176
+ expect(result.data?.[0].is_active).toBe(true);
177
+ });
178
+ });
179
+
180
+ describe('With tenantSlug', () => {
181
+ it('should return services when using tenantSlug', async () => {
182
+ const mockServices: Service[] = [
183
+ {
184
+ id: 'service-1',
185
+ tenant_id: 'tenant-123',
186
+ name: 'Consultation',
187
+ is_active: true,
188
+ created_at: '2024-01-01T00:00:00Z',
189
+ updated_at: '2024-01-01T00:00:00Z',
190
+ },
191
+ ];
192
+
193
+ mockAdapter
194
+ .onGet('/api/v1/services', { params: { tenantSlug: 'sn-cleaning-co' } })
195
+ .reply(200, {
196
+ success: true,
197
+ data: mockServices,
198
+ });
199
+
200
+ const result = await serviceClient.getServicesByTenant({
201
+ tenantSlug: 'sn-cleaning-co',
202
+ });
203
+
204
+ expect(result.success).toBe(true);
205
+ expect(result.data).toBeDefined();
206
+ expect(result.data?.length).toBe(1);
207
+ });
208
+ });
209
+
210
+ describe('Error handling', () => {
211
+ it('should return MISSING_TENANT_ID error when neither tenantId nor tenantSlug provided', async () => {
212
+ const result = await serviceClient.getServicesByTenant({});
213
+
214
+ expect(result.success).toBe(false);
215
+ expect(result.error).toBeDefined();
216
+ expect(result.error?.code).toBe('MISSING_TENANT_ID');
217
+ expect(result.error?.message).toContain('tenantId or tenantSlug is required');
218
+ });
219
+
220
+ it('should handle TENANT_NOT_FOUND error', async () => {
221
+ mockAdapter
222
+ .onGet('/api/v1/services', { params: { tenantSlug: 'invalid-tenant' } })
223
+ .reply(404, {
224
+ success: false,
225
+ error: {
226
+ code: 'TENANT_NOT_FOUND',
227
+ message: 'Tenant with slug "invalid-tenant" not found',
228
+ },
229
+ });
230
+
231
+ const result = await serviceClient.getServicesByTenant({
232
+ tenantSlug: 'invalid-tenant',
233
+ });
234
+
235
+ expect(result.success).toBe(false);
236
+ expect(result.error?.code).toBe('TENANT_NOT_FOUND');
237
+ });
238
+
239
+ it('should handle INTERNAL_ERROR', async () => {
240
+ mockAdapter
241
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123' } })
242
+ .reply(500, {
243
+ success: false,
244
+ error: {
245
+ code: 'INTERNAL_ERROR',
246
+ message: 'Internal server error',
247
+ },
248
+ });
249
+
250
+ const result = await serviceClient.getServicesByTenant({
251
+ tenantId: 'tenant-123',
252
+ });
253
+
254
+ expect(result.success).toBe(false);
255
+ expect(result.error?.code).toBe('INTERNAL_ERROR');
256
+ });
257
+
258
+ it('should enforce max limit of 100', async () => {
259
+ const mockServices: Service[] = [];
260
+
261
+ mockAdapter
262
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', limit: 100 } })
263
+ .reply(200, {
264
+ success: true,
265
+ data: mockServices,
266
+ });
267
+
268
+ // Request with limit > 100 should be capped at 100
269
+ await serviceClient.getServicesByTenant({
270
+ tenantId: 'tenant-123',
271
+ limit: 150,
272
+ });
273
+
274
+ // Verify the request was made with limit: 100
275
+ expect(mockAdapter.history.get.length).toBe(1);
276
+ const request = mockAdapter.history.get[0];
277
+ expect(request.params?.limit).toBe(100);
278
+ });
279
+ });
280
+
281
+ describe('Deprecated slotId parameter', () => {
282
+ it('should accept slotId parameter for compatibility', async () => {
283
+ const mockServices: Service[] = [
284
+ {
285
+ id: 'service-1',
286
+ tenant_id: 'tenant-123',
287
+ name: 'Consultation',
288
+ is_active: true,
289
+ created_at: '2024-01-01T00:00:00Z',
290
+ updated_at: '2024-01-01T00:00:00Z',
291
+ },
292
+ ];
293
+
294
+ mockAdapter
295
+ .onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', slot_id: 'slot-123' } })
296
+ .reply(200, {
297
+ success: true,
298
+ data: mockServices,
299
+ });
300
+
301
+ const result = await serviceClient.getServicesByTenant({
302
+ tenantId: 'tenant-123',
303
+ slotId: 'slot-123', // Deprecated but accepted
304
+ });
305
+
306
+ expect(result.success).toBe(true);
307
+ // Verify slot_id was included in request (even though API ignores it)
308
+ expect(mockAdapter.history.get[0].params?.slot_id).toBe('slot-123');
309
+ });
310
+ });
311
+ });
312
+
313
+ describe('getServicesBySlot()', () => {
314
+ describe('Valid slot ID', () => {
315
+ it('should return services with items property (not serviceItems)', async () => {
316
+ const mockServices: SlotServiceWithItems[] = [
317
+ {
318
+ id: 'service-1',
319
+ tenant_id: 'tenant-123',
320
+ name: 'Consultation',
321
+ is_active: true,
322
+ created_at: '2024-01-01T00:00:00Z',
323
+ updated_at: '2024-01-01T00:00:00Z',
324
+ items: [
325
+ {
326
+ id: 'item-1',
327
+ service_id: 'service-1',
328
+ name: 'Standard Consultation',
329
+ price: 100,
330
+ is_available: true,
331
+ created_at: '2024-01-01T00:00:00Z',
332
+ updated_at: '2024-01-01T00:00:00Z',
333
+ },
334
+ ],
335
+ },
336
+ ];
337
+
338
+ mockAdapter
339
+ .onGet('/api/v1/slots/slot-123/services')
340
+ .reply(200, {
341
+ success: true,
342
+ data: mockServices,
343
+ });
344
+
345
+ const result = await serviceClient.getServicesBySlot('slot-123');
346
+
347
+ expect(result.success).toBe(true);
348
+ expect(result.data).toBeDefined();
349
+ expect(Array.isArray(result.data)).toBe(true);
350
+ expect(result.data?.length).toBe(1);
351
+
352
+ const firstService = result.data?.[0];
353
+ expect(firstService?.items).toBeDefined();
354
+ expect(firstService?.items.length).toBe(1);
355
+ // Verify it uses 'items' not 'serviceItems'
356
+ expect('serviceItems' in (firstService || {})).toBe(false);
357
+ });
358
+
359
+ it('should only return active services', async () => {
360
+ const mockServices: SlotServiceWithItems[] = [
361
+ {
362
+ id: 'service-1',
363
+ tenant_id: 'tenant-123',
364
+ name: 'Active Service',
365
+ is_active: true,
366
+ created_at: '2024-01-01T00:00:00Z',
367
+ updated_at: '2024-01-01T00:00:00Z',
368
+ items: [],
369
+ },
370
+ ];
371
+
372
+ mockAdapter
373
+ .onGet('/api/v1/slots/slot-123/services')
374
+ .reply(200, {
375
+ success: true,
376
+ data: mockServices,
377
+ });
378
+
379
+ const result = await serviceClient.getServicesBySlot('slot-123');
380
+
381
+ expect(result.success).toBe(true);
382
+ expect(result.data?.every(s => s.is_active === true)).toBe(true);
383
+ });
384
+
385
+ it('should always include items (even if empty array)', async () => {
386
+ const mockServices: SlotServiceWithItems[] = [
387
+ {
388
+ id: 'service-1',
389
+ tenant_id: 'tenant-123',
390
+ name: 'Service without items',
391
+ is_active: true,
392
+ created_at: '2024-01-01T00:00:00Z',
393
+ updated_at: '2024-01-01T00:00:00Z',
394
+ items: [],
395
+ },
396
+ ];
397
+
398
+ mockAdapter
399
+ .onGet('/api/v1/slots/slot-123/services')
400
+ .reply(200, {
401
+ success: true,
402
+ data: mockServices,
403
+ });
404
+
405
+ const result = await serviceClient.getServicesBySlot('slot-123');
406
+
407
+ expect(result.success).toBe(true);
408
+ expect(result.data?.[0].items).toBeDefined();
409
+ expect(Array.isArray(result.data?.[0].items)).toBe(true);
410
+ });
411
+ });
412
+
413
+ describe('Error handling', () => {
414
+ it('should handle SLOT_NOT_FOUND error', async () => {
415
+ mockAdapter
416
+ .onGet('/api/v1/slots/invalid-slot/services')
417
+ .reply(404, {
418
+ success: false,
419
+ error: {
420
+ code: 'SLOT_NOT_FOUND',
421
+ message: 'Slot with id "invalid-slot" not found',
422
+ },
423
+ });
424
+
425
+ const result = await serviceClient.getServicesBySlot('invalid-slot');
426
+
427
+ expect(result.success).toBe(false);
428
+ expect(result.error?.code).toBe('SLOT_NOT_FOUND');
429
+ });
430
+
431
+ it('should handle INTERNAL_ERROR', async () => {
432
+ mockAdapter
433
+ .onGet('/api/v1/slots/slot-123/services')
434
+ .reply(500, {
435
+ success: false,
436
+ error: {
437
+ code: 'INTERNAL_ERROR',
438
+ message: 'Internal server error',
439
+ },
440
+ });
441
+
442
+ const result = await serviceClient.getServicesBySlot('slot-123');
443
+
444
+ expect(result.success).toBe(false);
445
+ expect(result.error?.code).toBe('INTERNAL_ERROR');
446
+ });
447
+ });
448
+ });
449
+ });
450
+
@@ -1,5 +1,14 @@
1
1
  import { AxiosInstance } from 'axios';
2
- import { ApiResponse, Customer, Booking } from '../types/api';
2
+ import {
3
+ ApiResponse,
4
+ Customer,
5
+ CustomerIdentity,
6
+ CustomerWithIdentities,
7
+ CustomerWithBookings,
8
+ NotificationPreferences,
9
+ FindOrCreateCustomerOptions,
10
+ ResolveCustomerOptions,
11
+ } from '../types/api';
3
12
 
4
13
  export class CustomerClient {
5
14
  constructor(private client: AxiosInstance) {}
@@ -58,11 +67,8 @@ export class CustomerClient {
58
67
  /**
59
68
  * Create a new customer
60
69
  * POST /api/v1/customers
61
- *
62
- * @param data - Customer creation data (tenant_id, name, email required)
63
- * @returns Created customer
64
70
  */
65
- async create(data: Partial<Customer>): Promise<ApiResponse<Customer>> {
71
+ async create(data: Partial<Customer> & { tenant_id: string; name: string; email: string }): Promise<ApiResponse<Customer>> {
66
72
  const response = await this.client.post<ApiResponse<Customer>>(
67
73
  '/api/v1/customers',
68
74
  data
@@ -119,14 +125,25 @@ export class CustomerClient {
119
125
  }
120
126
 
121
127
  /**
122
- * Get autocomplete data from existing customers and bookings
123
- * GET /api/v1/customers/autocomplete
124
- *
125
- * @param tenantId - Tenant ID (required)
126
- * @returns Autocomplete data
128
+ * Get autocomplete data from existing customers and bookings.
129
+ * GET /api/v1/customers/autocomplete?tenantId=:tenantId
127
130
  */
128
- async autocomplete(tenantId: string): Promise<ApiResponse<any>> {
129
- const response = await this.client.get<ApiResponse<any>>(
131
+ async autocomplete(tenantId: string): Promise<ApiResponse<{
132
+ companies: string[];
133
+ jobTitles: string[];
134
+ experienceLevels: string[];
135
+ cities: string[];
136
+ states: string[];
137
+ zipCodes: string[];
138
+ }>> {
139
+ const response = await this.client.get<ApiResponse<{
140
+ companies: string[];
141
+ jobTitles: string[];
142
+ experienceLevels: string[];
143
+ cities: string[];
144
+ states: string[];
145
+ zipCodes: string[];
146
+ }>>(
130
147
  '/api/v1/customers/autocomplete',
131
148
  { params: { tenantId } }
132
149
  );
@@ -134,32 +151,115 @@ export class CustomerClient {
134
151
  }
135
152
 
136
153
  /**
137
- * Get all bookings for a customer
138
- * GET /api/v1/customers/bookings
139
- *
140
- * @param params - Query parameters (email optional, userId optional, tenantId required)
141
- * @returns Customer bookings data
154
+ * Get all bookings for a customer, grouped by slot.
155
+ * GET /api/v1/customers/bookings?tenantId=:id
142
156
  */
143
157
  async getBookings(params: {
144
158
  email?: string;
145
159
  userId?: string;
146
160
  tenantId: string;
147
- }): Promise<ApiResponse<{
148
- customer: Customer | null;
149
- bookings: Booking[];
150
- groupedBySlot: Record<string, Booking[]>;
151
- total: number;
152
- }>> {
153
- const response = await this.client.get<ApiResponse<{
154
- customer: Customer | null;
155
- bookings: Booking[];
156
- groupedBySlot: Record<string, Booking[]>;
157
- total: number;
158
- }>>(
161
+ }): Promise<ApiResponse<CustomerWithBookings>> {
162
+ const response = await this.client.get<ApiResponse<CustomerWithBookings>>(
159
163
  '/api/v1/customers/bookings',
160
164
  { params }
161
165
  );
162
166
  return response.data;
163
167
  }
168
+
169
+ // ─── Atomic upsert ────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Atomic upsert: find an existing customer by email within a tenant, or create one.
173
+ * POST /api/v1/customers/find-or-create
174
+ */
175
+ async findOrCreate(options: FindOrCreateCustomerOptions): Promise<ApiResponse<Customer & { created: boolean }>> {
176
+ const response = await this.client.post<ApiResponse<Customer & { created: boolean }>>(
177
+ '/api/v1/customers/find-or-create',
178
+ options
179
+ );
180
+ return response.data;
181
+ }
182
+
183
+ /**
184
+ * Resolve a customer from an IDP identity or email.
185
+ * Tries idpId lookup (customer_users) first, then falls back to email.
186
+ * POST /api/v1/customers/resolve
187
+ */
188
+ async resolve(options: ResolveCustomerOptions): Promise<ApiResponse<Customer | null>> {
189
+ const response = await this.client.post<ApiResponse<Customer | null>>(
190
+ '/api/v1/customers/resolve',
191
+ options
192
+ );
193
+ return response.data;
194
+ }
195
+
196
+ // ─── IDP Identity Management ─────────────────────────────────────────────
197
+
198
+ /**
199
+ * Get a customer with their linked IDP identities (customer_users rows).
200
+ * GET /api/v1/customers/:id?include_identities=true
201
+ */
202
+ async getWithIdentities(id: string): Promise<ApiResponse<CustomerWithIdentities>> {
203
+ const response = await this.client.get<ApiResponse<CustomerWithIdentities>>(
204
+ `/api/v1/customers/${id}`,
205
+ { params: { include_identities: true } }
206
+ );
207
+ return response.data;
208
+ }
209
+
210
+ /**
211
+ * Link an IDP user ID to a customer (creates a customer_users row).
212
+ * POST /api/v1/customers/:id/users
213
+ */
214
+ async linkUser(
215
+ customerId: string,
216
+ idpId: string,
217
+ options?: { role?: CustomerIdentity['role']; is_primary?: boolean }
218
+ ): Promise<ApiResponse<CustomerIdentity>> {
219
+ const response = await this.client.post<ApiResponse<CustomerIdentity>>(
220
+ `/api/v1/customers/${customerId}/users`,
221
+ { idp_id: idpId, role: options?.role ?? 'member', is_primary: options?.is_primary ?? false }
222
+ );
223
+ return response.data;
224
+ }
225
+
226
+ /**
227
+ * Unlink an IDP user from a customer (deletes the customer_users row).
228
+ * DELETE /api/v1/customers/:id/users/:idpId
229
+ */
230
+ async unlinkUser(customerId: string, idpId: string): Promise<ApiResponse<void>> {
231
+ const response = await this.client.delete<ApiResponse<void>>(
232
+ `/api/v1/customers/${customerId}/users/${encodeURIComponent(idpId)}`
233
+ );
234
+ return response.data;
235
+ }
236
+
237
+ // ─── Notification Preferences ────────────────────────────────────────────
238
+
239
+ /**
240
+ * Get notification preferences for a customer.
241
+ * GET /api/v1/customers/:id/preferences/notifications
242
+ */
243
+ async getNotificationPreferences(customerId: string): Promise<ApiResponse<NotificationPreferences>> {
244
+ const response = await this.client.get<ApiResponse<NotificationPreferences>>(
245
+ `/api/v1/customers/${customerId}/preferences/notifications`
246
+ );
247
+ return response.data;
248
+ }
249
+
250
+ /**
251
+ * Update notification preferences for a customer (partial merge).
252
+ * PATCH /api/v1/customers/:id/preferences/notifications
253
+ */
254
+ async updateNotificationPreferences(
255
+ customerId: string,
256
+ preferences: Partial<NotificationPreferences>
257
+ ): Promise<ApiResponse<NotificationPreferences>> {
258
+ const response = await this.client.patch<ApiResponse<NotificationPreferences>>(
259
+ `/api/v1/customers/${customerId}/preferences/notifications`,
260
+ preferences
261
+ );
262
+ return response.data;
263
+ }
164
264
  }
165
265