@startsimpli/funnels 0.4.14 → 0.4.16

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,383 @@
1
+ /**
2
+ * The FunnelsPage composer's contract (bd startsim-em5mn).
3
+ *
4
+ * These assert the three things that make it a SHARED page rather than one
5
+ * app's screen, and the three the bead names as acceptance:
6
+ *
7
+ * 1. every list is paged BY THE SERVER (repo rule #8) — a page change is a
8
+ * new request, never a slice of rows already in the browser;
9
+ * 2. the rule builder's vocabulary arrives from an INJECTED fields client,
10
+ * so the surface that mounts it decides where `/fields/` lives;
11
+ * 3. nothing on this page evaluates the corpus in the browser.
12
+ *
13
+ * `@startsimpli/ui/table` is stubbed, so what is asserted is the CONFIG the
14
+ * composer hands UnifiedTable (`serverSide`, `totalCount`, `onPageChange`)
15
+ * rather than the shared table's own rendering, which has its own suite.
16
+ */
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
19
+ import { readFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import * as React from 'react';
22
+
23
+ /** The composer's own source — vitest runs with cwd at the package root. */
24
+ const composerSource = () =>
25
+ readFileSync(resolve(process.cwd(), 'src/page/FunnelsPage.tsx'), 'utf8');
26
+
27
+ const tableProps: any[] = [];
28
+
29
+ vi.mock('@startsimpli/ui/table', () => ({
30
+ UnifiedTable: (props: any) => {
31
+ tableProps.push(props);
32
+ return (
33
+ <div data-testid={`table-${props.tableId}`}>
34
+ <span data-testid={`table-${props.tableId}-count`}>
35
+ {props.pagination?.totalCount ?? 0}
36
+ </span>
37
+ <button
38
+ type="button"
39
+ data-testid={`table-${props.tableId}-next`}
40
+ onClick={() => props.pagination?.onPageChange(props.pagination.currentPage + 1)}
41
+ >
42
+ next
43
+ </button>
44
+ {props.data.map((row: any) => (
45
+ <button
46
+ type="button"
47
+ key={props.getRowId(row)}
48
+ data-testid={`row-${props.getRowId(row)}`}
49
+ onClick={() => props.onRowClick?.(row)}
50
+ >
51
+ {props.columns.map((c: any) => (
52
+ <span key={c.id}>{c.cell ? c.cell(row) : String(row[c.accessorKey ?? c.id] ?? '')}</span>
53
+ ))}
54
+ </button>
55
+ ))}
56
+ </div>
57
+ );
58
+ },
59
+ }));
60
+
61
+ import { FunnelsPage } from './FunnelsPage';
62
+ import { FunnelApiClient } from '../api/client';
63
+ import { foundryTenantFunnelPaths, centralFunnelPaths } from '../api/paths';
64
+ import type { ApiAdapter } from '../api/adapter';
65
+
66
+ interface Call {
67
+ method: string;
68
+ url: string;
69
+ params?: Record<string, any>;
70
+ body?: any;
71
+ }
72
+
73
+ /** An adapter that records every call and answers from a routing table. */
74
+ function recorder(routes: Array<[RegExp, (call: Call) => any]>) {
75
+ const calls: Call[] = [];
76
+ const answer = (call: Call) => {
77
+ calls.push(call);
78
+ for (const [pattern, handler] of routes) {
79
+ if (pattern.test(call.url)) return Promise.resolve(handler(call));
80
+ }
81
+ return Promise.reject(new Error(`no stub for ${call.method} ${call.url}`));
82
+ };
83
+ const adapter: ApiAdapter = {
84
+ get: (url, params) => answer({ method: 'GET', url, params }),
85
+ post: (url, body) => answer({ method: 'POST', url, body }),
86
+ patch: (url, body) => answer({ method: 'PATCH', url, body }),
87
+ delete: (url) => answer({ method: 'DELETE', url }),
88
+ };
89
+ return { adapter, calls };
90
+ }
91
+
92
+ const page = (results: any[], count = results.length) => ({
93
+ results,
94
+ count,
95
+ next: null,
96
+ previous: null,
97
+ });
98
+
99
+ const FUNNEL = {
100
+ id: 'f-1',
101
+ name: 'Seed-stage investors',
102
+ status: 'active',
103
+ entityType: 'investor',
104
+ stageCount: 2,
105
+ totalRuns: 3,
106
+ lastRunAt: '2026-09-18T10:00:00Z',
107
+ lastRunId: 'r-1',
108
+ lastRunMatched: 12,
109
+ tags: [],
110
+ stages: [
111
+ {
112
+ id: 's-1',
113
+ order: 0,
114
+ name: 'Has a check size',
115
+ filterLogic: 'AND',
116
+ rules: [],
117
+ matchAction: 'continue',
118
+ noMatchAction: 'exclude',
119
+ },
120
+ ],
121
+ createdAt: '2026-09-01T00:00:00Z',
122
+ updatedAt: '2026-09-18T10:00:00Z',
123
+ };
124
+
125
+ const RUN = {
126
+ id: 'r-1',
127
+ funnel: 'f-1',
128
+ status: 'completed',
129
+ triggerType: 'manual',
130
+ startedAt: '2026-09-18T10:00:00Z',
131
+ completedAt: '2026-09-18T10:00:30Z',
132
+ totalInput: 208_000,
133
+ totalMatched: 12,
134
+ totalExcluded: 207_988,
135
+ totalTagged: 0,
136
+ stageStats: {},
137
+ stageBreakdown: [],
138
+ };
139
+
140
+ const FIELDS = {
141
+ entity_type: null,
142
+ fields: [
143
+ {
144
+ key: 'contact.name',
145
+ label: 'Name',
146
+ category: 'Contact',
147
+ value_type: 'string',
148
+ enum_values: [],
149
+ allowed_operators: ['eq', 'contains'],
150
+ },
151
+ ],
152
+ };
153
+
154
+ function build(overrides: Array<[RegExp, (call: Call) => any]> = []) {
155
+ const { adapter, calls } = recorder([
156
+ ...overrides,
157
+ [/\/api\/v1\/funnels\/fields\/$/, () => FIELDS],
158
+ [/funnels\/f-1\/runs\/$/, (c) => (c.method === 'POST' ? { ...RUN, id: 'r-2', status: 'pending' } : page([RUN]))],
159
+ [/funnel-runs\/[^/]+\/results\/$/, () => page([
160
+ {
161
+ id: 'res-1',
162
+ entityId: 'e-1',
163
+ entityType: 'investor',
164
+ matched: true,
165
+ excludedAtStage: null,
166
+ stageReached: { id: 's-1', name: 'Has a check size', order: 0 },
167
+ exclusionReason: '',
168
+ context: {},
169
+ accumulatedTags: ['warm'],
170
+ createdAt: '2026-09-18T10:00:30Z',
171
+ },
172
+ ], 40)],
173
+ [/funnels\/f-1\/$/, () => FUNNEL],
174
+ [/funnels\/$/, (c) => (c.method === 'POST' ? { ...FUNNEL, id: 'f-new' } : page([FUNNEL], 40))],
175
+ ]);
176
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('qafoundry'));
177
+ const fieldsClient = new FunnelApiClient(adapter, '', centralFunnelPaths);
178
+ return { client, fieldsClient, calls };
179
+ }
180
+
181
+ beforeEach(() => {
182
+ tableProps.length = 0;
183
+ });
184
+
185
+ describe('the funnel list is paged by the server (repo rule #8)', () => {
186
+ it('asks the injected client for one page, and tells the table so', async () => {
187
+ const { client, fieldsClient, calls } = build();
188
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
189
+
190
+ await screen.findByTestId('row-f-1');
191
+
192
+ const list = calls.find((c) => c.method === 'GET' && /tenants\/qafoundry\/funnels\/$/.test(c.url));
193
+ expect(list).toBeTruthy();
194
+ expect(list!.params).toMatchObject({ page: 1 });
195
+ expect(list!.params!.pageSize).toBeGreaterThan(0);
196
+
197
+ const cfg = tableProps.at(-1)!.pagination;
198
+ expect(cfg.serverSide).toBe(true);
199
+ // The count is the SERVER's, not the number of rows in the browser.
200
+ expect(cfg.totalCount).toBe(40);
201
+ expect(tableProps.at(-1)!.data).toHaveLength(1);
202
+ });
203
+
204
+ it('turning the page issues a new request rather than slicing what it holds', async () => {
205
+ const { client, fieldsClient, calls } = build();
206
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
207
+ await screen.findByTestId('row-f-1');
208
+
209
+ const before = calls.filter((c) => /tenants\/qafoundry\/funnels\/$/.test(c.url)).length;
210
+ fireEvent.click(screen.getByTestId('table-funnels-next'));
211
+
212
+ await waitFor(() => {
213
+ const listCalls = calls.filter((c) => /tenants\/qafoundry\/funnels\/$/.test(c.url));
214
+ expect(listCalls.length).toBe(before + 1);
215
+ expect(listCalls.at(-1)!.params).toMatchObject({ page: 2 });
216
+ });
217
+ });
218
+
219
+ it('narrows by status and search ON THE SERVER, not in the browser', async () => {
220
+ const { client, fieldsClient, calls } = build();
221
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
222
+ await screen.findByTestId('row-f-1');
223
+
224
+ fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'active' } });
225
+ await waitFor(() => {
226
+ const last = calls.filter((c) => /tenants\/qafoundry\/funnels\/$/.test(c.url)).at(-1);
227
+ expect(last!.params).toMatchObject({ status: 'active', page: 1 });
228
+ });
229
+ });
230
+ });
231
+
232
+ describe('the builder is fed by the injected fields client', () => {
233
+ it('reads the vocabulary from the fields client, never the funnel path family', async () => {
234
+ const { client, fieldsClient, calls } = build();
235
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
236
+ await screen.findByTestId('row-f-1');
237
+ fireEvent.click(screen.getByTestId('row-f-1'));
238
+
239
+ await waitFor(() => {
240
+ expect(calls.some((c) => c.url === '/api/v1/funnels/fields/')).toBe(true);
241
+ });
242
+ // The control plane does not mount funnels/fields/ — it is shadowed by the
243
+ // funnels/<funnel_id>/ action and 404s. Asking for it would be a bug.
244
+ expect(calls.some((c) => /tenants\/qafoundry\/funnels\/fields\//.test(c.url))).toBe(false);
245
+ });
246
+
247
+ it('hand-writes no field list of its own', () => {
248
+ const src = composerSource();
249
+ expect(src).not.toMatch(/allowedOperators:\s*\[/);
250
+ expect(src).toMatch(/getFields\(/);
251
+ });
252
+ });
253
+
254
+ describe('running a funnel', () => {
255
+ it('"Run now" POSTs to the funnel\'s runs collection and adopts the returned id', async () => {
256
+ const { client, fieldsClient, calls } = build();
257
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
258
+ await screen.findByTestId('row-f-1');
259
+ fireEvent.click(screen.getByTestId('row-f-1'));
260
+
261
+ const runNow = await screen.findByRole('button', { name: /run now/i });
262
+ fireEvent.click(runNow);
263
+
264
+ await waitFor(() => {
265
+ const post = calls.find((c) => c.method === 'POST' && /funnels\/f-1\/runs\/$/.test(c.url));
266
+ expect(post).toBeTruthy();
267
+ });
268
+ // The id the POST returned is the id that completes (bd startsim-qv6pk).
269
+ expect(await screen.findByText(/r-2/)).toBeTruthy();
270
+ });
271
+
272
+ it('hides the write controls when the viewer cannot manage funnels', async () => {
273
+ const { client, fieldsClient } = build();
274
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage={false} />);
275
+ await screen.findByTestId('row-f-1');
276
+ expect(screen.queryByRole('button', { name: /new funnel/i })).toBeNull();
277
+ });
278
+ });
279
+
280
+ describe('results are filtered where the rows are', () => {
281
+ it('sends stage-reached and outcome to the server as query params', async () => {
282
+ const { client, fieldsClient, calls } = build();
283
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
284
+ await screen.findByTestId('row-f-1');
285
+ fireEvent.click(screen.getByTestId('row-f-1'));
286
+
287
+ fireEvent.click(await screen.findByRole('tab', { name: /results/i }));
288
+ await screen.findByTestId('table-funnel-results');
289
+
290
+ fireEvent.change(screen.getByLabelText('Stage reached'), { target: { value: 's-1' } });
291
+ await waitFor(() => {
292
+ const last = calls.filter((c) => /results\/$/.test(c.url)).at(-1);
293
+ expect(last!.params).toMatchObject({ stage: 's-1' });
294
+ });
295
+
296
+ fireEvent.change(screen.getByLabelText('Outcome'), { target: { value: 'true' } });
297
+ await waitFor(() => {
298
+ const last = calls.filter((c) => /results\/$/.test(c.url)).at(-1);
299
+ expect(last!.params).toMatchObject({ matched: true });
300
+ });
301
+ });
302
+
303
+ it('pages the results on the server too', async () => {
304
+ const { client, fieldsClient, calls } = build();
305
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
306
+ await screen.findByTestId('row-f-1');
307
+ fireEvent.click(screen.getByTestId('row-f-1'));
308
+ fireEvent.click(await screen.findByRole('tab', { name: /results/i }));
309
+ await screen.findByTestId('table-funnel-results');
310
+
311
+ const cfg = tableProps.filter((p) => p.tableId === 'funnel-results').at(-1)!.pagination;
312
+ expect(cfg.serverSide).toBe(true);
313
+ expect(cfg.totalCount).toBe(40);
314
+
315
+ const before = calls.filter((c) => /results\/$/.test(c.url)).length;
316
+ fireEvent.click(screen.getByTestId('table-funnel-results-next'));
317
+ await waitFor(() => {
318
+ expect(calls.filter((c) => /results\/$/.test(c.url)).length).toBe(before + 1);
319
+ });
320
+ });
321
+ });
322
+
323
+ describe('the corpus never reaches the browser', () => {
324
+ it('drives no in-memory evaluation and asks for no preview', () => {
325
+ const src = composerSource();
326
+ // core/engine.ts evaluates an in-memory array; the corpus is ~208k rows.
327
+ expect(src).not.toMatch(/FunnelEngine|filterEntities|evaluateRules/);
328
+ // Neither preview path exists on the control plane (no `preview` action on
329
+ // FoundryViewSet), and previewFunnelServer would make the CALLER ship the
330
+ // entities. The run is the preview.
331
+ expect(src).not.toMatch(/previewFunnel/);
332
+ });
333
+ });
334
+
335
+ describe('saving a funnel', () => {
336
+ it('drops a stage id the builder minted, and keeps one the server assigned', async () => {
337
+ // FunnelStageBuilder mints `stage-<ts>-<rand>` for a newly added stage so
338
+ // its list has a stable React key. FunnelStageSerializer.id is a UUIDField,
339
+ // so sending it back is a 400 — verified against a live control plane
340
+ // 2026-09-18. A server-assigned UUID must survive, because the reconciler
341
+ // keys stage identity on it (stage_stats, reached_stage_id).
342
+ const { client, fieldsClient, calls } = build();
343
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
344
+ await screen.findByTestId('row-f-1');
345
+ fireEvent.click(screen.getByTestId('row-f-1'));
346
+ await screen.findByTestId('funnel-detail');
347
+
348
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
349
+
350
+ await waitFor(() => {
351
+ const patch = calls.find((c) => c.method === 'PATCH');
352
+ expect(patch).toBeTruthy();
353
+ });
354
+ const patch = calls.find((c) => c.method === 'PATCH')!;
355
+ // s-1 is not a UUID -> dropped, so the server creates the stage instead of
356
+ // rejecting the whole payload.
357
+ expect(patch.body.stages[0]).not.toHaveProperty('id');
358
+ expect(patch.body.stages[0].name).toBe('Has a check size');
359
+ });
360
+
361
+ it('keeps a real UUID stage id', async () => {
362
+ const uuid = '3f1b0c7e-9a2d-4c51-8e6f-1a2b3c4d5e6f';
363
+ const { adapter, calls } = recorder([
364
+ [/\/api\/v1\/funnels\/fields\/$/, () => FIELDS],
365
+ [/funnels\/f-1\/$/, () => ({
366
+ ...FUNNEL,
367
+ stages: [{ ...FUNNEL.stages[0], id: uuid }],
368
+ })],
369
+ [/funnels\/$/, () => page([FUNNEL], 1)],
370
+ ]);
371
+ const client = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('qafoundry'));
372
+ const fieldsClient = new FunnelApiClient(adapter, '', centralFunnelPaths);
373
+
374
+ render(<FunnelsPage client={client} fieldsClient={fieldsClient} canManage />);
375
+ await screen.findByTestId('row-f-1');
376
+ fireEvent.click(screen.getByTestId('row-f-1'));
377
+ await screen.findByTestId('funnel-detail');
378
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
379
+
380
+ await waitFor(() => expect(calls.some((c) => c.method === 'PATCH')).toBe(true));
381
+ expect(calls.find((c) => c.method === 'PATCH')!.body.stages[0].id).toBe(uuid);
382
+ });
383
+ });