@teleporthq/teleport-plugin-next-workflows 0.43.62 → 0.43.64
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__/data-update-item-runtime.test.ts +39 -0
- package/__tests__/page-revalidate-node.test.ts +276 -0
- package/__tests__/payment-refund.test.ts +135 -0
- package/__tests__/state-same-run-write-then-read.test.ts +225 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.d.ts +46 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.d.ts.map +1 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.js +94 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.js.map +1 -0
- package/dist/cjs/nodes/cache/page-revalidate.d.ts +3 -0
- package/dist/cjs/nodes/cache/page-revalidate.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/page-revalidate.js +125 -0
- package/dist/cjs/nodes/cache/page-revalidate.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-add-item.js +22 -1
- package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-remove-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-remove-item.js +23 -1
- package/dist/cjs/nodes/cart/cart-remove-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.js +42 -1
- package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.js +38 -3
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +3 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/nodes/payment/payment-refund.d.ts +3 -0
- package/dist/cjs/nodes/payment/payment-refund.d.ts.map +1 -0
- package/dist/cjs/nodes/payment/payment-refund.js +446 -0
- package/dist/cjs/nodes/payment/payment-refund.js.map +1 -0
- package/dist/cjs/secret-collector.d.ts.map +1 -1
- package/dist/cjs/secret-collector.js +3 -0
- package/dist/cjs/secret-collector.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.js +23 -3
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/nodes/analytics/commerce-tracking.d.ts +46 -0
- package/dist/esm/nodes/analytics/commerce-tracking.d.ts.map +1 -0
- package/dist/esm/nodes/analytics/commerce-tracking.js +90 -0
- package/dist/esm/nodes/analytics/commerce-tracking.js.map +1 -0
- package/dist/esm/nodes/cache/page-revalidate.d.ts +3 -0
- package/dist/esm/nodes/cache/page-revalidate.d.ts.map +1 -0
- package/dist/esm/nodes/cache/page-revalidate.js +122 -0
- package/dist/esm/nodes/cache/page-revalidate.js.map +1 -0
- package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-add-item.js +22 -1
- package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/esm/nodes/cart/cart-remove-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-remove-item.js +23 -1
- package/dist/esm/nodes/cart/cart-remove-item.js.map +1 -1
- package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-create-item.js +42 -1
- package/dist/esm/nodes/data/data-create-item.js.map +1 -1
- package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-update-item.js +38 -3
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +3 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/nodes/payment/payment-refund.d.ts +3 -0
- package/dist/esm/nodes/payment/payment-refund.d.ts.map +1 -0
- package/dist/esm/nodes/payment/payment-refund.js +443 -0
- package/dist/esm/nodes/payment/payment-refund.js.map +1 -0
- package/dist/esm/secret-collector.d.ts.map +1 -1
- package/dist/esm/secret-collector.js +3 -0
- package/dist/esm/secret-collector.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.js +23 -3
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/nodes/analytics/commerce-tracking.ts +93 -0
- package/src/nodes/cache/page-revalidate.ts +140 -0
- package/src/nodes/cart/cart-add-item.ts +35 -1
- package/src/nodes/cart/cart-remove-item.ts +36 -1
- package/src/nodes/data/data-create-item.ts +44 -1
- package/src/nodes/data/data-update-item.ts +39 -3
- package/src/nodes/index.ts +4 -0
- package/src/nodes/payment/payment-refund.ts +512 -0
- package/src/secret-collector.ts +3 -0
- package/src/workflow-component-plugin.ts +23 -3
|
@@ -99,6 +99,45 @@ describe('data-update-item runtime output + null-safety', () => {
|
|
|
99
99
|
expect(result.affected).toBe(0)
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
/*
|
|
103
|
+
A value that could not cross the client→server hand-off arrives as the
|
|
104
|
+
pruner's placeholder. Written through, it REPLACES the stored content with
|
|
105
|
+
`{"__truncated":true}` — which is how a rich-text field carrying an inlined
|
|
106
|
+
image emptied a whole custom page's block tree. The column keeps what it has
|
|
107
|
+
instead, exactly like an undefined mapping.
|
|
108
|
+
*/
|
|
109
|
+
it('keeps the stored value when the new one arrived truncated', async () => {
|
|
110
|
+
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
111
|
+
await handler(
|
|
112
|
+
{
|
|
113
|
+
tableName: 'teleport_pages',
|
|
114
|
+
filters: [{ field: 'id', operator: '=', value: 'p1' }],
|
|
115
|
+
columnMappings: [
|
|
116
|
+
{ column: 'blocks', value: { __truncated: true, type: 'string' } },
|
|
117
|
+
{ column: 'title', value: 'Launch' },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{}
|
|
121
|
+
)
|
|
122
|
+
expect(calls).toHaveLength(1)
|
|
123
|
+
const sentMappings = calls[0].body.columnMappings as Array<{ column: string }>
|
|
124
|
+
expect(sentMappings.map((m) => m.column)).toEqual(['title'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps the stored value when the new one could not be serialized', async () => {
|
|
128
|
+
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
129
|
+
const result = (await handler(
|
|
130
|
+
{
|
|
131
|
+
tableName: 'teleport_pages',
|
|
132
|
+
filters: [{ field: 'id', operator: '=', value: 'p1' }],
|
|
133
|
+
columnMappings: { blocks: { __serializationError: true } },
|
|
134
|
+
},
|
|
135
|
+
{}
|
|
136
|
+
)) as Record<string, unknown>
|
|
137
|
+
expect(calls).toHaveLength(0)
|
|
138
|
+
expect(result.updatedCount).toBe(0)
|
|
139
|
+
})
|
|
140
|
+
|
|
102
141
|
it('preserves an explicit null columnMapping (intentional clear ≠ undefined)', async () => {
|
|
103
142
|
const calls = installFetch({ id: 'r1', updatedCount: 1 })
|
|
104
143
|
await handler(
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { nodeRegistry } from '../src'
|
|
2
|
+
|
|
3
|
+
interface RevalidateResult {
|
|
4
|
+
revalidated?: string[]
|
|
5
|
+
warning?: string
|
|
6
|
+
success?: boolean
|
|
7
|
+
error?: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FetchCall {
|
|
11
|
+
url: string
|
|
12
|
+
init: {
|
|
13
|
+
method: string
|
|
14
|
+
headers: Record<string, string>
|
|
15
|
+
body: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const source = () => nodeRegistry['page-revalidate'].generateHandler()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs the emitted handler the way the generated app does: as runtime source in
|
|
23
|
+
* a scope that provides only `globalThis`, `window` and `fetch`. `process` is
|
|
24
|
+
* reached through the injected `globalThis`, which is exactly how the handler
|
|
25
|
+
* has to read it to survive the browser webpack.
|
|
26
|
+
*/
|
|
27
|
+
const runHandler = async (
|
|
28
|
+
config: Record<string, unknown>,
|
|
29
|
+
options: {
|
|
30
|
+
secret?: string
|
|
31
|
+
context?: Record<string, unknown>
|
|
32
|
+
// tslint:disable-next-line:no-any
|
|
33
|
+
respond?: (call: FetchCall) => any
|
|
34
|
+
calls?: FetchCall[]
|
|
35
|
+
} = {}
|
|
36
|
+
): Promise<RevalidateResult> => {
|
|
37
|
+
const env = options.secret === undefined ? {} : { TQ_CACHE_SECRET: options.secret }
|
|
38
|
+
const calls = options.calls || []
|
|
39
|
+
|
|
40
|
+
// tslint:disable-next-line:no-any
|
|
41
|
+
const fetchImpl = async (url: string, init: any) => {
|
|
42
|
+
const call: FetchCall = { url, init }
|
|
43
|
+
calls.push(call)
|
|
44
|
+
if (!options.respond) {
|
|
45
|
+
throw new Error('offline')
|
|
46
|
+
}
|
|
47
|
+
const payload = options.respond(call)
|
|
48
|
+
return {
|
|
49
|
+
ok: payload.ok !== false,
|
|
50
|
+
json: async () => payload.body,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// tslint:disable-next-line:function-constructor
|
|
55
|
+
const factory = new Function(
|
|
56
|
+
'globalThis',
|
|
57
|
+
'window',
|
|
58
|
+
'fetch',
|
|
59
|
+
`${source()}; return page_revalidate;`
|
|
60
|
+
)
|
|
61
|
+
const fn = factory({ process: { env } }, undefined, fetchImpl)
|
|
62
|
+
|
|
63
|
+
return fn(config, options.context || { __baseUrl: 'https://site.test' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const expectNotFatal = (result: RevalidateResult) => {
|
|
67
|
+
// Mirrors `isFatalNodeResult` in the emitted runtime: `success: false`, a
|
|
68
|
+
// string `error`, or `error: true` HALTS the workflow.
|
|
69
|
+
expect(result.success).toBeUndefined()
|
|
70
|
+
expect(typeof result.error).not.toBe('string')
|
|
71
|
+
expect(result.error).not.toBe(true)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('page-revalidate node', () => {
|
|
75
|
+
it('is registered and runs on the server', () => {
|
|
76
|
+
expect(nodeRegistry['page-revalidate']).toBeDefined()
|
|
77
|
+
expect(nodeRegistry['page-revalidate'].nodeType).toBe('page-revalidate')
|
|
78
|
+
// `res.revalidate` only exists inside a server route, and server-side puts
|
|
79
|
+
// the rebuild in the same segment as the write it follows.
|
|
80
|
+
expect(nodeRegistry['page-revalidate'].executionEnv).toBe('server')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Handlers ship as runtime source and are re-bundled by webpack, which
|
|
85
|
+
* rewrites `require(`.
|
|
86
|
+
*/
|
|
87
|
+
it('never requires a module', () => {
|
|
88
|
+
expect(source()).not.toMatch(/(?<![\w.])require\s*\(/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('path resolution', () => {
|
|
92
|
+
it('posts the configured paths to the revalidate route', async () => {
|
|
93
|
+
const calls: FetchCall[] = []
|
|
94
|
+
const result = await runHandler(
|
|
95
|
+
{ paths: ['/pages/welcome', '/pages/about'] },
|
|
96
|
+
{
|
|
97
|
+
secret: 'sekret',
|
|
98
|
+
calls,
|
|
99
|
+
respond: () => ({ body: { ok: true, revalidated: ['/pages/welcome'], failed: [] } }),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(calls).toHaveLength(1)
|
|
104
|
+
expect(calls[0].url).toBe('https://site.test/api/tq-revalidate')
|
|
105
|
+
expect(calls[0].init.method).toBe('POST')
|
|
106
|
+
expect(calls[0].init.headers['x-tq-cache-secret']).toBe('sekret')
|
|
107
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({
|
|
108
|
+
paths: ['/pages/welcome', '/pages/about'],
|
|
109
|
+
})
|
|
110
|
+
expect(result.revalidated).toEqual(['/pages/welcome'])
|
|
111
|
+
expect(result.warning).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A path built from a database column routinely arrives as the bare slug,
|
|
116
|
+
* and `res.revalidate` only accepts a site-root-relative path.
|
|
117
|
+
*/
|
|
118
|
+
it('gives a bare slug a leading slash', async () => {
|
|
119
|
+
const calls: FetchCall[] = []
|
|
120
|
+
await runHandler(
|
|
121
|
+
{ paths: ['pages/welcome'] },
|
|
122
|
+
{ secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An update revalidates the slug the row HAD and the slug it now has — and
|
|
130
|
+
* when the slug did not change, those are the same string. Rebuilding it
|
|
131
|
+
* twice would double the work for no effect.
|
|
132
|
+
*/
|
|
133
|
+
it('drops blanks, unresolved refs and duplicates', async () => {
|
|
134
|
+
const calls: FetchCall[] = []
|
|
135
|
+
await runHandler(
|
|
136
|
+
{
|
|
137
|
+
paths: [
|
|
138
|
+
'/pages/welcome',
|
|
139
|
+
' ',
|
|
140
|
+
'/pages/welcome',
|
|
141
|
+
null,
|
|
142
|
+
{ type: 'workflowContext', nodeId: 'n1', path: ['value'] },
|
|
143
|
+
'pages/welcome',
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
{ secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* A step that rebuilds several rows at once resolves ONE reference to an
|
|
154
|
+
* array of slugs — a bulk delete cannot know at build time how many rows the
|
|
155
|
+
* admin will select, so it cannot configure a path per row.
|
|
156
|
+
*/
|
|
157
|
+
it('flattens an array of paths resolved from a single reference', async () => {
|
|
158
|
+
const calls: FetchCall[] = []
|
|
159
|
+
await runHandler(
|
|
160
|
+
{ paths: [['/pages/welcome', '/pages/pricing'], '/pages/about'] },
|
|
161
|
+
{ secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(JSON.parse(calls[0].init.body).paths).toEqual([
|
|
165
|
+
'/pages/welcome',
|
|
166
|
+
'/pages/pricing',
|
|
167
|
+
'/pages/about',
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('accepts a single path configured as a bare string', async () => {
|
|
172
|
+
const calls: FetchCall[] = []
|
|
173
|
+
await runHandler(
|
|
174
|
+
{ paths: '/pages/welcome' },
|
|
175
|
+
{ secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('forwards the internal request headers the deployment protection needs', async () => {
|
|
182
|
+
const calls: FetchCall[] = []
|
|
183
|
+
await runHandler(
|
|
184
|
+
{ paths: ['/pages/welcome'] },
|
|
185
|
+
{
|
|
186
|
+
secret: 'sekret',
|
|
187
|
+
calls,
|
|
188
|
+
context: {
|
|
189
|
+
__baseUrl: 'https://site.test',
|
|
190
|
+
__internalHeaders: { cookie: '_vercel_jwt=abc' },
|
|
191
|
+
},
|
|
192
|
+
respond: () => ({ body: { ok: true } }),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
expect(calls[0].init.headers.cookie).toBe('_vercel_jwt=abc')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* THE regression this node is shaped around. The runtime treats
|
|
202
|
+
* `success: false` / a string `error` as FATAL, so a page that could not be
|
|
203
|
+
* rebuilt would halt the workflow AFTER the row was already written — no
|
|
204
|
+
* success toast, no navigation, and an error for an action that worked. Every
|
|
205
|
+
* failure mode below has to report a warning instead.
|
|
206
|
+
*/
|
|
207
|
+
describe('never reports failure in a shape the runtime treats as fatal', () => {
|
|
208
|
+
it('with no paths configured', async () => {
|
|
209
|
+
const calls: FetchCall[] = []
|
|
210
|
+
const result = await runHandler({}, { secret: 'sekret', calls })
|
|
211
|
+
|
|
212
|
+
expect(result.revalidated).toEqual([])
|
|
213
|
+
expect(result.warning).toContain('No page paths')
|
|
214
|
+
expect(calls).toHaveLength(0)
|
|
215
|
+
expectNotFatal(result)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('with every configured path blank', async () => {
|
|
219
|
+
const result = await runHandler({ paths: ['', ' '] }, { secret: 'sekret' })
|
|
220
|
+
|
|
221
|
+
expect(result.revalidated).toEqual([])
|
|
222
|
+
expectNotFatal(result)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('with no secret configured', async () => {
|
|
226
|
+
const calls: FetchCall[] = []
|
|
227
|
+
const result = await runHandler({ paths: ['/pages/welcome'] }, { calls })
|
|
228
|
+
|
|
229
|
+
expect(result.revalidated).toEqual([])
|
|
230
|
+
expect(result.warning).toContain('TQ_CACHE_SECRET')
|
|
231
|
+
// Nothing is sent without the credential the route requires.
|
|
232
|
+
expect(calls).toHaveLength(0)
|
|
233
|
+
expectNotFatal(result)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('when the route answers with an error status', async () => {
|
|
237
|
+
const result = await runHandler(
|
|
238
|
+
{ paths: ['/pages/welcome'] },
|
|
239
|
+
{ secret: 'sekret', respond: () => ({ ok: false, body: { error: 'Unauthorized' } }) }
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expect(result.revalidated).toEqual([])
|
|
243
|
+
expect(result.warning).toBe('Unauthorized')
|
|
244
|
+
expectNotFatal(result)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('when the route is unreachable', async () => {
|
|
248
|
+
const result = await runHandler({ paths: ['/pages/welcome'] }, { secret: 'sekret' })
|
|
249
|
+
|
|
250
|
+
expect(result.revalidated).toEqual([])
|
|
251
|
+
expect(result.warning).toBe('offline')
|
|
252
|
+
expectNotFatal(result)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* `res.revalidate` throws for a path that was never statically generated —
|
|
257
|
+
* the normal case for a page whose row was just deleted. The paths that DID
|
|
258
|
+
* rebuild are still reported.
|
|
259
|
+
*/
|
|
260
|
+
it('when some paths could not be rebuilt', async () => {
|
|
261
|
+
const result = await runHandler(
|
|
262
|
+
{ paths: ['/pages/welcome', '/pages/gone'] },
|
|
263
|
+
{
|
|
264
|
+
secret: 'sekret',
|
|
265
|
+
respond: () => ({
|
|
266
|
+
body: { ok: true, revalidated: ['/pages/welcome'], failed: ['/pages/gone'] },
|
|
267
|
+
}),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(result.revalidated).toEqual(['/pages/welcome'])
|
|
272
|
+
expect(result.warning).toContain('/pages/gone')
|
|
273
|
+
expectNotFatal(result)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
2
|
+
import { paymentRefund } from '../src/nodes/payment/payment-refund'
|
|
3
|
+
import { resolveHandlerEntryName } from '../src/nodes/types'
|
|
4
|
+
import { collectSecrets } from '../src/secret-collector'
|
|
5
|
+
|
|
6
|
+
interface RefundResult {
|
|
7
|
+
success: boolean
|
|
8
|
+
refundId: string
|
|
9
|
+
amount: number
|
|
10
|
+
currency: string
|
|
11
|
+
status: string
|
|
12
|
+
error: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('payment-refund', () => {
|
|
16
|
+
const handler: HandlerFn = loadHandler('payment-refund')
|
|
17
|
+
const source = paymentRefund.generateHandler()
|
|
18
|
+
|
|
19
|
+
it('is a SERVER node that continues the workflow', () => {
|
|
20
|
+
expect(paymentRefund.executionEnv).toBe('server')
|
|
21
|
+
// Unlike a charge, a refund must NOT be terminal: the steps that record it,
|
|
22
|
+
// restock it and email the buyer all run after it.
|
|
23
|
+
expect(paymentRefund.isTerminal).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('has its provider credentials collected into the generated env', () => {
|
|
27
|
+
// Without this the exported project ships with no key for the refund node
|
|
28
|
+
// and every refund fails at runtime with "not configured".
|
|
29
|
+
const secrets = collectSecrets({
|
|
30
|
+
workflows: {
|
|
31
|
+
wf1: {
|
|
32
|
+
nodes: [
|
|
33
|
+
{
|
|
34
|
+
id: 'node-1',
|
|
35
|
+
type: 'payment-refund',
|
|
36
|
+
config: { secretKey: 'sk_test_1', clientId: 'id_1', clientSecret: 'cs_1' },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
} as never)
|
|
42
|
+
expect(secrets.map((secret) => secret.fieldName).sort()).toEqual([
|
|
43
|
+
'clientId',
|
|
44
|
+
'clientSecret',
|
|
45
|
+
'secretKey',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('resolves its own entry point out of the concatenated source', () => {
|
|
50
|
+
// The entry has to be the FIRST statement-level two-parameter function, or
|
|
51
|
+
// the generated route calls a helper instead.
|
|
52
|
+
expect(resolveHandlerEntryName(source, 'payment-refund')).toBe('payment_refund')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reads process through globalThis, which webpack leaves alone', () => {
|
|
56
|
+
// A bare `process` is rewritten by the GUI's browser packer into a name
|
|
57
|
+
// that does not exist on the Vercel Node runtime.
|
|
58
|
+
expect(source).toContain('globalThis.process.env')
|
|
59
|
+
// …and nowhere is `process.env` read WITHOUT that prefix.
|
|
60
|
+
expect(source.replace(/globalThis\.process\.env/g, '')).not.toContain('process.env')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps each currency list in the SAME snapshot as the code that reads it', () => {
|
|
64
|
+
// A declaration in one `.toString()`'d function and its reference in
|
|
65
|
+
// another is the "Oo is not defined" bug — a minifier renames the two
|
|
66
|
+
// independently and the generated file references a name it never declares.
|
|
67
|
+
// Slicing the emitted source per function is the only way to check that.
|
|
68
|
+
for (const fnName of ['toProviderMinorUnits', 'fromProviderMinorUnits']) {
|
|
69
|
+
const start = source.indexOf(`function ${fnName}`)
|
|
70
|
+
expect(start).toBeGreaterThanOrEqual(0)
|
|
71
|
+
const next = source.indexOf('\nfunction ', start + 1)
|
|
72
|
+
const body = next === -1 ? source.slice(start) : source.slice(start, next)
|
|
73
|
+
expect(body).toContain('zeroDecimalCurrencies = [')
|
|
74
|
+
expect(body).toContain('threeDecimalCurrencies = [')
|
|
75
|
+
expect(body).toContain('zeroDecimalCurrencies.indexOf')
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refuses without a payment reference instead of calling a provider', async () => {
|
|
80
|
+
const result = (await handler({ currency: 'usd' }, {})) as RefundResult
|
|
81
|
+
expect(result.success).toBe(false)
|
|
82
|
+
expect(result.error).toMatch(/no payment reference/i)
|
|
83
|
+
expect(result.refundId).toBe('')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reports missing Stripe credentials as an actionable error', async () => {
|
|
87
|
+
const saved = process.env.STRIPE_SECRET_KEY
|
|
88
|
+
delete process.env.STRIPE_SECRET_KEY
|
|
89
|
+
try {
|
|
90
|
+
const result = (await handler(
|
|
91
|
+
{ providerType: 'stripe', paymentReference: 'pi_1', currency: 'usd' },
|
|
92
|
+
{}
|
|
93
|
+
)) as RefundResult
|
|
94
|
+
expect(result.success).toBe(false)
|
|
95
|
+
expect(result.error).toContain('STRIPE_SECRET_KEY')
|
|
96
|
+
// The shape stays constant on every path, so a downstream node can bind
|
|
97
|
+
// to it without knowing which branch ran.
|
|
98
|
+
expect(result).toMatchObject({ refundId: '', amount: 0, status: '' })
|
|
99
|
+
} finally {
|
|
100
|
+
if (saved !== undefined) {
|
|
101
|
+
process.env.STRIPE_SECRET_KEY = saved
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('reports missing PayPal credentials without attempting a network call', async () => {
|
|
107
|
+
const savedId = process.env.PAYPAL_CLIENT_ID
|
|
108
|
+
const savedSecret = process.env.PAYPAL_CLIENT_SECRET
|
|
109
|
+
delete process.env.PAYPAL_CLIENT_ID
|
|
110
|
+
delete process.env.PAYPAL_CLIENT_SECRET
|
|
111
|
+
try {
|
|
112
|
+
const result = (await handler(
|
|
113
|
+
{ providerType: 'paypal', paymentReference: 'cap_1', currency: 'usd' },
|
|
114
|
+
{}
|
|
115
|
+
)) as RefundResult
|
|
116
|
+
expect(result.success).toBe(false)
|
|
117
|
+
expect(result.error).toMatch(/PayPal credentials are not configured/i)
|
|
118
|
+
} finally {
|
|
119
|
+
if (savedId !== undefined) {
|
|
120
|
+
process.env.PAYPAL_CLIENT_ID = savedId
|
|
121
|
+
}
|
|
122
|
+
if (savedSecret !== undefined) {
|
|
123
|
+
process.env.PAYPAL_CLIENT_SECRET = savedSecret
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('always returns an answer rather than throwing, so bookkeeping can still run', async () => {
|
|
129
|
+
// A declined refund is a branch the workflow takes, not a crash that takes
|
|
130
|
+
// the whole segment — including the steps that record what happened — down.
|
|
131
|
+
await expect(
|
|
132
|
+
handler({ providerType: 'stripe', paymentReference: 'pi_1', currency: 'usd' }, {})
|
|
133
|
+
).resolves.toBeDefined()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// A workflow that WRITES a state key and then READS it back in the same run
|
|
2
|
+
// used to get the value from before the write.
|
|
3
|
+
//
|
|
4
|
+
// `state-update-local-state` calls the React setter, which only schedules a
|
|
5
|
+
// render, and `state-get-local-state` deliberately reads `stateValuesRef`
|
|
6
|
+
// (the "latest committed" values) rather than the trigger-time snapshot. The
|
|
7
|
+
// ref is rebuilt from state on render, so between the write and the next
|
|
8
|
+
// render it still held the old value.
|
|
9
|
+
//
|
|
10
|
+
// The chat's option chips are exactly this sequence — write the chip's text
|
|
11
|
+
// into the input state, then invoke the node that reads it back — so clicking
|
|
12
|
+
// a chip re-sent the previous message, and the assistant answered the question
|
|
13
|
+
// before the one the visitor had just chosen.
|
|
14
|
+
|
|
15
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
16
|
+
|
|
17
|
+
const extractFunctionSource = (haystack: string, funcDecl: string): string => {
|
|
18
|
+
const startIdx = haystack.indexOf(funcDecl)
|
|
19
|
+
if (startIdx === -1) {
|
|
20
|
+
throw new Error('Helper not found: ' + funcDecl)
|
|
21
|
+
}
|
|
22
|
+
let depth = 0
|
|
23
|
+
let i = haystack.indexOf('{', startIdx)
|
|
24
|
+
if (i === -1) {
|
|
25
|
+
throw new Error('No opening brace after ' + funcDecl)
|
|
26
|
+
}
|
|
27
|
+
for (; i < haystack.length; i++) {
|
|
28
|
+
const ch = haystack.charAt(i)
|
|
29
|
+
if (ch === '{') {
|
|
30
|
+
depth++
|
|
31
|
+
} else if (ch === '}') {
|
|
32
|
+
depth--
|
|
33
|
+
if (depth === 0) {
|
|
34
|
+
return haystack.slice(startIdx, i + 1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Unbalanced braces for ' + funcDecl)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const buildStructure = (): any => {
|
|
42
|
+
const workflow = {
|
|
43
|
+
id: 'wf-1',
|
|
44
|
+
name: 'Test WF',
|
|
45
|
+
trigger: {
|
|
46
|
+
type: 'event-page-loaded',
|
|
47
|
+
nodeId: 'trigger-1',
|
|
48
|
+
scope: 'page',
|
|
49
|
+
config: { pageId: 'page-1' },
|
|
50
|
+
},
|
|
51
|
+
nodes: [
|
|
52
|
+
{
|
|
53
|
+
id: 'update-1',
|
|
54
|
+
type: 'state-update-local-state',
|
|
55
|
+
config: { property: 'chatInputValue', value: 'x' },
|
|
56
|
+
stepNumber: 1,
|
|
57
|
+
label: 'X',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
edges: [{ id: 'e', source: 'trigger-1', target: 'update-1' }],
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
uidl: {
|
|
64
|
+
name: 'Page',
|
|
65
|
+
outputOptions: { pageId: 'page-1', fileName: 'page-1' },
|
|
66
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
67
|
+
stateDefinitions: {
|
|
68
|
+
chatInputValue: { type: 'string', defaultValue: '' },
|
|
69
|
+
draft: { type: 'object', defaultValue: {} },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
chunks: [
|
|
73
|
+
{
|
|
74
|
+
type: 'chunk-type-ast',
|
|
75
|
+
name: 'jsx-component',
|
|
76
|
+
content: {
|
|
77
|
+
type: 'VariableDeclaration',
|
|
78
|
+
declarations: [
|
|
79
|
+
{
|
|
80
|
+
type: 'VariableDeclarator',
|
|
81
|
+
init: {
|
|
82
|
+
type: 'ArrowFunctionExpression',
|
|
83
|
+
body: {
|
|
84
|
+
type: 'BlockStatement',
|
|
85
|
+
body: [{ type: 'ReturnStatement', argument: null }],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
options: { workflows: { workflows: { 'wf-1': workflow }, customNodes: {} } },
|
|
94
|
+
dependencies: {},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Harness {
|
|
99
|
+
update: (config: Record<string, unknown>, context: unknown) => Promise<unknown>
|
|
100
|
+
batchUpdate: (config: Record<string, unknown>, context: unknown) => Promise<unknown>
|
|
101
|
+
read: (config: Record<string, unknown>, context: unknown) => Promise<{ value: unknown }>
|
|
102
|
+
/** What React has actually committed — only advances on "render". */
|
|
103
|
+
committed: Record<string, unknown>
|
|
104
|
+
render: () => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Instantiates the emitted `__createWorkflowHandlers` against a fake React:
|
|
109
|
+
* setters record into `pending`, and the ref is only rebuilt from `pending`
|
|
110
|
+
* when `render()` is called — the timing that made the bug possible.
|
|
111
|
+
*/
|
|
112
|
+
const buildHarness = async (): Promise<Harness> => {
|
|
113
|
+
const plugin = createNextWorkflowPlugin({ isPage: true })
|
|
114
|
+
const structure = buildStructure()
|
|
115
|
+
await plugin(structure as any)
|
|
116
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
117
|
+
if (!moduleChunk) {
|
|
118
|
+
throw new Error('workflow-module chunk not emitted by plugin')
|
|
119
|
+
}
|
|
120
|
+
const moduleCode = String(moduleChunk.content)
|
|
121
|
+
|
|
122
|
+
// The factory returns only its trigger wiring, so capture the handler map
|
|
123
|
+
// itself at the point it is built.
|
|
124
|
+
const HANDLERS_DECL = 'const __handlers = Object.assign({}, workflowClientHandlers);'
|
|
125
|
+
const factorySource = extractFunctionSource(moduleCode, 'function __createWorkflowHandlers')
|
|
126
|
+
if (!factorySource.includes(HANDLERS_DECL)) {
|
|
127
|
+
throw new Error('handler map declaration moved; update this test')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const source = [
|
|
131
|
+
extractFunctionSource(moduleCode, 'function __resolveName'),
|
|
132
|
+
extractFunctionSource(moduleCode, 'function __defaultValueForType'),
|
|
133
|
+
extractFunctionSource(moduleCode, 'function __coerceValue'),
|
|
134
|
+
factorySource.replace(HANDLERS_DECL, HANDLERS_DECL + '\n __captureHandlers(__handlers);'),
|
|
135
|
+
'return __createWorkflowHandlers;',
|
|
136
|
+
].join('\n')
|
|
137
|
+
|
|
138
|
+
const stateTypes = { chatInputValue: 'string', draft: 'object' }
|
|
139
|
+
const committed: Record<string, unknown> = { chatInputValue: '', draft: {} }
|
|
140
|
+
const pending: Record<string, unknown> = { ...committed }
|
|
141
|
+
const stateSetters: Record<string, (v: unknown) => void> = {
|
|
142
|
+
chatInputValue: (v) => {
|
|
143
|
+
pending.chatInputValue = v
|
|
144
|
+
},
|
|
145
|
+
draft: (v) => {
|
|
146
|
+
pending.draft = v
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
// Rebuilt from committed state on every render, exactly like the emitted
|
|
150
|
+
// `__wfStateRef.current = { ... }` assignment.
|
|
151
|
+
const stateValuesRef = { current: { ...committed } }
|
|
152
|
+
|
|
153
|
+
let handlers: Record<string, (config: any, context: any) => Promise<any>> = {}
|
|
154
|
+
// `__handlers` starts as a copy of the shared client handlers; the state
|
|
155
|
+
// handlers under test are assigned over it, so an empty map is enough.
|
|
156
|
+
const factory = new Function('stateTypes', 'workflowClientHandlers', '__captureHandlers', source)(
|
|
157
|
+
stateTypes,
|
|
158
|
+
{},
|
|
159
|
+
(map: Record<string, any>) => {
|
|
160
|
+
handlers = map
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
factory(stateSetters, stateTypes, stateValuesRef)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
update: handlers['state-update-local-state'],
|
|
167
|
+
batchUpdate: handlers['state-batch-update'],
|
|
168
|
+
read: handlers['state-get-local-state'],
|
|
169
|
+
committed,
|
|
170
|
+
render: () => {
|
|
171
|
+
Object.assign(committed, pending)
|
|
172
|
+
stateValuesRef.current = { ...committed }
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe('state written and read back inside one workflow run', () => {
|
|
178
|
+
it('reads the value the run just wrote, before React has re-rendered', async () => {
|
|
179
|
+
const h = await buildHarness()
|
|
180
|
+
const context = { __stateValues: { chatInputValue: 'the previous message' } }
|
|
181
|
+
|
|
182
|
+
await h.update({ property: 'chatInputValue', value: 'the message just chosen' }, context)
|
|
183
|
+
const result = await h.read({ property: 'chatInputValue' }, context)
|
|
184
|
+
|
|
185
|
+
expect(result.value).toBe('the message just chosen')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('still reads the written value after the render lands', async () => {
|
|
189
|
+
const h = await buildHarness()
|
|
190
|
+
const context = { __stateValues: { chatInputValue: '' } }
|
|
191
|
+
|
|
192
|
+
await h.update({ property: 'chatInputValue', value: 'chosen' }, context)
|
|
193
|
+
h.render()
|
|
194
|
+
const result = await h.read({ property: 'chatInputValue' }, context)
|
|
195
|
+
|
|
196
|
+
expect(result.value).toBe('chosen')
|
|
197
|
+
// The setter really ran; the ref write is an early mirror of it, not a
|
|
198
|
+
// substitute that would leave React holding something else.
|
|
199
|
+
expect(h.committed.chatInputValue).toBe('chosen')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('applies to a batch write too', async () => {
|
|
203
|
+
const h = await buildHarness()
|
|
204
|
+
const context = { __stateValues: {} }
|
|
205
|
+
|
|
206
|
+
await h.batchUpdate({ updates: [{ key: 'chatInputValue', value: 'batched' }] }, context)
|
|
207
|
+
const result = await h.read({ property: 'chatInputValue' }, context)
|
|
208
|
+
|
|
209
|
+
expect(result.value).toBe('batched')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('leaves a key the page does not own to the warning path', async () => {
|
|
213
|
+
const h = await buildHarness()
|
|
214
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
215
|
+
const context = { __stateValues: {} }
|
|
216
|
+
|
|
217
|
+
await h.update({ property: 'notOnThisPage', value: 'nope' }, context)
|
|
218
|
+
const result = await h.read({ property: 'notOnThisPage' }, context)
|
|
219
|
+
|
|
220
|
+
// No setter means no state write, so the ref must not claim otherwise.
|
|
221
|
+
expect(result.value).toBeUndefined()
|
|
222
|
+
expect(warn).toHaveBeenCalled()
|
|
223
|
+
warn.mockRestore()
|
|
224
|
+
})
|
|
225
|
+
})
|