@startsimpli/funnels 0.4.11 → 0.4.14

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 (31) hide show
  1. package/README.md +326 -281
  2. package/package.json +17 -17
  3. package/src/api/client.paths.test.ts +162 -0
  4. package/src/api/client.ts +72 -19
  5. package/src/api/index.ts +11 -0
  6. package/src/api/paths.test.ts +125 -0
  7. package/src/api/paths.ts +146 -0
  8. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  9. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  12. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  13. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  14. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  15. package/src/components/FilterRuleEditor/constants.ts +12 -0
  16. package/src/components/FunnelPreview/example.tsx +15 -15
  17. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  18. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  22. package/src/core/evaluator.example.ts +23 -23
  23. package/src/core/evaluator.test.ts +4 -1
  24. package/src/core/evaluator.ts +11 -5
  25. package/src/core/operators.ts +35 -0
  26. package/src/store/create-funnel-store.ts +1 -0
  27. package/src/stories/demo-data/investors.ts +2 -2
  28. package/src/stories/demo-data/leads.ts +2 -2
  29. package/src/stories/demo-data/recipes.ts +2 -2
  30. package/src/types/contract.test.ts +284 -0
  31. package/src/types/index.ts +339 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/funnels",
3
- "version": "0.4.11",
3
+ "version": "0.4.14",
4
4
  "description": "Brutally generic filtering pipeline package for any Simpli product",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -38,25 +38,13 @@
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "https://github.com/simpli/start-simpli-api",
41
+ "url": "git+https://github.com/qosha1/start-simpli.git",
42
42
  "directory": "packages/funnels"
43
43
  },
44
44
  "bugs": {
45
- "url": "https://github.com/simpli/start-simpli-api/issues"
46
- },
47
- "homepage": "https://github.com/simpli/start-simpli-api/tree/main/packages/funnels#readme",
48
- "scripts": {
49
- "build": "tsup",
50
- "dev": "tsup --watch",
51
- "type-check": "tsc --noEmit",
52
- "test": "vitest run",
53
- "test:watch": "vitest",
54
- "test:coverage": "vitest run --coverage",
55
- "clean": "rm -rf dist",
56
- "storybook": "storybook dev -p 6006",
57
- "build-storybook": "storybook build",
58
- "storybook:ci": "storybook build --test"
45
+ "url": "https://github.com/qosha1/start-simpli/issues"
59
46
  },
47
+ "homepage": "https://github.com/qosha1/start-simpli/tree/main/packages/funnels#readme",
60
48
  "peerDependencies": {
61
49
  "react": "^18.0.0 || ^19.0.0",
62
50
  "react-dom": "^18.0.0 || ^19.0.0",
@@ -94,5 +82,17 @@
94
82
  "@dnd-kit/sortable": "^8.0.0",
95
83
  "@dnd-kit/utilities": "^3.2.2",
96
84
  "@xyflow/react": "^12.10.2"
85
+ },
86
+ "scripts": {
87
+ "build": "tsup",
88
+ "dev": "tsup --watch",
89
+ "type-check": "tsc --noEmit",
90
+ "test": "vitest run",
91
+ "test:watch": "vitest",
92
+ "test:coverage": "vitest run --coverage",
93
+ "clean": "rm -rf dist",
94
+ "storybook": "storybook dev -p 6006",
95
+ "build-storybook": "storybook build",
96
+ "storybook:ci": "storybook build --test"
97
97
  }
98
- }
98
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * FunnelApiClient against an injected path family (bd startsim-pxzlf).
3
+ *
4
+ * client.test.ts covers the default (central) family and the error handling.
5
+ * This file covers the part that was impossible before: pointing the SAME
6
+ * client at the control-plane route, a tenant proxy, or a fork's own mount.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest';
10
+ import { FunnelApiClient } from './client';
11
+ import { createFunnelPaths, foundryTenantFunnelPaths } from './paths';
12
+ import type { ApiAdapter } from './adapter';
13
+
14
+ class RecordingAdapter implements ApiAdapter {
15
+ public calls: { method: string; url: string; data?: unknown; params?: unknown }[] = [];
16
+ public nextResponse: unknown = {};
17
+
18
+ async get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
19
+ this.calls.push({ method: 'GET', url, params });
20
+ return this.nextResponse as T;
21
+ }
22
+ async post<T>(url: string, data: unknown): Promise<T> {
23
+ this.calls.push({ method: 'POST', url, data });
24
+ return this.nextResponse as T;
25
+ }
26
+ async patch<T>(url: string, data: unknown): Promise<T> {
27
+ this.calls.push({ method: 'PATCH', url, data });
28
+ return this.nextResponse as T;
29
+ }
30
+ async delete<T>(url: string): Promise<T> {
31
+ this.calls.push({ method: 'DELETE', url });
32
+ return this.nextResponse as T;
33
+ }
34
+ get lastUrl(): string {
35
+ return this.calls[this.calls.length - 1]?.url ?? '';
36
+ }
37
+ }
38
+
39
+ describe('FunnelApiClient path injection', () => {
40
+ let adapter: RecordingAdapter;
41
+
42
+ beforeEach(() => {
43
+ adapter = new RecordingAdapter();
44
+ });
45
+
46
+ it('keeps the central family when no paths are injected — the old constructor still works', async () => {
47
+ const client = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
48
+ await client.listFunnels();
49
+ expect(adapter.lastUrl).toBe('https://api.startsimpli.com/api/v1/funnels/');
50
+ await client.getFunnelRun('r1');
51
+ expect(adapter.lastUrl).toBe('https://api.startsimpli.com/api/v1/funnel-runs/r1/');
52
+ });
53
+
54
+ it('talks to a foundry control-plane route when given that family', async () => {
55
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
56
+
57
+ await client.listFunnels({ status: 'active' });
58
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/');
59
+
60
+ await client.getFunnel('f1');
61
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
62
+
63
+ await client.runFunnel('f1');
64
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/run/');
65
+
66
+ await client.getFunnelRuns('f1');
67
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
68
+ });
69
+
70
+ it('addresses a nested run through its funnel on the control plane', async () => {
71
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
72
+ await client.getFunnelRun('r1', 'f1');
73
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/');
74
+ await client.getFunnelResults('r1', { funnelId: 'f1' });
75
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/results/');
76
+ await client.cancelFunnelRun('r1', 'f1');
77
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/cancel/');
78
+ });
79
+
80
+ it('talks through a tenant proxy prefix', async () => {
81
+ const client = new FunnelApiClient(
82
+ adapter,
83
+ '',
84
+ createFunnelPaths({
85
+ prefix: '/central-api/api/v1/funnels',
86
+ runsPrefix: '/central-api/api/v1/funnel-runs',
87
+ })
88
+ );
89
+ await client.listFunnels();
90
+ expect(adapter.lastUrl).toBe('/central-api/api/v1/funnels/');
91
+ await client.getFunnelRun('r1');
92
+ expect(adapter.lastUrl).toBe('/central-api/api/v1/funnel-runs/r1/');
93
+ });
94
+
95
+ it('exposes the family it was built with, so a composer can tell the surfaces apart', () => {
96
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
97
+ expect(client.paths.prefix).toBe('/api/v1/foundry/tenants/acme/funnels');
98
+ });
99
+ });
100
+
101
+ describe('FunnelApiClient.getFields', () => {
102
+ let adapter: RecordingAdapter;
103
+
104
+ beforeEach(() => {
105
+ adapter = new RecordingAdapter();
106
+ });
107
+
108
+ it('fetches the field registry from the family it was pointed at', async () => {
109
+ adapter.nextResponse = { entity_type: 'contact', fields: [] };
110
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
111
+ await client.getFields('contact');
112
+ expect(adapter.lastUrl).toBe('/api/v1/foundry/tenants/acme/funnels/fields/');
113
+ expect(adapter.calls[0].params).toEqual({ entity_type: 'contact' });
114
+ });
115
+
116
+ it('normalizes the payload, whether or not the adapter camelizes it', async () => {
117
+ adapter.nextResponse = {
118
+ entity_type: 'contact',
119
+ fields: [
120
+ {
121
+ key: 'tag.stage_focus',
122
+ label: 'stage_focus',
123
+ category: 'Tags',
124
+ value_type: 'enum',
125
+ enum_values: ['seed'],
126
+ allowed_operators: ['eq', 'exists'],
127
+ },
128
+ ],
129
+ };
130
+ const client = new FunnelApiClient(adapter, '');
131
+ const registry = await client.getFields();
132
+
133
+ expect(registry.entityType).toBe('contact');
134
+ expect(registry.fields[0]).toMatchObject({
135
+ key: 'tag.stage_focus',
136
+ valueType: 'enum',
137
+ enumValues: ['seed'],
138
+ allowedOperators: ['eq', 'exists'],
139
+ });
140
+ });
141
+
142
+ it('reads a camelized payload unchanged', async () => {
143
+ adapter.nextResponse = {
144
+ entityType: 'sc_artist',
145
+ fields: [
146
+ {
147
+ key: 'metric.reach.followers',
148
+ label: 'followers',
149
+ category: 'Metrics',
150
+ valueType: 'number',
151
+ enumValues: [],
152
+ allowedOperators: ['gte', 'between'],
153
+ },
154
+ ],
155
+ };
156
+ const client = new FunnelApiClient(adapter, '');
157
+ const registry = await client.getFields('sc_artist');
158
+
159
+ expect(registry.entityType).toBe('sc_artist');
160
+ expect(registry.fields[0].allowedOperators).toEqual(['gte', 'between']);
161
+ });
162
+ });
package/src/api/client.ts CHANGED
@@ -8,16 +8,21 @@
8
8
  */
9
9
 
10
10
  import type { ApiAdapter } from './adapter';
11
+ import type { FunnelPaths } from './paths';
12
+ import { centralFunnelPaths } from './paths';
11
13
  import type {
12
14
  Funnel,
13
15
  FunnelStage,
14
16
  FunnelRun,
15
17
  FunnelResult,
18
+ FieldRegistry,
19
+ FieldDefinitionInput,
16
20
  CreateFunnelInput,
17
21
  UpdateFunnelInput,
18
22
  CreateStageInput,
19
23
  UpdateStageInput,
20
24
  } from '../types';
25
+ import { normalizeFieldDefinition } from '../types';
21
26
 
22
27
  /**
23
28
  * List filters for funnels
@@ -89,7 +94,12 @@ export interface PreviewResult<TEntity = any> {
89
94
  * Usage:
90
95
  * ```ts
91
96
  * const adapter = new FetchAdapter({ headers: { 'Authorization': 'Bearer token' } });
92
- * const client = new FunnelApiClient(adapter, 'https://api.example.com');
97
+ *
98
+ * // the central API
99
+ * const client = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
100
+ *
101
+ * // a foundry's funnels on the control plane (startsim-9xbz2)
102
+ * const tenant = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
93
103
  *
94
104
  * const funnels = await client.listFunnels({ status: 'active' });
95
105
  * const funnel = await client.getFunnel('funnel-123');
@@ -97,12 +107,17 @@ export interface PreviewResult<TEntity = any> {
97
107
  * ```
98
108
  */
99
109
  export class FunnelApiClient {
110
+ /** The path family this client talks to. Readable, so a caller can log it. */
111
+ public readonly paths: FunnelPaths;
112
+
100
113
  constructor(
101
114
  private adapter: ApiAdapter,
102
- private baseUrl: string
115
+ private baseUrl: string,
116
+ paths: FunnelPaths = centralFunnelPaths
103
117
  ) {
104
118
  // Remove trailing slash
105
119
  this.baseUrl = baseUrl.replace(/\/$/, '');
120
+ this.paths = paths;
106
121
  }
107
122
 
108
123
  /**
@@ -126,7 +141,7 @@ export class FunnelApiClient {
126
141
  filters?: FunnelListFilters
127
142
  ): Promise<PaginatedResponse<Funnel<TEntity>>> {
128
143
  return this.adapter.get<PaginatedResponse<Funnel<TEntity>>>(
129
- this.url('/api/v1/funnels/'),
144
+ this.url(this.paths.list()),
130
145
  filters
131
146
  );
132
147
  }
@@ -139,7 +154,7 @@ export class FunnelApiClient {
139
154
  * @throws ApiError with status 404 if not found
140
155
  */
141
156
  async getFunnel<TEntity = any>(id: string): Promise<Funnel<TEntity>> {
142
- return this.adapter.get<Funnel<TEntity>>(this.url(`/api/v1/funnels/${id}/`));
157
+ return this.adapter.get<Funnel<TEntity>>(this.url(this.paths.detail(id)));
143
158
  }
144
159
 
145
160
  /**
@@ -152,7 +167,7 @@ export class FunnelApiClient {
152
167
  async createFunnel<TEntity = any>(
153
168
  data: CreateFunnelInput<TEntity>
154
169
  ): Promise<Funnel<TEntity>> {
155
- return this.adapter.post<Funnel<TEntity>>(this.url('/api/v1/funnels/'), data);
170
+ return this.adapter.post<Funnel<TEntity>>(this.url(this.paths.list()), data);
156
171
  }
157
172
 
158
173
  /**
@@ -168,7 +183,7 @@ export class FunnelApiClient {
168
183
  data: Partial<UpdateFunnelInput<TEntity>>
169
184
  ): Promise<Funnel<TEntity>> {
170
185
  return this.adapter.patch<Funnel<TEntity>>(
171
- this.url(`/api/v1/funnels/${id}/`),
186
+ this.url(this.paths.detail(id)),
172
187
  data
173
188
  );
174
189
  }
@@ -180,7 +195,7 @@ export class FunnelApiClient {
180
195
  * @throws ApiError with status 404 if not found
181
196
  */
182
197
  async deleteFunnel(id: string): Promise<void> {
183
- return this.adapter.delete<void>(this.url(`/api/v1/funnels/${id}/`));
198
+ return this.adapter.delete<void>(this.url(this.paths.detail(id)));
184
199
  }
185
200
 
186
201
  // ============================================================================
@@ -200,7 +215,7 @@ export class FunnelApiClient {
200
215
  data: CreateStageInput<TEntity>
201
216
  ): Promise<FunnelStage<TEntity>> {
202
217
  return this.adapter.post<FunnelStage<TEntity>>(
203
- this.url(`/api/v1/funnels/${funnelId}/stages/`),
218
+ this.url(this.paths.stages(funnelId)),
204
219
  data
205
220
  );
206
221
  }
@@ -220,7 +235,7 @@ export class FunnelApiClient {
220
235
  data: Partial<UpdateStageInput<TEntity>>
221
236
  ): Promise<FunnelStage<TEntity>> {
222
237
  return this.adapter.patch<FunnelStage<TEntity>>(
223
- this.url(`/api/v1/funnels/${funnelId}/stages/${stageId}/`),
238
+ this.url(this.paths.stage(funnelId, stageId)),
224
239
  data
225
240
  );
226
241
  }
@@ -234,7 +249,7 @@ export class FunnelApiClient {
234
249
  */
235
250
  async deleteStage(funnelId: string, stageId: string): Promise<void> {
236
251
  return this.adapter.delete<void>(
237
- this.url(`/api/v1/funnels/${funnelId}/stages/${stageId}/`)
252
+ this.url(this.paths.stage(funnelId, stageId))
238
253
  );
239
254
  }
240
255
 
@@ -258,7 +273,7 @@ export class FunnelApiClient {
258
273
  }
259
274
  ): Promise<FunnelRun> {
260
275
  return this.adapter.post<FunnelRun>(
261
- this.url(`/api/v1/funnels/${funnelId}/run/`),
276
+ this.url(this.paths.run(funnelId)),
262
277
  options || {}
263
278
  );
264
279
  }
@@ -281,7 +296,7 @@ export class FunnelApiClient {
281
296
  }
282
297
  ): Promise<PaginatedResponse<FunnelRun>> {
283
298
  return this.adapter.get<PaginatedResponse<FunnelRun>>(
284
- this.url(`/api/v1/funnels/${funnelId}/runs/`),
299
+ this.url(this.paths.runs(funnelId)),
285
300
  filters
286
301
  );
287
302
  }
@@ -290,11 +305,13 @@ export class FunnelApiClient {
290
305
  * Get single run detail
291
306
  *
292
307
  * @param runId - Run ID
308
+ * @param funnelId - Required only on a path family that nests runs under
309
+ * their funnel (the control plane); ignored on the central family.
293
310
  * @returns Funnel run detail
294
311
  * @throws ApiError with status 404 if not found
295
312
  */
296
- async getFunnelRun(runId: string): Promise<FunnelRun> {
297
- return this.adapter.get<FunnelRun>(this.url(`/api/v1/funnel-runs/${runId}/`));
313
+ async getFunnelRun(runId: string, funnelId?: string): Promise<FunnelRun> {
314
+ return this.adapter.get<FunnelRun>(this.url(this.paths.runDetail(runId, funnelId)));
298
315
  }
299
316
 
300
317
  /**
@@ -311,11 +328,14 @@ export class FunnelApiClient {
311
328
  matched?: boolean;
312
329
  page?: number;
313
330
  pageSize?: number;
331
+ /** Required only on a family that nests runs under their funnel. */
332
+ funnelId?: string;
314
333
  }
315
334
  ): Promise<PaginatedResponse<FunnelResult<TEntity>>> {
335
+ const { funnelId, ...query } = filters ?? {};
316
336
  return this.adapter.get<PaginatedResponse<FunnelResult<TEntity>>>(
317
- this.url(`/api/v1/funnel-runs/${runId}/results/`),
318
- filters
337
+ this.url(this.paths.runResults(runId, funnelId)),
338
+ query
319
339
  );
320
340
  }
321
341
 
@@ -326,13 +346,46 @@ export class FunnelApiClient {
326
346
  * @returns Updated run with status 'cancelled'
327
347
  * @throws ApiError with status 404 if not found, 400 if already completed
328
348
  */
329
- async cancelFunnelRun(runId: string): Promise<FunnelRun> {
349
+ async cancelFunnelRun(runId: string, funnelId?: string): Promise<FunnelRun> {
330
350
  return this.adapter.post<FunnelRun>(
331
- this.url(`/api/v1/funnel-runs/${runId}/cancel/`),
351
+ this.url(this.paths.runCancel(runId, funnelId)),
332
352
  {}
333
353
  );
334
354
  }
335
355
 
356
+ // ============================================================================
357
+ // Field Registry
358
+ // ============================================================================
359
+
360
+ /**
361
+ * Fetch the field registry that describes which keys a rule may name.
362
+ *
363
+ * This is what a rule editor should be driven by — the server's own resolver
364
+ * registry (backend/apps/funnels/resolvers/registry.py), not a hand-written
365
+ * per-app list that drifts from it.
366
+ *
367
+ * The payload is normalized, so the same call works whether the injected
368
+ * adapter camelizes keys (the @startsimpli/api FetchWrapper does) or hands
369
+ * back the raw snake_case body (FetchAdapter does).
370
+ *
371
+ * @param entityType - Optional: only the fields that apply to this entity type
372
+ */
373
+ async getFields(entityType?: string): Promise<FieldRegistry> {
374
+ const response = await this.adapter.get<{
375
+ entity_type?: string | null;
376
+ entityType?: string | null;
377
+ fields?: FieldDefinitionInput[];
378
+ }>(
379
+ this.url(this.paths.fields()),
380
+ entityType ? { entity_type: entityType } : undefined
381
+ );
382
+
383
+ return {
384
+ entityType: response.entityType ?? response.entity_type ?? null,
385
+ fields: (response.fields ?? []).map(normalizeFieldDefinition),
386
+ };
387
+ }
388
+
336
389
  // ============================================================================
337
390
  // Client-Side Preview (Local Evaluation)
338
391
  // ============================================================================
@@ -378,7 +431,7 @@ export class FunnelApiClient {
378
431
  sampleEntities: TEntity[]
379
432
  ): Promise<PreviewResult<TEntity>> {
380
433
  return this.adapter.post<PreviewResult<TEntity>>(
381
- this.url(`/api/v1/funnels/${funnelId}/preview/`),
434
+ this.url(this.paths.preview(funnelId)),
382
435
  { entities: sampleEntities }
383
436
  );
384
437
  }
package/src/api/index.ts CHANGED
@@ -15,6 +15,17 @@ export { createApiError, isApiError } from './adapter';
15
15
  export { FetchAdapter } from './default-adapter';
16
16
  export type { FetchAdapterConfig } from './default-adapter';
17
17
 
18
+ // Path families — the surfaces the same client can talk to (bd startsim-pxzlf)
19
+ export {
20
+ createFunnelPaths,
21
+ centralFunnelPaths,
22
+ foundryTenantFunnelPaths,
23
+ proxiedCentralFunnelPaths,
24
+ DEFAULT_FUNNEL_PATH_PREFIX,
25
+ DEFAULT_FUNNEL_RUNS_PATH_PREFIX,
26
+ } from './paths';
27
+ export type { FunnelPaths, FunnelPathsOptions } from './paths';
28
+
18
29
  // Client
19
30
  export { FunnelApiClient } from './client';
20
31
  export type {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tests for the injectable funnel path family (bd startsim-pxzlf).
3
+ *
4
+ * The same client has to talk to four surfaces that differ ONLY in their URL
5
+ * prefix and, for the control plane, in whether a run is addressed globally or
6
+ * under its funnel:
7
+ *
8
+ * central /api/v1/funnels/… + /api/v1/funnel-runs/<id>/
9
+ * control plane /api/v1/foundry/tenants/<slug>/funnels/… (runs nested)
10
+ * tenant proxy /central-api/api/v1/funnels/… (a prefix on the central family)
11
+ * a fork's own whatever the fork mounted
12
+ *
13
+ * Hardcoding '/api/v1/funnels' in the client (api/client.ts before this change)
14
+ * made the first the only one reachable.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import {
19
+ createFunnelPaths,
20
+ centralFunnelPaths,
21
+ foundryTenantFunnelPaths,
22
+ DEFAULT_FUNNEL_PATH_PREFIX,
23
+ } from './paths';
24
+
25
+ describe('centralFunnelPaths', () => {
26
+ it('is the /api/v1/funnels family the client used to hardcode', () => {
27
+ expect(centralFunnelPaths.list()).toBe('/api/v1/funnels/');
28
+ expect(centralFunnelPaths.detail('f1')).toBe('/api/v1/funnels/f1/');
29
+ expect(centralFunnelPaths.stages('f1')).toBe('/api/v1/funnels/f1/stages/');
30
+ expect(centralFunnelPaths.stage('f1', 's1')).toBe('/api/v1/funnels/f1/stages/s1/');
31
+ expect(centralFunnelPaths.run('f1')).toBe('/api/v1/funnels/f1/run/');
32
+ expect(centralFunnelPaths.runs('f1')).toBe('/api/v1/funnels/f1/runs/');
33
+ expect(centralFunnelPaths.preview('f1')).toBe('/api/v1/funnels/f1/preview/');
34
+ expect(centralFunnelPaths.fields()).toBe('/api/v1/funnels/fields/');
35
+ });
36
+
37
+ it('addresses a run globally, because the central API does', () => {
38
+ // config/api_router.py registers funnel-runs as its own router entry.
39
+ expect(centralFunnelPaths.runDetail('r1')).toBe('/api/v1/funnel-runs/r1/');
40
+ expect(centralFunnelPaths.runResults('r1')).toBe('/api/v1/funnel-runs/r1/results/');
41
+ expect(centralFunnelPaths.runCancel('r1')).toBe('/api/v1/funnel-runs/r1/cancel/');
42
+ });
43
+
44
+ it('exposes its prefix so callers can see which surface they are on', () => {
45
+ expect(centralFunnelPaths.prefix).toBe(DEFAULT_FUNNEL_PATH_PREFIX);
46
+ });
47
+ });
48
+
49
+ describe('createFunnelPaths', () => {
50
+ it('defaults to the central family', () => {
51
+ const paths = createFunnelPaths();
52
+ expect(paths.list()).toBe(centralFunnelPaths.list());
53
+ expect(paths.runDetail('r1')).toBe(centralFunnelPaths.runDetail('r1'));
54
+ });
55
+
56
+ it('mounts the whole family under a prefix — a tenant proxy', () => {
57
+ const paths = createFunnelPaths({ prefix: '/central-api/api/v1/funnels' });
58
+ expect(paths.list()).toBe('/central-api/api/v1/funnels/');
59
+ expect(paths.detail('f1')).toBe('/central-api/api/v1/funnels/f1/');
60
+ expect(paths.fields()).toBe('/central-api/api/v1/funnels/fields/');
61
+ });
62
+
63
+ it('takes the runs prefix separately, since runs need not live under funnels', () => {
64
+ const paths = createFunnelPaths({
65
+ prefix: '/central-api/api/v1/funnels',
66
+ runsPrefix: '/central-api/api/v1/funnel-runs',
67
+ });
68
+ expect(paths.runDetail('r1')).toBe('/central-api/api/v1/funnel-runs/r1/');
69
+ expect(paths.runCancel('r1')).toBe('/central-api/api/v1/funnel-runs/r1/cancel/');
70
+ });
71
+
72
+ it('nests runs under their funnel when asked (the control-plane shape)', () => {
73
+ const paths = createFunnelPaths({
74
+ prefix: '/api/v1/foundry/tenants/acme/funnels',
75
+ nestRuns: true,
76
+ });
77
+ expect(paths.runDetail('r1', 'f1')).toBe(
78
+ '/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
79
+ );
80
+ expect(paths.runResults('r1', 'f1')).toBe(
81
+ '/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/results/'
82
+ );
83
+ });
84
+
85
+ it('refuses a nested run address with no funnel, instead of building a wrong URL', () => {
86
+ const paths = createFunnelPaths({ prefix: '/api/v1/x/funnels', nestRuns: true });
87
+ expect(() => paths.runDetail('r1')).toThrow(/funnel/i);
88
+ });
89
+
90
+ it('normalizes a prefix given with or without a trailing slash', () => {
91
+ const withSlash = createFunnelPaths({ prefix: '/api/v1/funnels/' });
92
+ const without = createFunnelPaths({ prefix: '/api/v1/funnels' });
93
+ expect(withSlash.list()).toBe(without.list());
94
+ expect(withSlash.detail('f1')).toBe(without.detail('f1'));
95
+ });
96
+
97
+ it('URL-encodes ids so a slug or an id with a slash cannot escape the family', () => {
98
+ const paths = createFunnelPaths();
99
+ expect(paths.detail('a/b')).toBe('/api/v1/funnels/a%2Fb/');
100
+ });
101
+ });
102
+
103
+ describe('foundryTenantFunnelPaths', () => {
104
+ it('is the control-plane family for one foundry (startsim-9xbz2)', () => {
105
+ const paths = foundryTenantFunnelPaths('acme');
106
+ expect(paths.list()).toBe('/api/v1/foundry/tenants/acme/funnels/');
107
+ expect(paths.detail('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/');
108
+ expect(paths.run('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/run/');
109
+ expect(paths.runs('f1')).toBe('/api/v1/foundry/tenants/acme/funnels/f1/runs/');
110
+ expect(paths.fields()).toBe('/api/v1/foundry/tenants/acme/funnels/fields/');
111
+ });
112
+
113
+ it('nests runs under the funnel, which is how the nested viewset addresses them', () => {
114
+ const paths = foundryTenantFunnelPaths('acme');
115
+ expect(paths.runDetail('r1', 'f1')).toBe(
116
+ '/api/v1/foundry/tenants/acme/funnels/f1/runs/r1/'
117
+ );
118
+ });
119
+
120
+ it('encodes the slug', () => {
121
+ expect(foundryTenantFunnelPaths('a b').list()).toBe(
122
+ '/api/v1/foundry/tenants/a%20b/funnels/'
123
+ );
124
+ });
125
+ });