@slotchain/sdk 1.1.3 → 1.1.4

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,5 @@
1
1
  import { AxiosInstance } from 'axios';
2
- import { ApiResponse, Service, ServiceWithItems, Category } from '../types/api';
2
+ import { ApiResponse, Service, ServiceWithItems, SlotServiceWithItems, Category } from '../types/api';
3
3
 
4
4
  export class ServiceClient {
5
5
  constructor(private client: AxiosInstance) {}
@@ -20,10 +20,95 @@ export class ServiceClient {
20
20
  return response.data;
21
21
  }
22
22
 
23
+ /**
24
+ * Get services for a tenant (full list with optional filters)
25
+ * GET /api/v1/services
26
+ *
27
+ * @param options - Query options
28
+ * @param options.tenantId - Tenant ID (UUID) - required if tenantSlug not provided
29
+ * @param options.tenantSlug - Tenant slug - required if tenantId not provided
30
+ * @param options.slotId - Slot ID (DEPRECATED - accepted but ignored for API compatibility)
31
+ * @param options.isActive - Filter by active status
32
+ * @param options.includeItems - Include nested service items (default: false)
33
+ * @param options.page - Page number (default: 1)
34
+ * @param options.limit - Items per page (default: 20, max: 100)
35
+ * @returns Services with optional pagination metadata
36
+ */
37
+ async getServicesByTenant(options: {
38
+ tenantId?: string;
39
+ tenantSlug?: string;
40
+ slotId?: string; // DEPRECATED - accepted but ignored
41
+ isActive?: boolean;
42
+ includeItems?: boolean;
43
+ page?: number;
44
+ limit?: number;
45
+ }): Promise<ApiResponse<Service[] | ServiceWithItems[]>> {
46
+ // Validate that either tenantId or tenantSlug is provided
47
+ if (!options.tenantId && !options.tenantSlug) {
48
+ return {
49
+ success: false,
50
+ error: {
51
+ code: 'MISSING_TENANT_ID',
52
+ message: 'tenantId or tenantSlug is required',
53
+ },
54
+ };
55
+ }
56
+
57
+ // Build query parameters
58
+ const params: Record<string, any> = {};
59
+
60
+ if (options.tenantId) {
61
+ params.tenant_id = options.tenantId;
62
+ }
63
+ if (options.tenantSlug) {
64
+ params.tenantSlug = options.tenantSlug;
65
+ }
66
+ if (options.slotId) {
67
+ // Include for API compatibility, even though it's ignored
68
+ params.slot_id = options.slotId;
69
+ }
70
+ if (options.isActive !== undefined) {
71
+ params.is_active = options.isActive;
72
+ }
73
+ if (options.includeItems) {
74
+ params.includeItems = true;
75
+ }
76
+ if (options.page !== undefined) {
77
+ params.page = options.page;
78
+ }
79
+ if (options.limit !== undefined) {
80
+ params.limit = Math.min(options.limit, 100); // Enforce max limit
81
+ }
82
+
83
+ const response = await this.client.get<ApiResponse<Service[] | ServiceWithItems[]>>(
84
+ '/api/v1/services',
85
+ { params }
86
+ );
87
+ return response.data;
88
+ }
89
+
90
+ /**
91
+ * Get services for a specific slot
92
+ * GET /api/v1/slots/:id/services
93
+ *
94
+ * Note: Services are tenant-level entities. This endpoint returns all active services
95
+ * for the slot's tenant with items included. The response uses 'items' property (not 'serviceItems').
96
+ *
97
+ * @param slotId - Slot ID (UUID)
98
+ * @returns Services with nested items (always includes items, only active services)
99
+ */
100
+ async getServicesBySlot(slotId: string): Promise<ApiResponse<SlotServiceWithItems[]>> {
101
+ const response = await this.client.get<ApiResponse<SlotServiceWithItems[]>>(
102
+ `/api/v1/slots/${slotId}/services`
103
+ );
104
+ return response.data;
105
+ }
106
+
23
107
  /**
24
108
  * List services by tenant slug
25
109
  * GET /api/v1/services?tenantSlug=:slug
26
110
  *
111
+ * @deprecated Consider using getServicesByTenant() for better type safety and validation
27
112
  * @param slug - Tenant slug (or use tenant_id in list() method)
28
113
  * @param params - Optional parameters (includeItems, page, limit, is_active)
29
114
  * @returns List of services for the tenant
@@ -48,6 +133,7 @@ export class ServiceClient {
48
133
  * List services with optional filters
49
134
  * GET /api/v1/services
50
135
  *
136
+ * @deprecated Consider using getServicesByTenant() for better type safety and validation
51
137
  * @param params - Query parameters (tenant_id, tenantSlug, slot_id, page, limit, is_active, includeItems)
52
138
  * @returns Paginated list of services
53
139
  */
@@ -1,5 +1,5 @@
1
1
  import { AxiosInstance } from 'axios';
2
- import { ApiResponse, Slot, ServiceWithItems, Tenant } from '../types/api';
2
+ import { ApiResponse, Slot, SlotServiceWithItems, Tenant } from '../types/api';
3
3
 
4
4
  export class SlotClient {
5
5
  constructor(private client: AxiosInstance) {}
@@ -113,11 +113,14 @@ export class SlotClient {
113
113
  * Get all active services for a slot with their items
114
114
  * GET /api/v1/slots/:id/services
115
115
  *
116
- * @param id - Slot ID
117
- * @returns Services with nested service items
116
+ * Note: Services are tenant-level entities. This endpoint returns all active services
117
+ * for the slot's tenant with items included. The response uses 'items' property (not 'serviceItems').
118
+ *
119
+ * @param id - Slot ID (UUID)
120
+ * @returns Services with nested items (always includes items, only active services)
118
121
  */
119
- async getServices(id: string): Promise<ApiResponse<ServiceWithItems[]>> {
120
- const response = await this.client.get<ApiResponse<ServiceWithItems[]>>(
122
+ async getServices(id: string): Promise<ApiResponse<SlotServiceWithItems[]>> {
123
+ const response = await this.client.get<ApiResponse<SlotServiceWithItems[]>>(
121
124
  `/api/v1/slots/${id}/services`
122
125
  );
123
126
  return response.data;
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export type {
26
26
  Service,
27
27
  ServiceItem,
28
28
  ServiceWithItems,
29
+ SlotServiceWithItems,
29
30
  ServiceItemWithService,
30
31
  Category,
31
32
  Slot,
package/src/types/api.ts CHANGED
@@ -59,23 +59,35 @@ export interface TenantBranding {
59
59
  */
60
60
  export interface ServiceItem {
61
61
  id: string;
62
- serviceId: string;
62
+ service_id: string;
63
63
  name: string;
64
64
  description?: string;
65
65
  price?: number;
66
66
  duration?: number;
67
- available?: boolean;
68
- // TODO: Add actual service item properties
67
+ is_available?: boolean;
68
+ sort_order?: number;
69
+ created_at: string;
70
+ updated_at: string;
69
71
  }
70
72
 
71
73
  /**
72
- * Enhanced Service type with nested service items
74
+ * Enhanced Service type with nested service items (for tenant endpoint)
75
+ * Uses 'serviceItems' property name
73
76
  */
74
77
  export interface ServiceWithItems extends Service {
75
78
  /** Array of service items nested within this service (optional, may be empty array) */
76
79
  serviceItems?: ServiceItem[];
77
80
  }
78
81
 
82
+ /**
83
+ * Service with items for slot endpoint
84
+ * Uses 'items' property name (different from tenant endpoint)
85
+ */
86
+ export interface SlotServiceWithItems extends Service {
87
+ /** Array of service items nested within this service (always included for slot endpoint) */
88
+ items: ServiceItem[];
89
+ }
90
+
79
91
  /**
80
92
  * Service item with parent service reference
81
93
  */
@@ -148,12 +160,16 @@ export interface ListBookingsOptions {
148
160
 
149
161
  export interface Service {
150
162
  id: string;
151
- tenantId: string;
163
+ tenant_id: string;
164
+ slot_id?: string; // Present but not used in queries
152
165
  name: string;
153
166
  description?: string;
167
+ category?: string;
168
+ is_active?: boolean;
154
169
  duration?: number;
155
170
  price?: number;
156
- // TODO: Add actual service properties
171
+ created_at: string;
172
+ updated_at: string;
157
173
  }
158
174
 
159
175
  export interface Slot {