@teleporthq/teleport-plugin-next-workflows 0.45.2 → 0.45.5

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 (78) hide show
  1. package/__tests__/analytics-outcome-visit-ids.test.ts +583 -0
  2. package/__tests__/analytics-track-outcome.test.ts +71 -3
  3. package/__tests__/auth-password-security.test.ts +101 -0
  4. package/__tests__/automations-bus.test.ts +439 -2
  5. package/__tests__/security-lists-parity.test.ts +2 -2
  6. package/__tests__/security-runtime.test.ts +37 -20
  7. package/dist/cjs/api-route-generator.d.ts +2 -0
  8. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  9. package/dist/cjs/api-route-generator.js +43 -1
  10. package/dist/cjs/api-route-generator.js.map +1 -1
  11. package/dist/cjs/auth-generator.d.ts.map +1 -1
  12. package/dist/cjs/auth-generator.js +48 -19
  13. package/dist/cjs/auth-generator.js.map +1 -1
  14. package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
  15. package/dist/cjs/automations/dispatcher-source.js +13 -5
  16. package/dist/cjs/automations/dispatcher-source.js.map +1 -1
  17. package/dist/cjs/automations/event-bus-source.d.ts.map +1 -1
  18. package/dist/cjs/automations/event-bus-source.js +37 -23
  19. package/dist/cjs/automations/event-bus-source.js.map +1 -1
  20. package/dist/cjs/executor-generator.d.ts.map +1 -1
  21. package/dist/cjs/executor-generator.js +4 -0
  22. package/dist/cjs/executor-generator.js.map +1 -1
  23. package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -1
  24. package/dist/cjs/nodes/account/account-request-password-reset.js +3 -2
  25. package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -1
  26. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  27. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +17 -4
  28. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
  29. package/dist/cjs/password-reset-route-generator.d.ts +2 -0
  30. package/dist/cjs/password-reset-route-generator.d.ts.map +1 -1
  31. package/dist/cjs/password-reset-route-generator.js +17 -10
  32. package/dist/cjs/password-reset-route-generator.js.map +1 -1
  33. package/dist/cjs/security-runtime/security-runtime-code.d.ts +3 -2
  34. package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -1
  35. package/dist/cjs/security-runtime/security-runtime-code.js +17 -13
  36. package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -1
  37. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  38. package/dist/esm/api-route-generator.d.ts +2 -0
  39. package/dist/esm/api-route-generator.d.ts.map +1 -1
  40. package/dist/esm/api-route-generator.js +44 -2
  41. package/dist/esm/api-route-generator.js.map +1 -1
  42. package/dist/esm/auth-generator.d.ts.map +1 -1
  43. package/dist/esm/auth-generator.js +48 -19
  44. package/dist/esm/auth-generator.js.map +1 -1
  45. package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
  46. package/dist/esm/automations/dispatcher-source.js +13 -5
  47. package/dist/esm/automations/dispatcher-source.js.map +1 -1
  48. package/dist/esm/automations/event-bus-source.d.ts.map +1 -1
  49. package/dist/esm/automations/event-bus-source.js +37 -23
  50. package/dist/esm/automations/event-bus-source.js.map +1 -1
  51. package/dist/esm/executor-generator.d.ts.map +1 -1
  52. package/dist/esm/executor-generator.js +4 -0
  53. package/dist/esm/executor-generator.js.map +1 -1
  54. package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -1
  55. package/dist/esm/nodes/account/account-request-password-reset.js +3 -2
  56. package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -1
  57. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
  58. package/dist/esm/nodes/analytics/analytics-track-outcome.js +17 -4
  59. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
  60. package/dist/esm/password-reset-route-generator.d.ts +2 -0
  61. package/dist/esm/password-reset-route-generator.d.ts.map +1 -1
  62. package/dist/esm/password-reset-route-generator.js +16 -9
  63. package/dist/esm/password-reset-route-generator.js.map +1 -1
  64. package/dist/esm/security-runtime/security-runtime-code.d.ts +3 -2
  65. package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -1
  66. package/dist/esm/security-runtime/security-runtime-code.js +17 -13
  67. package/dist/esm/security-runtime/security-runtime-code.js.map +1 -1
  68. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +2 -2
  70. package/src/api-route-generator.ts +52 -2
  71. package/src/auth-generator.ts +48 -19
  72. package/src/automations/dispatcher-source.ts +13 -5
  73. package/src/automations/event-bus-source.ts +37 -23
  74. package/src/executor-generator.ts +4 -0
  75. package/src/nodes/account/account-request-password-reset.ts +3 -2
  76. package/src/nodes/analytics/analytics-track-outcome.ts +17 -4
  77. package/src/password-reset-route-generator.ts +18 -9
  78. package/src/security-runtime/security-runtime-code.ts +17 -13
@@ -0,0 +1,583 @@
1
+ import * as crypto from 'crypto'
2
+ import * as net from 'net'
3
+ import { generateServerSegmentAPIRoute } from '../src/api-route-generator'
4
+ import {
5
+ generateClientRuntimeCode,
6
+ generateSharedRuntimeUtilsCode,
7
+ } from '../src/executor-generator'
8
+ import { nodeRegistry } from '../src/nodes'
9
+ import { resolveHandlerEntryName } from '../src/nodes/types'
10
+ import { generateSecurityRuntimeCode } from '../src/security-runtime'
11
+ import { splitIntoSegments } from '../src/segment-splitter'
12
+ import { buildSegmentTrust } from '../src/segment-trust'
13
+ import { TRACKER_IDS_EXPRESSION } from '../src/trigger-generator'
14
+ import type { WorkflowSegment } from '../src/types'
15
+ import { loadServerRuntime } from './_helpers/load-server-runtime'
16
+
17
+ // A sale only converts in Growth when its report names the visit it came from.
18
+ // A trigger stamps the tracker's ids on itself (`__tpa`), and the steps that read
19
+ // them look at the top of the run's context, so the runtime has to lift them
20
+ // there. The node's own test hands it `{ __tpa }` as its context, which no run
21
+ // ever did: every server-reported outcome went out with no visit, and the order's
22
+ // "remember the visit" step wrote nothing. These run the GENERATED browser runtime
23
+ // against the GENERATED routes, split and signed as the project plugin emits them.
24
+
25
+ const SHOPPER = {
26
+ sessionId: '11111111-1111-4111-8111-111111111111',
27
+ visitorId: '22222222-2222-4222-8222-222222222222',
28
+ pageLoadId: '33333333-3333-4333-8333-333333333333',
29
+ path: '/checkout',
30
+ exp: 'hero:b',
31
+ }
32
+ const MERCHANT = {
33
+ sessionId: '44444444-4444-4444-8444-444444444444',
34
+ visitorId: '55555555-5555-4555-8555-555555555555',
35
+ pageLoadId: '66666666-6666-4666-8666-666666666666',
36
+ path: '/orders/ORD-15',
37
+ exp: 'hero:a',
38
+ }
39
+
40
+ const SET_ENV: Record<string, string> = {
41
+ NEXTAUTH_SECRET: 'analytics-outcome-visit-ids-secret',
42
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
43
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
44
+ TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
45
+ }
46
+
47
+ const wfCtx = (nodeId: string, path: string[]) => ({
48
+ type: 'workflowContext',
49
+ nodeId,
50
+ path: [nodeId, ...path],
51
+ })
52
+
53
+ const step = (
54
+ id: string,
55
+ type: string,
56
+ stepNumber: number,
57
+ config: Record<string, unknown> = {}
58
+ ) => ({ id, type, label: id, stepNumber, config })
59
+
60
+ const line = (...ids: string[]) =>
61
+ ids.slice(1).map((target, index) => ({
62
+ id: `${ids[index]}-${target}`,
63
+ source: ids[index],
64
+ target,
65
+ }))
66
+
67
+ interface Graph {
68
+ nodes: Array<ReturnType<typeof step>>
69
+ edges: Array<ReturnType<typeof line>[number]>
70
+ segments: WorkflowSegment[]
71
+ }
72
+
73
+ function graphOf(nodes: Graph['nodes'], edges: Graph['edges']): Graph {
74
+ const segments = splitIntoSegments({
75
+ nodes,
76
+ edges,
77
+ trigger: { type: 'event-element-clicked' },
78
+ } as any)
79
+ return { nodes, edges, segments }
80
+ }
81
+
82
+ function loadSecurity() {
83
+ const noop = (): undefined => undefined
84
+ const security = { exports: {} as any }
85
+ // tslint:disable-next-line:function-constructor
86
+ new Function(
87
+ 'module',
88
+ 'exports',
89
+ 'require',
90
+ 'process',
91
+ 'console',
92
+ 'Buffer',
93
+ 'Date',
94
+ generateSecurityRuntimeCode()
95
+ )(
96
+ security,
97
+ security.exports,
98
+ (name: string) => (name === 'crypto' ? crypto : name === 'net' ? net : (null as never)),
99
+ { env: {} },
100
+ { warn: noop, error: noop, log: noop },
101
+ Buffer,
102
+ Date
103
+ )
104
+ return security.exports
105
+ }
106
+
107
+ function bootRoute(segment: WorkflowSegment, graph: Graph) {
108
+ const security = loadSecurity()
109
+ const runtime = loadServerRuntime()
110
+ const source = generateServerSegmentAPIRoute(segment, 'Flow', undefined, {
111
+ trust: buildSegmentTrust(segment, graph as any),
112
+ })
113
+ const routeModule: { exports: any } = { exports: {} }
114
+ // tslint:disable-next-line:function-constructor
115
+ new Function('module', 'exports', 'require', source)(
116
+ routeModule,
117
+ routeModule.exports,
118
+ (name: string) =>
119
+ name.endsWith('server-runtime') ? runtime : name.endsWith('security-runtime') ? security : {}
120
+ )
121
+ return routeModule.exports as (req: unknown, res: unknown) => Promise<void>
122
+ }
123
+
124
+ function loadBrowserRuntime() {
125
+ const utils = { exports: {} as any }
126
+ // tslint:disable-next-line:function-constructor
127
+ new Function('module', 'exports', 'require', generateSharedRuntimeUtilsCode())(
128
+ utils,
129
+ utils.exports,
130
+ () => ({})
131
+ )
132
+ const client = { exports: {} as any }
133
+ // tslint:disable-next-line:function-constructor
134
+ new Function('module', 'exports', 'require', generateClientRuntimeCode())(
135
+ client,
136
+ client.exports,
137
+ (name: string) => (name === './runtime-utils' ? utils.exports : (null as never))
138
+ )
139
+ return { utils: utils.exports, client: client.exports }
140
+ }
141
+
142
+ function handlerOf(nodeType: string, source: string) {
143
+ const entry = resolveHandlerEntryName(source, nodeType)
144
+ // tslint:disable-next-line:function-constructor
145
+ return new Function(`${source}\nreturn ${entry};`)()
146
+ }
147
+
148
+ const trackerIdsIn = (window: unknown) =>
149
+ // tslint:disable-next-line:function-constructor
150
+ new Function('window', `return ${TRACKER_IDS_EXPRESSION};`)(window)
151
+
152
+ interface Seen {
153
+ outcomes: Array<Record<string, any>>
154
+ rawParams: unknown[][]
155
+ segmentContexts: Array<Record<string, any>>
156
+ }
157
+
158
+ describe('the visit a server-reported outcome names', () => {
159
+ const runtime = loadBrowserRuntime()
160
+ const scope = globalThis as Record<string, unknown>
161
+ const savedFetch = scope.fetch
162
+ const savedEnv: Record<string, string | undefined> = {}
163
+ let routes: Record<string, (req: unknown, res: unknown) => Promise<void>> = {}
164
+ let rawRows: Array<Record<string, unknown>> = []
165
+ let seen: Seen
166
+
167
+ beforeAll(() => {
168
+ Object.keys(SET_ENV).forEach((name) => {
169
+ savedEnv[name] = process.env[name]
170
+ process.env[name] = SET_ENV[name]
171
+ })
172
+ // The browser's fetch and the route's own: the analytics service, the data
173
+ // route and the workflow routes, the last answered by the generated route.
174
+ scope.fetch = async (url: string, init: { body: string }) => {
175
+ const address = String(url)
176
+ const body = JSON.parse(init.body)
177
+ if (address.indexOf('/events/tpa_key/server') !== -1) {
178
+ seen.outcomes.push(...body.outcomes)
179
+ return { ok: true, status: 202, json: async () => ({}) }
180
+ }
181
+ if (address.indexOf('/raw-query') !== -1) {
182
+ seen.rawParams.push(body.params)
183
+ return { ok: true, status: 200, json: async () => ({ rows: rawRows }) }
184
+ }
185
+ const route = routes[new URL(address, 'https://shop.test').pathname]
186
+ seen.segmentContexts.push(body.context)
187
+ const reply = { status: 200, body: null as any }
188
+ const res = {
189
+ setHeader: (): undefined => undefined,
190
+ status(code: number) {
191
+ reply.status = code
192
+ return this
193
+ },
194
+ json(payload: unknown) {
195
+ reply.body = payload
196
+ return this
197
+ },
198
+ }
199
+ await route(
200
+ {
201
+ method: 'POST',
202
+ body,
203
+ headers: { host: 'shop.test' },
204
+ socket: { remoteAddress: '203.0.113.9' },
205
+ },
206
+ res
207
+ )
208
+ return {
209
+ ok: reply.status >= 200 && reply.status < 300,
210
+ status: reply.status,
211
+ statusText: '',
212
+ json: async () => reply.body,
213
+ }
214
+ }
215
+ })
216
+
217
+ afterAll(() => {
218
+ scope.fetch = savedFetch
219
+ Object.keys(SET_ENV).forEach((name) => {
220
+ if (savedEnv[name] === undefined) {
221
+ delete process.env[name]
222
+ } else {
223
+ process.env[name] = savedEnv[name]
224
+ }
225
+ })
226
+ })
227
+
228
+ beforeEach(() => {
229
+ routes = {}
230
+ rawRows = []
231
+ seen = { outcomes: [], rawParams: [], segmentContexts: [] }
232
+ })
233
+
234
+ function run(
235
+ graph: Graph,
236
+ trigger: Record<string, unknown>,
237
+ clientHandlers: Record<string, unknown> = {}
238
+ ) {
239
+ const urls: Record<string, string> = {}
240
+ graph.segments
241
+ .filter((segment) => segment.env === 'server')
242
+ .forEach((segment) => {
243
+ const path = `/api/workflows/flow-${segment.id}`
244
+ routes[path] = bootRoute(segment, graph)
245
+ urls[segment.id] = path
246
+ })
247
+ return runtime.client.executeWorkflowWithSegments(
248
+ {
249
+ id: 'flow',
250
+ triggerNodeId: 'trigger',
251
+ nodes: graph.nodes,
252
+ edges: graph.edges,
253
+ segments: graph.segments,
254
+ },
255
+ trigger,
256
+ clientHandlers,
257
+ urls
258
+ )
259
+ }
260
+
261
+ describe('a run the visitor started: the place-order shape', () => {
262
+ const placeOrder = graphOf(
263
+ [
264
+ step('assemble', 'general-custom-js', 1, {
265
+ context: 'server',
266
+ code: `function customHandler() {
267
+ return {
268
+ id: 'order-15',
269
+ total_amount: 159,
270
+ currency: 'RON',
271
+ attribution: { utm_source: 'newsletter', landing_path: '/sale', visitor_id: 'stale-aid' },
272
+ }
273
+ }`,
274
+ }),
275
+ step('busy', 'state-update-local-state', 2, { property: 'isPlacing', value: 'true' }),
276
+ // `buildRememberOrderVisitNode` in teleport-gui: order, session, visitor, arm.
277
+ step('remember', 'data-raw-query', 3, {
278
+ dataSourceId: 'ds',
279
+ query:
280
+ "UPDATE teleport_orders o SET metadata = jsonb_build_object('analyticsVisit', jsonb_build_object('sessionId', $2::text, 'visitorId', $3::text, 'exp', $4::text))::text WHERE o.id::text = $1::text AND NULLIF($2::text, '') IS NOT NULL",
281
+ params: [
282
+ wfCtx('assemble', ['id']),
283
+ wfCtx('__tpa', ['sessionId']),
284
+ wfCtx('__tpa', ['visitorId']),
285
+ wfCtx('__tpa', ['exp']),
286
+ ],
287
+ continueOnError: true,
288
+ }),
289
+ // `buildReportOrderNode` in teleport-gui.
290
+ step('report', 'analytics-track-outcome', 4, {
291
+ name: 'purchase',
292
+ value: wfCtx('assemble', ['total_amount']),
293
+ currency: wfCtx('assemble', ['currency']),
294
+ outcomeId: wfCtx('assemble', ['id']),
295
+ attribution: wfCtx('assemble', ['attribution']),
296
+ }),
297
+ ],
298
+ line('assemble', 'busy', 'remember', 'report')
299
+ )
300
+
301
+ // What a generated trigger hands the runtime (`generateExecutionCall`).
302
+ const placeOrderWith = (stamped: unknown) =>
303
+ run(
304
+ placeOrder,
305
+ { formData: {}, __tpa: stamped },
306
+ {
307
+ 'state-update-local-state': async () => ({ updated: true }),
308
+ }
309
+ )
310
+
311
+ it('splits into a server segment, a browser one and a server one', () => {
312
+ expect(placeOrder.segments.map((segment) => segment.env)).toEqual([
313
+ 'server',
314
+ 'client',
315
+ 'server',
316
+ ])
317
+ })
318
+
319
+ it('reports the sale with the session, visitor and arm of the visit', async () => {
320
+ await placeOrderWith(SHOPPER)
321
+
322
+ expect(seen.outcomes).toHaveLength(1)
323
+ expect(seen.outcomes[0]).toMatchObject({
324
+ name: 'purchase',
325
+ value: 159,
326
+ currency: 'RON',
327
+ outcomeId: 'order-15',
328
+ sessionId: SHOPPER.sessionId,
329
+ visitorId: SHOPPER.visitorId,
330
+ path: SHOPPER.path,
331
+ exp: SHOPPER.exp,
332
+ attribution: { utmSource: 'newsletter', landingPath: '/sale' },
333
+ })
334
+ })
335
+
336
+ it('leaves the same ids on the order for the run that confirms its payment', async () => {
337
+ await placeOrderWith(SHOPPER)
338
+
339
+ expect(seen.rawParams).toEqual([
340
+ ['order-15', SHOPPER.sessionId, SHOPPER.visitorId, SHOPPER.exp],
341
+ ])
342
+ })
343
+
344
+ it('still carries the ids to the server segment after the browser one', async () => {
345
+ await placeOrderWith(SHOPPER)
346
+
347
+ expect(seen.segmentContexts).toHaveLength(2)
348
+ expect(seen.segmentContexts[0].__tpa).toEqual(SHOPPER)
349
+ expect(seen.segmentContexts[1].__tpa).toEqual(SHOPPER)
350
+ })
351
+
352
+ it('names no visitor for a visitor without consent, and still the session', async () => {
353
+ await placeOrderWith({ ...SHOPPER, visitorId: null, exp: null })
354
+
355
+ expect(seen.outcomes[0]).toMatchObject({
356
+ sessionId: SHOPPER.sessionId,
357
+ visitorId: null,
358
+ exp: null,
359
+ })
360
+ expect(seen.rawParams).toEqual([['order-15', SHOPPER.sessionId, null, null]])
361
+ })
362
+
363
+ it('reports the sale with no visit, and keeps none, without a tracker', async () => {
364
+ const stamped = trackerIdsIn({})
365
+ expect(stamped).toBeNull()
366
+
367
+ await placeOrderWith(stamped)
368
+
369
+ expect(seen.segmentContexts[0]).not.toHaveProperty('__tpa')
370
+ expect(seen.outcomes).toHaveLength(1)
371
+ expect(seen.outcomes[0]).toMatchObject({
372
+ outcomeId: 'order-15',
373
+ sessionId: null,
374
+ visitorId: null,
375
+ path: null,
376
+ exp: null,
377
+ })
378
+ // The statement's own guard writes nothing without a session.
379
+ expect(seen.rawParams).toEqual([['order-15', null, null, null]])
380
+ })
381
+ })
382
+
383
+ describe('a run without segments', () => {
384
+ const workflow = {
385
+ triggerNodeId: 'trigger',
386
+ nodes: [
387
+ step('report', 'analytics-track-outcome', 1, {
388
+ name: 'purchase',
389
+ value: 20,
390
+ currency: 'RON',
391
+ outcomeId: 'order-7',
392
+ }),
393
+ step('read', 'read-the-ids', 2, { sessionId: wfCtx('__tpa', ['sessionId']) }),
394
+ ],
395
+ edges: line('report', 'read'),
396
+ }
397
+ const handlers = () => ({
398
+ 'analytics-track-outcome': handlerOf(
399
+ 'analytics-track-outcome',
400
+ nodeRegistry['analytics-track-outcome'].generateServerHandler?.() ?? ''
401
+ ),
402
+ 'read-the-ids': async (config: { sessionId: unknown }) => ({ sessionId: config.sessionId }),
403
+ })
404
+
405
+ it('gives its steps the ids the trigger stamped', async () => {
406
+ const context = await runtime.utils.executeWorkflow(workflow, { __tpa: SHOPPER }, handlers())
407
+
408
+ expect(seen.outcomes[0]).toMatchObject({
409
+ sessionId: SHOPPER.sessionId,
410
+ visitorId: SHOPPER.visitorId,
411
+ exp: SHOPPER.exp,
412
+ })
413
+ expect(context.read).toEqual({ sessionId: SHOPPER.sessionId })
414
+ })
415
+
416
+ it('runs the same with no ids stamped', async () => {
417
+ const context = await runtime.utils.executeWorkflow(workflow, { __tpa: null }, handlers())
418
+
419
+ expect(context).not.toHaveProperty('__tpa')
420
+ expect(seen.outcomes[0]).toMatchObject({ sessionId: null, visitorId: null, exp: null })
421
+ expect(context.read.sessionId).toBeUndefined()
422
+ })
423
+ })
424
+
425
+ describe('a run outside the visit: a payment webhook reading the order back', () => {
426
+ // `buildPaidOrderReportSteps` in teleport-gui: the read, then the report.
427
+ const confirmPayment = graphOf(
428
+ [
429
+ step('load', 'data-raw-query', 1, {
430
+ dataSourceId: 'ds',
431
+ query: 'SELECT o.id FROM teleport_orders o WHERE o.id::text = $1::text',
432
+ params: ['order-15'],
433
+ continueOnError: true,
434
+ }),
435
+ step('report', 'analytics-track-outcome', 2, {
436
+ name: 'purchase',
437
+ value: wfCtx('load', ['result', '0', 'total_amount']),
438
+ currency: wfCtx('load', ['result', '0', 'currency']),
439
+ outcomeId: wfCtx('load', ['result', '0', 'id']),
440
+ attribution: wfCtx('load', ['result', '0']),
441
+ }),
442
+ ],
443
+ line('load', 'report')
444
+ )
445
+ const paidOrder = {
446
+ id: 'order-15',
447
+ total_amount: '159.00',
448
+ currency: 'RON',
449
+ utm_source: 'newsletter',
450
+ }
451
+ const rememberedVisit = {
452
+ session_id: SHOPPER.sessionId,
453
+ visitor_id: SHOPPER.visitorId,
454
+ exp: SHOPPER.exp,
455
+ }
456
+
457
+ it('takes the ids from the visit the order kept, with none on the trigger', async () => {
458
+ rawRows = [{ ...paidOrder, ...rememberedVisit }]
459
+
460
+ await run(confirmPayment, { timestamp: 1 })
461
+
462
+ expect(seen.segmentContexts[0]).not.toHaveProperty('__tpa')
463
+ expect(seen.outcomes).toHaveLength(1)
464
+ expect(seen.outcomes[0]).toMatchObject({
465
+ outcomeId: 'order-15',
466
+ value: 159,
467
+ sessionId: SHOPPER.sessionId,
468
+ visitorId: SHOPPER.visitorId,
469
+ exp: SHOPPER.exp,
470
+ attribution: { utmSource: 'newsletter' },
471
+ })
472
+ })
473
+
474
+ it('prefers the visit the order kept to the ids of whoever runs the step', async () => {
475
+ rawRows = [{ ...paidOrder, ...rememberedVisit }]
476
+
477
+ await run(confirmPayment, { __tpa: MERCHANT })
478
+
479
+ expect(seen.segmentContexts[0].__tpa).toEqual(MERCHANT)
480
+ expect(seen.outcomes[0]).toMatchObject({
481
+ sessionId: SHOPPER.sessionId,
482
+ visitorId: SHOPPER.visitorId,
483
+ exp: SHOPPER.exp,
484
+ })
485
+ })
486
+
487
+ it('still reports the sale, with no visit, when neither names one', async () => {
488
+ rawRows = [paidOrder]
489
+
490
+ const context = await run(confirmPayment, { timestamp: 1 })
491
+
492
+ expect(seen.outcomes).toHaveLength(1)
493
+ expect(seen.outcomes[0]).toMatchObject({
494
+ outcomeId: 'order-15',
495
+ sessionId: null,
496
+ visitorId: null,
497
+ path: null,
498
+ exp: null,
499
+ })
500
+ expect(context.report).toEqual({ sent: true, status: 202 })
501
+ })
502
+
503
+ it('sends nothing, and does not fail, when the read found no order', async () => {
504
+ rawRows = []
505
+
506
+ const context = await run(confirmPayment, { timestamp: 1 })
507
+
508
+ expect(seen.outcomes).toEqual([])
509
+ expect(context.report).toEqual({ sent: false, reason: 'no-outcome-id' })
510
+ })
511
+ })
512
+
513
+ describe('a run that stays in the browser', () => {
514
+ const customJs = (id: string, stepNumber: number, returned: string) =>
515
+ step(id, 'general-custom-js', stepNumber, {
516
+ code: `function customHandler(params) { return ${returned} }`,
517
+ })
518
+ const browserOnly = {
519
+ nodes: [
520
+ customJs('first', 1, '{ count: params.length }'),
521
+ customJs('second', 2, '{ count: params.length, last: params[params.length - 1] }'),
522
+ step('newsletter', 'analytics-track-outcome', 3, {
523
+ name: 'newsletter',
524
+ recordId: 'form-1',
525
+ }),
526
+ ],
527
+ edges: line('first', 'second', 'newsletter'),
528
+ }
529
+ const inTheBrowser = (stamped: unknown) =>
530
+ run(
531
+ {
532
+ ...browserOnly,
533
+ segments: [
534
+ {
535
+ id: 'seg-client-1',
536
+ env: 'client',
537
+ nodes: browserOnly.nodes,
538
+ edges: browserOnly.edges,
539
+ },
540
+ ] as unknown as WorkflowSegment[],
541
+ },
542
+ { __tpa: stamped },
543
+ {
544
+ 'general-custom-js': handlerOf(
545
+ 'general-custom-js',
546
+ nodeRegistry['general-custom-js'].generateHandler()
547
+ ),
548
+ 'analytics-track-outcome': handlerOf(
549
+ 'analytics-track-outcome',
550
+ nodeRegistry['analytics-track-outcome'].generateHandler()
551
+ ),
552
+ }
553
+ )
554
+
555
+ it('reads a missing tracker, or one without the ids, as no ids', () => {
556
+ expect(trackerIdsIn(undefined)).toBeNull()
557
+ expect(trackerIdsIn({})).toBeNull()
558
+ expect(trackerIdsIn({ tpAnalytics: {} })).toBeNull()
559
+ expect(trackerIdsIn({ tpAnalytics: { ids: () => SHOPPER } })).toEqual(SHOPPER)
560
+ })
561
+
562
+ it('runs every step without a tracker, and puts no ids on the context', async () => {
563
+ const context = await inTheBrowser(trackerIdsIn({}))
564
+
565
+ expect(context).not.toHaveProperty('__tpa')
566
+ expect(context.first).toEqual({ count: 1 })
567
+ expect(context.second.count).toBe(2)
568
+ expect(context.newsletter).toEqual({ sent: false, reason: 'tracker-unavailable' })
569
+ expect(seen.segmentContexts).toEqual([])
570
+ expect(seen.outcomes).toEqual([])
571
+ })
572
+
573
+ it('gives a custom script the same params with the ids lifted', async () => {
574
+ const without = await inTheBrowser(null)
575
+ const withIds = await inTheBrowser(SHOPPER)
576
+
577
+ expect(withIds.__tpa).toEqual(SHOPPER)
578
+ expect(withIds.first).toEqual(without.first)
579
+ expect(withIds.second).toEqual(without.second)
580
+ expect(seen.segmentContexts).toEqual([])
581
+ })
582
+ })
583
+ })
@@ -88,7 +88,7 @@ describe('analytics-track-outcome node', () => {
88
88
  value: '49.90',
89
89
  currency: 'EUR',
90
90
  outcomeId: 'ord-1',
91
- attribution: { utm_source: 'facebook', visitor_id: 'aid-1' },
91
+ attribution: { utm_source: 'facebook', utm_content: 'hero', visitor_id: 'stale-aid' },
92
92
  },
93
93
  { __tpa: { sessionId: 'sid-1', visitorId: null, path: '/checkout' } }
94
94
  )
@@ -102,9 +102,77 @@ describe('analytics-track-outcome node', () => {
102
102
  currency: 'EUR',
103
103
  outcomeId: 'ord-1',
104
104
  sessionId: 'sid-1',
105
- visitorId: 'aid-1',
105
+ visitorId: null,
106
106
  path: '/checkout',
107
- attribution: { utmSource: 'facebook' },
107
+ attribution: { utmSource: 'facebook', utmContent: 'hero' },
108
+ })
109
+ })
110
+
111
+ describe('server: an order read back outside the visit that placed it', () => {
112
+ const configured = (calls: Array<{ body: string }>) =>
113
+ evalHandler(generator.generateServerHandler?.() ?? '', {
114
+ globalThis: {
115
+ process: {
116
+ env: {
117
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
118
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
119
+ TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
120
+ },
121
+ },
122
+ },
123
+ fetch: (_url: string, init: { body: string }) => {
124
+ calls.push(init)
125
+ return Promise.resolve({ status: 202 })
126
+ },
127
+ AbortController: undefined,
128
+ })
129
+
130
+ it('credits the visit the order names, with no ids on the context', async () => {
131
+ const calls: Array<{ body: string }> = []
132
+ await configured(calls)(
133
+ {
134
+ name: 'purchase',
135
+ value: '20.00',
136
+ currency: 'EUR',
137
+ outcomeId: 'ord-2',
138
+ attribution: {
139
+ session_id: 'sid-shopper',
140
+ visitor_id: 'aid-shopper',
141
+ exp: 'hero:b',
142
+ landing_path: '/sale',
143
+ },
144
+ },
145
+ {}
146
+ )
147
+ expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
148
+ outcomeId: 'ord-2',
149
+ sessionId: 'sid-shopper',
150
+ visitorId: 'aid-shopper',
151
+ exp: 'hero:b',
152
+ attribution: { landingPath: '/sale' },
153
+ })
154
+ })
155
+
156
+ it('never credits the visit of whoever runs the step', async () => {
157
+ const calls: Array<{ body: string }> = []
158
+ await configured(calls)(
159
+ { name: 'purchase', outcomeId: 'ord-3', attribution: { session_id: 'sid-shopper' } },
160
+ { __tpa: { sessionId: 'sid-merchant', visitorId: 'aid-merchant', exp: 'hero:a' } }
161
+ )
162
+ expect(JSON.parse(calls[0].body).outcomes[0]).toMatchObject({
163
+ sessionId: 'sid-shopper',
164
+ visitorId: null,
165
+ exp: null,
166
+ })
167
+ })
168
+
169
+ it('sends nothing for an order that resolved to no id', async () => {
170
+ const calls: Array<{ body: string }> = []
171
+ expect(await configured(calls)({ name: 'purchase', value: '20.00' }, {})).toEqual({
172
+ sent: false,
173
+ reason: 'no-outcome-id',
174
+ })
175
+ expect(calls).toEqual([])
108
176
  })
109
177
  })
110
178