@teleporthq/teleport-plugin-next-workflows 0.45.4 → 0.45.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.
- package/__tests__/account-delete-password-check.test.ts +39 -0
- package/__tests__/analytics-outcome-visit-ids.test.ts +583 -0
- package/__tests__/automations-bus.test.ts +354 -0
- package/__tests__/public-step-guards.test.ts +26 -0
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +17 -8
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts +2 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +43 -1
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/cjs/automations/dispatcher-source.js +13 -5
- package/dist/cjs/automations/dispatcher-source.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +4 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/public-step-guards.d.ts +2 -2
- package/dist/cjs/public-step-guards.d.ts.map +1 -1
- package/dist/cjs/public-step-guards.js +11 -8
- package/dist/cjs/public-step-guards.js.map +1 -1
- package/dist/cjs/publish-refusals.js +1 -1
- package/dist/cjs/publish-refusals.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +17 -8
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +2 -0
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +44 -2
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/automations/dispatcher-source.d.ts.map +1 -1
- package/dist/esm/automations/dispatcher-source.js +13 -5
- package/dist/esm/automations/dispatcher-source.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +4 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/public-step-guards.d.ts +2 -2
- package/dist/esm/public-step-guards.d.ts.map +1 -1
- package/dist/esm/public-step-guards.js +11 -8
- package/dist/esm/public-step-guards.js.map +1 -1
- package/dist/esm/publish-refusals.js +1 -1
- package/dist/esm/publish-refusals.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/account-delete-route-generator.ts +17 -8
- package/src/api-route-generator.ts +52 -2
- package/src/automations/dispatcher-source.ts +13 -5
- package/src/executor-generator.ts +4 -0
- package/src/public-step-guards.ts +14 -8
- package/src/publish-refusals.ts +1 -1
|
@@ -86,6 +86,45 @@ describe('deleting an account with a password', () => {
|
|
|
86
86
|
expect(withoutPepper.status).toBe(403)
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
+
it('checks with the project’s own pepper first, and still with the old one, as sign-in does', async () => {
|
|
90
|
+
const env = { AUTH_PEPPER: 'pepper-1', AUTH_PEPPER_SECRET: 'secret-1' }
|
|
91
|
+
// Signed up, or signed in (rehashed), after the deploy provisioned the secret.
|
|
92
|
+
const current = await runAccountDeleteRoute({
|
|
93
|
+
userRow: userRow(storedHashOf(PASSWORD, 'secret-1')),
|
|
94
|
+
tables: TABLES,
|
|
95
|
+
requestBody: { password: PASSWORD },
|
|
96
|
+
env,
|
|
97
|
+
})
|
|
98
|
+
expect(current.status).toBe(200)
|
|
99
|
+
|
|
100
|
+
// Not signed in since: the hash is still on the old pepper.
|
|
101
|
+
const earlier = await runAccountDeleteRoute({
|
|
102
|
+
userRow: userRow(storedHashOf(PASSWORD, 'pepper-1')),
|
|
103
|
+
tables: TABLES,
|
|
104
|
+
requestBody: { password: PASSWORD },
|
|
105
|
+
env,
|
|
106
|
+
})
|
|
107
|
+
expect(earlier.status).toBe(200)
|
|
108
|
+
|
|
109
|
+
const wrong = await runAccountDeleteRoute({
|
|
110
|
+
userRow: userRow(storedHashOf(PASSWORD, 'secret-1')),
|
|
111
|
+
tables: TABLES,
|
|
112
|
+
requestBody: { password: 'not it' },
|
|
113
|
+
env,
|
|
114
|
+
})
|
|
115
|
+
expect(wrong.status).toBe(403)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('takes an unresolved secret placeholder for no secret', async () => {
|
|
119
|
+
const run = await runAccountDeleteRoute({
|
|
120
|
+
userRow: userRow(storedHashOf(PASSWORD, 'pepper-1')),
|
|
121
|
+
tables: TABLES,
|
|
122
|
+
requestBody: { password: PASSWORD },
|
|
123
|
+
env: { AUTH_PEPPER: 'pepper-1', AUTH_PEPPER_SECRET: 'teleporthq.secrets.AUTH_PEPPER_SECRET' },
|
|
124
|
+
})
|
|
125
|
+
expect(run.status).toBe(200)
|
|
126
|
+
})
|
|
127
|
+
|
|
89
128
|
it('refuses any password for an account that has none stored (a social sign-in)', async () => {
|
|
90
129
|
const run = await runAccountDeleteRoute({
|
|
91
130
|
userRow: userRow(null),
|
|
@@ -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
|
+
})
|