@startsimpli/funnels 0.1.4 → 0.1.6

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 (151) hide show
  1. package/package.json +9 -31
  2. package/src/api/README.md +507 -0
  3. package/src/api/adapter.ts +106 -0
  4. package/src/api/client.test.ts +640 -0
  5. package/src/api/client.ts +385 -0
  6. package/src/api/default-adapter.ts +243 -0
  7. package/src/api/index.ts +24 -0
  8. package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
  9. package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
  13. package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
  15. package/src/components/FilterRuleEditor/README.md +291 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
  17. package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
  18. package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
  19. package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
  20. package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
  21. package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
  22. package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
  23. package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
  24. package/src/components/FilterRuleEditor/constants.ts +64 -0
  25. package/src/components/FilterRuleEditor/index.ts +14 -0
  26. package/src/components/FunnelCard/DESIGN.md +447 -0
  27. package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
  28. package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
  29. package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
  30. package/src/components/FunnelCard/FunnelCard.tsx +204 -0
  31. package/src/components/FunnelCard/FunnelStats.tsx +68 -0
  32. package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
  33. package/src/components/FunnelCard/INSTALLATION.md +304 -0
  34. package/src/components/FunnelCard/MatchBar.tsx +49 -0
  35. package/src/components/FunnelCard/README.md +294 -0
  36. package/src/components/FunnelCard/StageIndicator.tsx +62 -0
  37. package/src/components/FunnelCard/StatusBadge.tsx +52 -0
  38. package/src/components/FunnelCard/index.ts +14 -0
  39. package/src/components/FunnelPreview/EntityCard.tsx +72 -0
  40. package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
  41. package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
  42. package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
  43. package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
  44. package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
  45. package/src/components/FunnelPreview/README.md +337 -0
  46. package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
  47. package/src/components/FunnelPreview/example.tsx +286 -0
  48. package/src/components/FunnelPreview/index.ts +14 -0
  49. package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
  50. package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
  51. package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
  52. package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
  53. package/src/components/FunnelRunHistory/README.md +325 -0
  54. package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
  55. package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
  56. package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
  57. package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
  58. package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
  59. package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
  60. package/src/components/FunnelRunHistory/index.ts +51 -0
  61. package/src/components/FunnelRunHistory/types.ts +40 -0
  62. package/src/components/FunnelRunHistory/utils.test.ts +126 -0
  63. package/src/components/FunnelRunHistory/utils.ts +100 -0
  64. package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
  65. package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
  66. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
  67. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
  68. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
  69. package/src/components/FunnelStageBuilder/README.md +341 -0
  70. package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
  71. package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
  72. package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
  73. package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
  74. package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
  75. package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
  76. package/src/components/FunnelStageBuilder/index.ts +21 -0
  77. package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
  78. package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
  79. package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
  80. package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
  81. package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
  82. package/src/components/FunnelVisualFlow/README.md +323 -0
  83. package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
  84. package/src/components/FunnelVisualFlow/example.tsx +227 -0
  85. package/src/components/FunnelVisualFlow/index.ts +10 -0
  86. package/src/components/index.ts +102 -0
  87. package/src/core/README.md +307 -0
  88. package/src/core/engine.test.ts +1087 -0
  89. package/src/core/engine.ts +329 -0
  90. package/src/core/evaluator.example.ts +353 -0
  91. package/src/core/evaluator.test.ts +639 -0
  92. package/src/core/evaluator.ts +261 -0
  93. package/src/core/field-resolver.example.ts +175 -0
  94. package/src/core/field-resolver.test.ts +541 -0
  95. package/src/core/field-resolver.ts +247 -0
  96. package/src/core/index.ts +34 -0
  97. package/src/core/operators.test.ts +539 -0
  98. package/src/core/operators.ts +241 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/useDebouncedValue.ts +28 -0
  101. package/src/index.ts +155 -0
  102. package/src/store/README.md +342 -0
  103. package/src/store/create-funnel-store.test.ts +686 -0
  104. package/src/store/create-funnel-store.ts +538 -0
  105. package/src/store/index.ts +9 -0
  106. package/src/store/types.ts +294 -0
  107. package/src/stories/CrossDomain.stories.tsx +149 -0
  108. package/src/stories/Welcome.stories.tsx +81 -0
  109. package/src/stories/demo-data/index.ts +3 -0
  110. package/src/stories/demo-data/investors.ts +216 -0
  111. package/src/stories/demo-data/leads.ts +223 -0
  112. package/src/stories/demo-data/recipes.ts +217 -0
  113. package/src/test/setup.ts +5 -0
  114. package/src/types/index.ts +843 -0
  115. package/dist/client-3ESO2NHy.d.ts +0 -310
  116. package/dist/client-CZu03ACp.d.cts +0 -310
  117. package/dist/components/index.cjs +0 -3241
  118. package/dist/components/index.cjs.map +0 -1
  119. package/dist/components/index.css.map +0 -1
  120. package/dist/components/index.d.cts +0 -726
  121. package/dist/components/index.d.ts +0 -726
  122. package/dist/components/index.js +0 -3194
  123. package/dist/components/index.js.map +0 -1
  124. package/dist/core/index.cjs +0 -500
  125. package/dist/core/index.cjs.map +0 -1
  126. package/dist/core/index.d.cts +0 -359
  127. package/dist/core/index.d.ts +0 -359
  128. package/dist/core/index.js +0 -486
  129. package/dist/core/index.js.map +0 -1
  130. package/dist/hooks/index.cjs +0 -20
  131. package/dist/hooks/index.cjs.map +0 -1
  132. package/dist/hooks/index.d.cts +0 -11
  133. package/dist/hooks/index.d.ts +0 -11
  134. package/dist/hooks/index.js +0 -18
  135. package/dist/hooks/index.js.map +0 -1
  136. package/dist/index-BGDEXbuz.d.cts +0 -434
  137. package/dist/index-BGDEXbuz.d.ts +0 -434
  138. package/dist/index.cjs +0 -4499
  139. package/dist/index.cjs.map +0 -1
  140. package/dist/index.css +0 -198
  141. package/dist/index.css.map +0 -1
  142. package/dist/index.d.cts +0 -99
  143. package/dist/index.d.ts +0 -99
  144. package/dist/index.js +0 -4421
  145. package/dist/index.js.map +0 -1
  146. package/dist/store/index.cjs +0 -389
  147. package/dist/store/index.cjs.map +0 -1
  148. package/dist/store/index.d.cts +0 -225
  149. package/dist/store/index.d.ts +0 -225
  150. package/dist/store/index.js +0 -386
  151. package/dist/store/index.js.map +0 -1
@@ -0,0 +1,640 @@
1
+ /**
2
+ * Tests for FunnelApiClient
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { FunnelApiClient, PaginatedResponse } from './client';
7
+ import type { ApiAdapter } from './adapter';
8
+ import { createApiError } from './adapter';
9
+ import type {
10
+ Funnel,
11
+ FunnelStage,
12
+ FunnelRun,
13
+ FunnelResult,
14
+ CreateFunnelInput,
15
+ } from '../types';
16
+
17
+ /**
18
+ * Mock adapter for testing
19
+ */
20
+ class MockAdapter implements ApiAdapter {
21
+ public lastRequest: {
22
+ method: string;
23
+ url: string;
24
+ data?: any;
25
+ params?: Record<string, any>;
26
+ } | null = null;
27
+
28
+ private responses = new Map<string, any>();
29
+ private errors = new Map<string, any>();
30
+
31
+ /**
32
+ * Mock a successful response
33
+ */
34
+ mockResponse(url: string, response: any): void {
35
+ this.responses.set(url, response);
36
+ }
37
+
38
+ /**
39
+ * Mock an error response
40
+ */
41
+ mockError(url: string, status: number, message: string): void {
42
+ this.errors.set(url, { status, message });
43
+ }
44
+
45
+ /**
46
+ * Clear mocks
47
+ */
48
+ clear(): void {
49
+ this.responses.clear();
50
+ this.errors.clear();
51
+ this.lastRequest = null;
52
+ }
53
+
54
+ private async handleRequest<T>(
55
+ method: string,
56
+ url: string,
57
+ data?: any,
58
+ params?: Record<string, any>
59
+ ): Promise<T> {
60
+ this.lastRequest = { method, url, data, params };
61
+
62
+ // Check for error
63
+ if (this.errors.has(url)) {
64
+ const error = this.errors.get(url);
65
+ throw createApiError(error.message, error.status);
66
+ }
67
+
68
+ // Check for response
69
+ if (this.responses.has(url)) {
70
+ return this.responses.get(url);
71
+ }
72
+
73
+ throw createApiError(`No mock response for ${url}`, 404);
74
+ }
75
+
76
+ async get<T>(url: string, params?: Record<string, any>): Promise<T> {
77
+ return this.handleRequest<T>('GET', url, undefined, params);
78
+ }
79
+
80
+ async post<T>(url: string, data: any): Promise<T> {
81
+ return this.handleRequest<T>('POST', url, data);
82
+ }
83
+
84
+ async patch<T>(url: string, data: any): Promise<T> {
85
+ return this.handleRequest<T>('PATCH', url, data);
86
+ }
87
+
88
+ async delete<T>(url: string): Promise<T> {
89
+ return this.handleRequest<T>('DELETE', url);
90
+ }
91
+ }
92
+
93
+ describe('FunnelApiClient', () => {
94
+ let adapter: MockAdapter;
95
+ let client: FunnelApiClient;
96
+ const baseUrl = 'https://api.example.com';
97
+
98
+ beforeEach(() => {
99
+ adapter = new MockAdapter();
100
+ client = new FunnelApiClient(adapter, baseUrl);
101
+ });
102
+
103
+ // ============================================================================
104
+ // Funnel CRUD
105
+ // ============================================================================
106
+
107
+ describe('listFunnels', () => {
108
+ it('should fetch list of funnels', async () => {
109
+ const mockResponse: PaginatedResponse<Funnel> = {
110
+ count: 2,
111
+ next: null,
112
+ previous: null,
113
+ results: [
114
+ {
115
+ id: 'funnel-1',
116
+ name: 'Investor Qualification',
117
+ status: 'active',
118
+ input_type: 'contacts',
119
+ stages: [],
120
+ created_at: '2024-01-01T00:00:00Z',
121
+ updated_at: '2024-01-01T00:00:00Z',
122
+ },
123
+ {
124
+ id: 'funnel-2',
125
+ name: 'Recipe Recommendations',
126
+ status: 'draft',
127
+ input_type: 'any',
128
+ stages: [],
129
+ created_at: '2024-01-02T00:00:00Z',
130
+ updated_at: '2024-01-02T00:00:00Z',
131
+ },
132
+ ],
133
+ };
134
+
135
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/`, mockResponse);
136
+
137
+ const result = await client.listFunnels();
138
+
139
+ expect(result.count).toBe(2);
140
+ expect(result.results).toHaveLength(2);
141
+ expect(adapter.lastRequest?.method).toBe('GET');
142
+ expect(adapter.lastRequest?.url).toBe(`${baseUrl}/api/v1/funnels/`);
143
+ });
144
+
145
+ it('should pass filters as query params', async () => {
146
+ const mockResponse: PaginatedResponse<Funnel> = {
147
+ count: 1,
148
+ next: null,
149
+ previous: null,
150
+ results: [],
151
+ };
152
+
153
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/`, mockResponse);
154
+
155
+ await client.listFunnels({
156
+ status: 'active',
157
+ page: 2,
158
+ page_size: 10,
159
+ ordering: '-created_at',
160
+ });
161
+
162
+ expect(adapter.lastRequest?.params).toEqual({
163
+ status: 'active',
164
+ page: 2,
165
+ page_size: 10,
166
+ ordering: '-created_at',
167
+ });
168
+ });
169
+ });
170
+
171
+ describe('getFunnel', () => {
172
+ it('should fetch single funnel', async () => {
173
+ const mockFunnel: Funnel = {
174
+ id: 'funnel-1',
175
+ name: 'Test Funnel',
176
+ status: 'active',
177
+ input_type: 'contacts',
178
+ stages: [
179
+ {
180
+ id: 'stage-1',
181
+ order: 0,
182
+ name: 'Stage 1',
183
+ filter_logic: 'AND',
184
+ rules: [],
185
+ match_action: 'continue',
186
+ no_match_action: 'exclude',
187
+ },
188
+ ],
189
+ created_at: '2024-01-01T00:00:00Z',
190
+ updated_at: '2024-01-01T00:00:00Z',
191
+ };
192
+
193
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/`, mockFunnel);
194
+
195
+ const result = await client.getFunnel('funnel-1');
196
+
197
+ expect(result.id).toBe('funnel-1');
198
+ expect(result.name).toBe('Test Funnel');
199
+ expect(result.stages).toHaveLength(1);
200
+ });
201
+
202
+ it('should throw 404 for non-existent funnel', async () => {
203
+ adapter.mockError(`${baseUrl}/api/v1/funnels/invalid/`, 404, 'Not found');
204
+
205
+ await expect(client.getFunnel('invalid')).rejects.toThrow('Not found');
206
+ });
207
+ });
208
+
209
+ describe('createFunnel', () => {
210
+ it('should create new funnel', async () => {
211
+ const input: CreateFunnelInput = {
212
+ name: 'New Funnel',
213
+ status: 'draft',
214
+ input_type: 'contacts',
215
+ stages: [],
216
+ };
217
+
218
+ const mockResponse: Funnel = {
219
+ ...input,
220
+ id: 'funnel-new',
221
+ created_at: '2024-01-01T00:00:00Z',
222
+ updated_at: '2024-01-01T00:00:00Z',
223
+ };
224
+
225
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/`, mockResponse);
226
+
227
+ const result = await client.createFunnel(input);
228
+
229
+ expect(result.id).toBe('funnel-new');
230
+ expect(result.name).toBe('New Funnel');
231
+ expect(adapter.lastRequest?.method).toBe('POST');
232
+ expect(adapter.lastRequest?.data).toEqual(input);
233
+ });
234
+
235
+ it('should throw 400 for invalid input', async () => {
236
+ adapter.mockError(`${baseUrl}/api/v1/funnels/`, 400, 'Invalid input');
237
+
238
+ const input: CreateFunnelInput = {
239
+ name: '', // Invalid: empty name
240
+ status: 'draft',
241
+ input_type: 'contacts',
242
+ stages: [],
243
+ };
244
+
245
+ await expect(client.createFunnel(input)).rejects.toThrow('Invalid input');
246
+ });
247
+ });
248
+
249
+ describe('updateFunnel', () => {
250
+ it('should update funnel', async () => {
251
+ const mockResponse: Funnel = {
252
+ id: 'funnel-1',
253
+ name: 'Updated Funnel',
254
+ status: 'active',
255
+ input_type: 'contacts',
256
+ stages: [],
257
+ created_at: '2024-01-01T00:00:00Z',
258
+ updated_at: '2024-01-02T00:00:00Z',
259
+ };
260
+
261
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/`, mockResponse);
262
+
263
+ const result = await client.updateFunnel('funnel-1', { name: 'Updated Funnel' });
264
+
265
+ expect(result.name).toBe('Updated Funnel');
266
+ expect(adapter.lastRequest?.method).toBe('PATCH');
267
+ expect(adapter.lastRequest?.data).toEqual({ name: 'Updated Funnel' });
268
+ });
269
+ });
270
+
271
+ describe('deleteFunnel', () => {
272
+ it('should delete funnel', async () => {
273
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/`, undefined);
274
+
275
+ await client.deleteFunnel('funnel-1');
276
+
277
+ expect(adapter.lastRequest?.method).toBe('DELETE');
278
+ expect(adapter.lastRequest?.url).toBe(`${baseUrl}/api/v1/funnels/funnel-1/`);
279
+ });
280
+ });
281
+
282
+ // ============================================================================
283
+ // Stage CRUD
284
+ // ============================================================================
285
+
286
+ describe('createStage', () => {
287
+ it('should create stage', async () => {
288
+ const input = {
289
+ order: 0,
290
+ name: 'New Stage',
291
+ filter_logic: 'AND' as const,
292
+ rules: [],
293
+ match_action: 'continue' as const,
294
+ no_match_action: 'exclude' as const,
295
+ };
296
+
297
+ const mockResponse: FunnelStage = {
298
+ ...input,
299
+ id: 'stage-new',
300
+ };
301
+
302
+ adapter.mockResponse(
303
+ `${baseUrl}/api/v1/funnels/funnel-1/stages/`,
304
+ mockResponse
305
+ );
306
+
307
+ const result = await client.createStage('funnel-1', input);
308
+
309
+ expect(result.id).toBe('stage-new');
310
+ expect(result.name).toBe('New Stage');
311
+ expect(adapter.lastRequest?.method).toBe('POST');
312
+ });
313
+ });
314
+
315
+ describe('updateStage', () => {
316
+ it('should update stage', async () => {
317
+ const mockResponse: FunnelStage = {
318
+ id: 'stage-1',
319
+ order: 0,
320
+ name: 'Updated Stage',
321
+ filter_logic: 'AND',
322
+ rules: [],
323
+ match_action: 'continue',
324
+ no_match_action: 'exclude',
325
+ };
326
+
327
+ adapter.mockResponse(
328
+ `${baseUrl}/api/v1/funnels/funnel-1/stages/stage-1/`,
329
+ mockResponse
330
+ );
331
+
332
+ const result = await client.updateStage('funnel-1', 'stage-1', {
333
+ name: 'Updated Stage',
334
+ });
335
+
336
+ expect(result.name).toBe('Updated Stage');
337
+ expect(adapter.lastRequest?.method).toBe('PATCH');
338
+ });
339
+ });
340
+
341
+ describe('deleteStage', () => {
342
+ it('should delete stage', async () => {
343
+ adapter.mockResponse(
344
+ `${baseUrl}/api/v1/funnels/funnel-1/stages/stage-1/`,
345
+ undefined
346
+ );
347
+
348
+ await client.deleteStage('funnel-1', 'stage-1');
349
+
350
+ expect(adapter.lastRequest?.method).toBe('DELETE');
351
+ });
352
+ });
353
+
354
+ // ============================================================================
355
+ // Run Operations
356
+ // ============================================================================
357
+
358
+ describe('runFunnel', () => {
359
+ it('should trigger funnel run', async () => {
360
+ const mockRun: FunnelRun = {
361
+ id: 'run-1',
362
+ funnel_id: 'funnel-1',
363
+ status: 'pending',
364
+ trigger_type: 'manual',
365
+ started_at: '2024-01-01T00:00:00Z',
366
+ total_input: 0,
367
+ total_matched: 0,
368
+ total_excluded: 0,
369
+ total_tagged: 0,
370
+ stage_stats: {},
371
+ };
372
+
373
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/run/`, mockRun);
374
+
375
+ const result = await client.runFunnel('funnel-1', { trigger_type: 'manual' });
376
+
377
+ expect(result.id).toBe('run-1');
378
+ expect(result.status).toBe('pending');
379
+ expect(adapter.lastRequest?.method).toBe('POST');
380
+ });
381
+ });
382
+
383
+ describe('getFunnelRuns', () => {
384
+ it('should fetch run history', async () => {
385
+ const mockResponse: PaginatedResponse<FunnelRun> = {
386
+ count: 1,
387
+ next: null,
388
+ previous: null,
389
+ results: [
390
+ {
391
+ id: 'run-1',
392
+ funnel_id: 'funnel-1',
393
+ status: 'completed',
394
+ trigger_type: 'manual',
395
+ started_at: '2024-01-01T00:00:00Z',
396
+ completed_at: '2024-01-01T00:01:00Z',
397
+ total_input: 100,
398
+ total_matched: 50,
399
+ total_excluded: 50,
400
+ total_tagged: 50,
401
+ stage_stats: {},
402
+ },
403
+ ],
404
+ };
405
+
406
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/runs/`, mockResponse);
407
+
408
+ const result = await client.getFunnelRuns('funnel-1');
409
+
410
+ expect(result.count).toBe(1);
411
+ expect(result.results[0].status).toBe('completed');
412
+ });
413
+
414
+ it('should pass filters', async () => {
415
+ const mockResponse: PaginatedResponse<FunnelRun> = {
416
+ count: 0,
417
+ next: null,
418
+ previous: null,
419
+ results: [],
420
+ };
421
+
422
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/funnel-1/runs/`, mockResponse);
423
+
424
+ await client.getFunnelRuns('funnel-1', {
425
+ status: 'completed',
426
+ page: 1,
427
+ page_size: 20,
428
+ });
429
+
430
+ expect(adapter.lastRequest?.params).toEqual({
431
+ status: 'completed',
432
+ page: 1,
433
+ page_size: 20,
434
+ });
435
+ });
436
+ });
437
+
438
+ describe('getFunnelRun', () => {
439
+ it('should fetch single run', async () => {
440
+ const mockRun: FunnelRun = {
441
+ id: 'run-1',
442
+ funnel_id: 'funnel-1',
443
+ status: 'completed',
444
+ trigger_type: 'manual',
445
+ started_at: '2024-01-01T00:00:00Z',
446
+ completed_at: '2024-01-01T00:01:00Z',
447
+ total_input: 100,
448
+ total_matched: 50,
449
+ total_excluded: 50,
450
+ total_tagged: 50,
451
+ stage_stats: {},
452
+ };
453
+
454
+ adapter.mockResponse(`${baseUrl}/api/v1/funnel-runs/run-1/`, mockRun);
455
+
456
+ const result = await client.getFunnelRun('run-1');
457
+
458
+ expect(result.id).toBe('run-1');
459
+ expect(result.status).toBe('completed');
460
+ });
461
+ });
462
+
463
+ describe('getFunnelResults', () => {
464
+ it('should fetch run results', async () => {
465
+ const mockResponse: PaginatedResponse<FunnelResult> = {
466
+ count: 2,
467
+ next: null,
468
+ previous: null,
469
+ results: [
470
+ {
471
+ entity: { id: 'entity-1', name: 'Test Entity' },
472
+ matched: true,
473
+ accumulated_tags: ['qualified'],
474
+ context: {},
475
+ },
476
+ {
477
+ entity: { id: 'entity-2', name: 'Test Entity 2' },
478
+ matched: false,
479
+ excluded_at_stage: 'stage-1',
480
+ accumulated_tags: [],
481
+ context: {},
482
+ },
483
+ ],
484
+ };
485
+
486
+ adapter.mockResponse(
487
+ `${baseUrl}/api/v1/funnel-runs/run-1/results/`,
488
+ mockResponse
489
+ );
490
+
491
+ const result = await client.getFunnelResults('run-1');
492
+
493
+ expect(result.count).toBe(2);
494
+ expect(result.results[0].matched).toBe(true);
495
+ expect(result.results[1].matched).toBe(false);
496
+ });
497
+
498
+ it('should filter by matched', async () => {
499
+ const mockResponse: PaginatedResponse<FunnelResult> = {
500
+ count: 1,
501
+ next: null,
502
+ previous: null,
503
+ results: [],
504
+ };
505
+
506
+ adapter.mockResponse(
507
+ `${baseUrl}/api/v1/funnel-runs/run-1/results/`,
508
+ mockResponse
509
+ );
510
+
511
+ await client.getFunnelResults('run-1', { matched: true });
512
+
513
+ expect(adapter.lastRequest?.params).toEqual({ matched: true });
514
+ });
515
+ });
516
+
517
+ describe('cancelFunnelRun', () => {
518
+ it('should cancel running funnel', async () => {
519
+ const mockRun: FunnelRun = {
520
+ id: 'run-1',
521
+ funnel_id: 'funnel-1',
522
+ status: 'cancelled',
523
+ trigger_type: 'manual',
524
+ started_at: '2024-01-01T00:00:00Z',
525
+ total_input: 0,
526
+ total_matched: 0,
527
+ total_excluded: 0,
528
+ total_tagged: 0,
529
+ stage_stats: {},
530
+ };
531
+
532
+ adapter.mockResponse(`${baseUrl}/api/v1/funnel-runs/run-1/cancel/`, mockRun);
533
+
534
+ const result = await client.cancelFunnelRun('run-1');
535
+
536
+ expect(result.status).toBe('cancelled');
537
+ expect(adapter.lastRequest?.method).toBe('POST');
538
+ });
539
+ });
540
+
541
+ // ============================================================================
542
+ // Error Handling
543
+ // ============================================================================
544
+
545
+ describe('error handling', () => {
546
+ it('should handle 404 errors', async () => {
547
+ adapter.mockError(`${baseUrl}/api/v1/funnels/invalid/`, 404, 'Not found');
548
+
549
+ await expect(client.getFunnel('invalid')).rejects.toMatchObject({
550
+ message: 'Not found',
551
+ status: 404,
552
+ });
553
+ });
554
+
555
+ it('should handle 500 errors', async () => {
556
+ adapter.mockError(
557
+ `${baseUrl}/api/v1/funnels/`,
558
+ 500,
559
+ 'Internal server error'
560
+ );
561
+
562
+ await expect(client.listFunnels()).rejects.toMatchObject({
563
+ message: 'Internal server error',
564
+ status: 500,
565
+ });
566
+ });
567
+
568
+ it('should handle network errors', async () => {
569
+ // No mock response = network error
570
+ await expect(client.getFunnel('funnel-1')).rejects.toThrow();
571
+ });
572
+ });
573
+
574
+ // ============================================================================
575
+ // Preview
576
+ // ============================================================================
577
+
578
+ describe('preview', () => {
579
+ it('should throw for client-side preview (not implemented)', async () => {
580
+ const funnel: Funnel = {
581
+ id: 'funnel-1',
582
+ name: 'Test Funnel',
583
+ status: 'active',
584
+ input_type: 'contacts',
585
+ stages: [],
586
+ created_at: '2024-01-01T00:00:00Z',
587
+ updated_at: '2024-01-01T00:00:00Z',
588
+ };
589
+
590
+ await expect(
591
+ client.previewFunnel(funnel, [{ id: 'entity-1' }])
592
+ ).rejects.toThrow('Client-side preview requires evaluation engine');
593
+ });
594
+
595
+ it('should call server-side preview', async () => {
596
+ const mockPreview = {
597
+ matched_entities: [{ id: 'entity-1' }],
598
+ excluded_entities: [],
599
+ stage_breakdown: [],
600
+ total_input: 1,
601
+ total_matched: 1,
602
+ total_excluded: 0,
603
+ };
604
+
605
+ adapter.mockResponse(
606
+ `${baseUrl}/api/v1/funnels/funnel-1/preview/`,
607
+ mockPreview
608
+ );
609
+
610
+ const result = await client.previewFunnelServer('funnel-1', [
611
+ { id: 'entity-1' },
612
+ ]);
613
+
614
+ expect(result.total_matched).toBe(1);
615
+ expect(adapter.lastRequest?.method).toBe('POST');
616
+ expect(adapter.lastRequest?.data).toEqual({
617
+ entities: [{ id: 'entity-1' }],
618
+ });
619
+ });
620
+ });
621
+
622
+ // ============================================================================
623
+ // URL Handling
624
+ // ============================================================================
625
+
626
+ describe('URL handling', () => {
627
+ it('should remove trailing slash from baseUrl', () => {
628
+ const clientWithTrailingSlash = new FunnelApiClient(
629
+ adapter,
630
+ 'https://api.example.com/'
631
+ );
632
+
633
+ adapter.mockResponse(`${baseUrl}/api/v1/funnels/`, { count: 0, results: [] });
634
+
635
+ clientWithTrailingSlash.listFunnels();
636
+
637
+ expect(adapter.lastRequest?.url).toBe(`${baseUrl}/api/v1/funnels/`);
638
+ });
639
+ });
640
+ });