@teleporthq/teleport-plugin-next-workflows 0.43.54 → 0.43.57
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__/_helpers/abort-controller-stub.ts +64 -0
- package/__tests__/account-refresh-session.test.ts +110 -0
- package/__tests__/cache-nodes.test.ts +137 -0
- package/__tests__/cart-clear-db-mirror.test.ts +45 -0
- package/__tests__/cart-get-total-delivery-flags.test.ts +86 -0
- package/__tests__/cron-route-loop-templateparams.test.ts +15 -6
- package/__tests__/data-create-item-order-notification.test.ts +33 -0
- package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
- package/__tests__/invoice-delivery-fee.test.ts +155 -0
- package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
- package/__tests__/resolve-config-template-params.test.ts +174 -0
- package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
- package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
- package/__tests__/stock-decrement-audit.test.ts +77 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +0 -12
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.js +72 -6
- package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +125 -26
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +31 -2
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +25 -4
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
- package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.js +39 -0
- package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.js +71 -0
- package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
- package/dist/cjs/nodes/cache/store.d.ts +29 -0
- package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/store.js +83 -0
- package/dist/cjs/nodes/cache/store.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-clear.js +40 -0
- package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
- package/dist/cjs/nodes/cart/cart-get-total.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 +37 -6
- package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +5 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/runtime-storage-generator.d.ts +18 -0
- package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
- package/dist/cjs/runtime-storage-generator.js +63 -3
- package/dist/cjs/runtime-storage-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +0 -12
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.js +72 -6
- package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +125 -26
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +31 -2
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +25 -4
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-refresh-session.js +93 -0
- package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-get.js +36 -0
- package/dist/esm/nodes/cache/cache-get.js.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
- package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-set.js +68 -0
- package/dist/esm/nodes/cache/cache-set.js.map +1 -0
- package/dist/esm/nodes/cache/store.d.ts +29 -0
- package/dist/esm/nodes/cache/store.d.ts.map +1 -0
- package/dist/esm/nodes/cache/store.js +80 -0
- package/dist/esm/nodes/cache/store.js.map +1 -0
- package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-clear.js +40 -0
- package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.js +16 -0
- package/dist/esm/nodes/cart/cart-get-total.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 +37 -6
- package/dist/esm/nodes/data/data-create-item.js.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +5 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/runtime-storage-generator.d.ts +18 -0
- package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
- package/dist/esm/runtime-storage-generator.js +62 -2
- package/dist/esm/runtime-storage-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +0 -12
- package/src/ecommerce/stock-decrement.ts +83 -11
- package/src/executor-generator.ts +125 -26
- package/src/index.ts +1 -0
- package/src/invoice/api-routes-code.ts +31 -2
- package/src/invoice/invoice-html-code.ts +25 -4
- package/src/nodes/account/account-refresh-session.ts +97 -0
- package/src/nodes/cache/cache-get.ts +38 -0
- package/src/nodes/cache/cache-invalidate.ts +95 -0
- package/src/nodes/cache/cache-set.ts +71 -0
- package/src/nodes/cache/store.ts +80 -0
- package/src/nodes/cart/cart-clear.ts +40 -0
- package/src/nodes/cart/cart-get-total.ts +18 -0
- package/src/nodes/data/data-create-item.ts +37 -6
- package/src/nodes/file-storage/file-storage-upload.ts +51 -6
- package/src/nodes/index.ts +8 -0
- package/src/runtime-storage-generator.ts +63 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// jest 26's node sandbox exposes none of the browser globals the upload
|
|
2
|
+
// handlers use — no `fetch`, no `FormData`, no `Blob`, no `atob` and no
|
|
3
|
+
// `AbortController`. The first four have always been stubbed by the tests that
|
|
4
|
+
// need them; this is the fifth, added when `file-storage-upload` grew a timeout.
|
|
5
|
+
//
|
|
6
|
+
// It is a TEST-ONLY absence: `AbortController` has been available in every
|
|
7
|
+
// browser since 2017 and in Node since 14, which is why the generated handler
|
|
8
|
+
// uses it unguarded.
|
|
9
|
+
|
|
10
|
+
type AbortListener = () => void
|
|
11
|
+
|
|
12
|
+
class AbortSignalStub {
|
|
13
|
+
public aborted = false
|
|
14
|
+
public reason: unknown = undefined
|
|
15
|
+
private listeners: AbortListener[] = []
|
|
16
|
+
|
|
17
|
+
public addEventListener(type: string, listener: AbortListener): void {
|
|
18
|
+
if (type === 'abort') {
|
|
19
|
+
this.listeners.push(listener)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public removeEventListener(type: string, listener: AbortListener): void {
|
|
24
|
+
if (type === 'abort') {
|
|
25
|
+
this.listeners = this.listeners.filter((entry) => entry !== listener)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @internal — driven by the controller, never by the code under test. */
|
|
30
|
+
public dispatchAbort(reason: unknown): void {
|
|
31
|
+
if (this.aborted) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
this.aborted = true
|
|
35
|
+
this.reason = reason
|
|
36
|
+
for (const listener of this.listeners.slice()) {
|
|
37
|
+
listener()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AbortControllerStub {
|
|
43
|
+
public readonly signal = new AbortSignalStub()
|
|
44
|
+
|
|
45
|
+
public abort(reason?: unknown): void {
|
|
46
|
+
this.signal.dispatchAbort(reason)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The browser globals the file-storage upload handler expects to exist. */
|
|
51
|
+
export const installUploadGlobals = (overrides: Record<string, unknown> = {}): void => {
|
|
52
|
+
const globals = globalThis as Record<string, unknown>
|
|
53
|
+
globals.AbortController = AbortControllerStub
|
|
54
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
55
|
+
globals[name] = value
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const removeUploadGlobals = (names: string[]): void => {
|
|
60
|
+
const globals = globalThis as Record<string, unknown>
|
|
61
|
+
for (const name of ['AbortController', ...names]) {
|
|
62
|
+
delete globals[name]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
2
|
+
|
|
3
|
+
// After a profile save the NextAuth JWT still carries the PREVIOUS name/image:
|
|
4
|
+
// the generated jwt callback only re-reads the users row once per refresh
|
|
5
|
+
// interval unless next-auth passes `trigger: 'update'`, which only
|
|
6
|
+
// `useSession().update()` does. `account-refresh-session` is the node that
|
|
7
|
+
// calls it and then republishes the result to the two consumers that would
|
|
8
|
+
// otherwise keep the stale value: the `teleport_auth_user` localStorage mirror
|
|
9
|
+
// `account-get-current` falls back to, and GlobalContext's `currentUser` (which
|
|
10
|
+
// the account-menu avatar binds to) via `teleport:auth-user-changed`.
|
|
11
|
+
|
|
12
|
+
interface RefreshResult {
|
|
13
|
+
id?: string
|
|
14
|
+
image?: string
|
|
15
|
+
user: Record<string, unknown> | null
|
|
16
|
+
refreshed: boolean
|
|
17
|
+
error?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('account-refresh-session', () => {
|
|
21
|
+
const handler: HandlerFn = loadHandler('account-refresh-session')
|
|
22
|
+
let events: Array<{ type: string; detail: unknown }>
|
|
23
|
+
let stored: Record<string, string>
|
|
24
|
+
|
|
25
|
+
const installWindow = (refreshSession: unknown): void => {
|
|
26
|
+
events = []
|
|
27
|
+
stored = { teleport_auth_user: JSON.stringify({ id: 'u-1', image: 'https://cdn/old.png' }) }
|
|
28
|
+
;(globalThis as any).window = {
|
|
29
|
+
__teleportNextAuth: refreshSession === undefined ? {} : { refreshSession },
|
|
30
|
+
localStorage: {
|
|
31
|
+
getItem: (k: string) => (k in stored ? stored[k] : null),
|
|
32
|
+
setItem: (k: string, v: string) => {
|
|
33
|
+
stored[k] = v
|
|
34
|
+
},
|
|
35
|
+
removeItem: (k: string) => {
|
|
36
|
+
delete stored[k]
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
dispatchEvent: (event: { type: string; detail: unknown }) => events.push(event),
|
|
40
|
+
}
|
|
41
|
+
;(globalThis as any).CustomEvent = class {
|
|
42
|
+
public type: string
|
|
43
|
+
public detail: unknown
|
|
44
|
+
constructor(type: string, init?: { detail?: unknown }) {
|
|
45
|
+
this.type = type
|
|
46
|
+
this.detail = init ? init.detail : undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
delete (globalThis as any).window
|
|
53
|
+
delete (globalThis as any).CustomEvent
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('publishes the refreshed user to GlobalContext and the localStorage mirror', async () => {
|
|
57
|
+
const fresh = { id: 'u-1', name: 'Ada Lovelace', image: 'https://cdn/new.png', role: 'user' }
|
|
58
|
+
installWindow(async () => ({ user: fresh, expires: '2099-01-01' }))
|
|
59
|
+
|
|
60
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
61
|
+
|
|
62
|
+
expect(result.refreshed).toBe(true)
|
|
63
|
+
expect(result.user).toEqual(fresh)
|
|
64
|
+
// Flat fields too — same output contract as account-get-current.
|
|
65
|
+
expect(result.image).toBe('https://cdn/new.png')
|
|
66
|
+
expect(result.id).toBe('u-1')
|
|
67
|
+
expect(JSON.parse(stored.teleport_auth_user).image).toBe('https://cdn/new.png')
|
|
68
|
+
expect(events).toHaveLength(1)
|
|
69
|
+
expect(events[0].type).toBe('teleport:auth-user-changed')
|
|
70
|
+
expect(events[0].detail).toEqual({ user: fresh })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('never publishes a null user — that would sign the visitor out of the UI', async () => {
|
|
74
|
+
// `update()` resolves to undefined while the provider is still loading.
|
|
75
|
+
installWindow(async (): Promise<undefined> => undefined)
|
|
76
|
+
|
|
77
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
78
|
+
|
|
79
|
+
expect(result.refreshed).toBe(false)
|
|
80
|
+
expect(events).toHaveLength(0)
|
|
81
|
+
expect(JSON.parse(stored.teleport_auth_user).image).toBe('https://cdn/old.png')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('degrades quietly when the session bridge is unavailable', async () => {
|
|
85
|
+
installWindow(undefined)
|
|
86
|
+
|
|
87
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
88
|
+
|
|
89
|
+
expect(result.refreshed).toBe(false)
|
|
90
|
+
expect(events).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('never returns a string error key (that would abort the workflow)', async () => {
|
|
94
|
+
installWindow(async () => {
|
|
95
|
+
throw new Error('network down')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
99
|
+
|
|
100
|
+
expect(result.refreshed).toBe(false)
|
|
101
|
+
expect(typeof result.error).not.toBe('string')
|
|
102
|
+
expect(events).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is inert on the server', async () => {
|
|
106
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
107
|
+
expect(result.refreshed).toBe(false)
|
|
108
|
+
expect(result.user).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { nodeRegistry } from '../src'
|
|
2
|
+
|
|
3
|
+
const CACHE_NODES = ['cache-get', 'cache-set', 'cache-invalidate']
|
|
4
|
+
|
|
5
|
+
const emittedFor = (nodeType: string): string => {
|
|
6
|
+
const generator = nodeRegistry[nodeType]
|
|
7
|
+
let source = generator.generateHandler()
|
|
8
|
+
if (typeof generator.generateServerHandler === 'function') {
|
|
9
|
+
source += '\n' + generator.generateServerHandler()
|
|
10
|
+
}
|
|
11
|
+
return source
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors `isFatalNodeResult` in the emitted runtime: a node result carrying
|
|
16
|
+
* `success: false`, a string `error`, or `error: true` HALTS the workflow.
|
|
17
|
+
*/
|
|
18
|
+
const runHandler = async (nodeType: string, config: Record<string, unknown>, context = {}) => {
|
|
19
|
+
const source = nodeRegistry[nodeType].generateHandler()
|
|
20
|
+
const entry = nodeType.replace(/-/g, '_')
|
|
21
|
+
// tslint:disable-next-line:function-constructor
|
|
22
|
+
const factory = new Function('globalThis', 'window', 'fetch', `${source}; return ${entry};`)
|
|
23
|
+
const fn = factory({}, undefined, () => Promise.reject(new Error('offline')))
|
|
24
|
+
return fn(config, context)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('cache workflow nodes', () => {
|
|
28
|
+
it('registers all three', () => {
|
|
29
|
+
for (const nodeType of CACHE_NODES) {
|
|
30
|
+
expect(nodeRegistry[nodeType]).toBeDefined()
|
|
31
|
+
expect(nodeRegistry[nodeType].nodeType).toBe(nodeType)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `cache-invalidate` bumps the SHARED version row, which needs the server-only
|
|
37
|
+
* secret — and being server-side puts it in the same segment as the write it
|
|
38
|
+
* follows, so a CRUD workflow pays no extra round trip for it.
|
|
39
|
+
*/
|
|
40
|
+
it('runs the purge on the server and the read/write anywhere', () => {
|
|
41
|
+
expect(nodeRegistry['cache-invalidate'].executionEnv).toBe('server')
|
|
42
|
+
expect(nodeRegistry['cache-get'].executionEnv).toBe('universal')
|
|
43
|
+
expect(nodeRegistry['cache-set'].executionEnv).toBe('universal')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handlers ship as runtime source and are re-bundled by webpack, which
|
|
48
|
+
* rewrites `require(`. The browser must also never be handed the Postgres
|
|
49
|
+
* driver by way of the server cache runtime.
|
|
50
|
+
*/
|
|
51
|
+
it('never requires a module', () => {
|
|
52
|
+
for (const nodeType of CACHE_NODES) {
|
|
53
|
+
expect(emittedFor(nodeType)).not.toMatch(/(?<![\w.])require\s*\(/)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* THE regression that would abort an admin's save. The runtime treats
|
|
59
|
+
* `success: false` / a string `error` as FATAL, so a cache that could not be
|
|
60
|
+
* cleared would halt the workflow AFTER the row was already written — no
|
|
61
|
+
* success toast, no navigation, and an error for an action that worked.
|
|
62
|
+
*/
|
|
63
|
+
describe('never reports failure in a shape the runtime treats as fatal', () => {
|
|
64
|
+
it('cache-invalidate without a configured scope', async () => {
|
|
65
|
+
const result = await runHandler('cache-invalidate', {})
|
|
66
|
+
expect(result.cleared).toBe(false)
|
|
67
|
+
expect(result.success).toBeUndefined()
|
|
68
|
+
expect(typeof result.error).not.toBe('string')
|
|
69
|
+
expect(result.error).not.toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('cache-invalidate with no secret configured', async () => {
|
|
73
|
+
const result = await runHandler('cache-invalidate', { scope: 'ds:products' })
|
|
74
|
+
expect(result.cleared).toBe(false)
|
|
75
|
+
expect(result.warning).toContain('TQ_CACHE_SECRET')
|
|
76
|
+
expect(result.success).toBeUndefined()
|
|
77
|
+
expect(typeof result.error).not.toBe('string')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('cache-set with no scope', async () => {
|
|
81
|
+
const result = await runHandler('cache-set', { key: 'k', value: 1 })
|
|
82
|
+
expect(result.stored).toBe(false)
|
|
83
|
+
expect(result.success).toBeUndefined()
|
|
84
|
+
expect(typeof result.error).not.toBe('string')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('cache-get reports a miss without any failure marker', async () => {
|
|
88
|
+
const result = await runHandler('cache-get', { scope: 'ds:products', key: 'missing' })
|
|
89
|
+
expect(result.hit).toBe(false)
|
|
90
|
+
expect(result.success).toBeUndefined()
|
|
91
|
+
expect(typeof result.error).not.toBe('string')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('round-trips a value through the workflow cache', async () => {
|
|
96
|
+
const shared = {}
|
|
97
|
+
const set = new Function(
|
|
98
|
+
'globalThis',
|
|
99
|
+
'window',
|
|
100
|
+
`${nodeRegistry['cache-set'].generateHandler()}; return cache_set;`
|
|
101
|
+
)(shared, undefined)
|
|
102
|
+
const get = new Function(
|
|
103
|
+
'globalThis',
|
|
104
|
+
'window',
|
|
105
|
+
`${nodeRegistry['cache-get'].generateHandler()}; return cache_get;`
|
|
106
|
+
)(shared, undefined)
|
|
107
|
+
|
|
108
|
+
await set({ scope: 'rates', key: 'EUR', value: { rate: 1.1 }, ttlSeconds: 60 }, {})
|
|
109
|
+
const hit = await get({ scope: 'rates', key: 'EUR' }, {})
|
|
110
|
+
|
|
111
|
+
expect(hit.hit).toBe(true)
|
|
112
|
+
expect(hit.value).toEqual({ rate: 1.1 })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reports a miss once the entry has expired', async () => {
|
|
116
|
+
const shared = {}
|
|
117
|
+
const set = new Function(
|
|
118
|
+
'globalThis',
|
|
119
|
+
'window',
|
|
120
|
+
`${nodeRegistry['cache-set'].generateHandler()}; return cache_set;`
|
|
121
|
+
)(shared, undefined)
|
|
122
|
+
const get = new Function(
|
|
123
|
+
'globalThis',
|
|
124
|
+
'window',
|
|
125
|
+
`${nodeRegistry['cache-get'].generateHandler()}; return cache_get;`
|
|
126
|
+
)(shared, undefined)
|
|
127
|
+
|
|
128
|
+
await set({ scope: 'rates', key: 'EUR', value: 1, ttlSeconds: 60 }, {})
|
|
129
|
+
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
const spy = jest.spyOn(Date, 'now').mockReturnValue(now + 61_000)
|
|
132
|
+
const miss = await get({ scope: 'rates', key: 'EUR' }, {})
|
|
133
|
+
spy.mockRestore()
|
|
134
|
+
|
|
135
|
+
expect(miss.hit).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cartClear } from '../src/nodes/cart/cart-clear'
|
|
2
|
+
|
|
3
|
+
// Regression: after a cash-on-delivery order the buyer's cart came back.
|
|
4
|
+
//
|
|
5
|
+
// `cart-clear` only emptied `localStorage.workflow_cart`. The database copy
|
|
6
|
+
// (`teleport_cart` / `teleport_cart_items`) stayed active, and
|
|
7
|
+
// `EcommerceProvider` re-hydrates from `/api/cart/load` on any mount where the
|
|
8
|
+
// local cart is empty — so the very next full page load (the post-order
|
|
9
|
+
// redirect, or a plain refresh) restored the cart the shopper had just ordered
|
|
10
|
+
// and wrote it straight back into localStorage.
|
|
11
|
+
|
|
12
|
+
describe('cart-clear mirrors the empty cart into the database cart', () => {
|
|
13
|
+
const handlerCode = cartClear.generateHandler()
|
|
14
|
+
|
|
15
|
+
it('still clears localStorage first — the DB mirror is best-effort on top', () => {
|
|
16
|
+
expect(handlerCode).toContain("localStorage.setItem('workflow_cart'")
|
|
17
|
+
expect(handlerCode).toContain("dispatchEvent(new CustomEvent('teleport:cart-changed'))")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('pushes an empty cart through /api/cart/sync with the guest session id', () => {
|
|
21
|
+
expect(handlerCode).toContain("'/api/cart/sync'")
|
|
22
|
+
expect(handlerCode).toContain("localStorage.getItem('workflow_cart_session_id')")
|
|
23
|
+
expect(handlerCode).toMatch(/items:\s*\[\s*\]/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('never awaits the sync and never lets it fail the node', () => {
|
|
27
|
+
// The route only exists for Postgres datasources, so a 404 here is a
|
|
28
|
+
// normal outcome — it must not surface to the shopper or abort the
|
|
29
|
+
// workflow (which would strand the order mid-chain).
|
|
30
|
+
const syncSection = handlerCode.slice(handlerCode.indexOf("'/api/cart/sync'"))
|
|
31
|
+
expect(syncSection).not.toMatch(/await\s+fetch/)
|
|
32
|
+
expect(syncSection).toContain('.catch(')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does NOT mark the cart as ordered — this handler also backs "Clear cart"', () => {
|
|
36
|
+
// `mark-ordered` is the checkout-only signal and stays owned by
|
|
37
|
+
// data-create-item. Flipping the status here would mislabel a cart the
|
|
38
|
+
// shopper simply emptied by hand.
|
|
39
|
+
expect(handlerCode).not.toContain('mark-ordered')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('stamps the clear so the provider can tell it apart from a first visit', () => {
|
|
43
|
+
expect(handlerCode).toContain("localStorage.setItem('workflow_cart_cleared_at'")
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* tslint:disable:no-eval */
|
|
2
|
+
import { cartGetTotal } from '../src/nodes/cart/cart-get-total'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `cart-get-total` is the ONLY channel by which the place-order workflow learns
|
|
6
|
+
* the merchant's CURRENT delivery settings: the workflow's assemble script is
|
|
7
|
+
* baked once, when the checkout page is built, while `EcommerceProvider`
|
|
8
|
+
* re-writes `workflow_cart_settings` on every page load.
|
|
9
|
+
*
|
|
10
|
+
* Two of those settings decide whether a delivery fee may be charged at all —
|
|
11
|
+
* whether the store delivers, and whether it offers store pickup — so they have
|
|
12
|
+
* to survive the trip. And they have to survive it as `undefined` when the
|
|
13
|
+
* storefront predates them: reading a missing flag as `false` would silently
|
|
14
|
+
* stop charging delivery on a store that does deliver.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function evalHandler(code: string): any {
|
|
18
|
+
return eval('(' + code + ')')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function withStoredSettings(settings: Record<string, unknown> | null): void {
|
|
22
|
+
const store: Record<string, string> = {
|
|
23
|
+
workflow_cart: JSON.stringify([{ price: 100, quantity: 1 }]),
|
|
24
|
+
}
|
|
25
|
+
if (settings) {
|
|
26
|
+
store.workflow_cart_settings = JSON.stringify(settings)
|
|
27
|
+
}
|
|
28
|
+
;(global as any).localStorage = {
|
|
29
|
+
getItem: (key: string) => (key in store ? store[key] : null),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const runHandler = async () => evalHandler(cartGetTotal.generateHandler())()
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
delete (global as any).localStorage
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('cart-get-total — fulfilment flags', () => {
|
|
40
|
+
it('surfaces deliveryEnabled and storePickupEnabled from the stored snapshot', async () => {
|
|
41
|
+
withStoredSettings({
|
|
42
|
+
deliveryConfig: {
|
|
43
|
+
deliveryPrice: 10,
|
|
44
|
+
freeDeliveryEnabled: false,
|
|
45
|
+
freeDeliveryThreshold: 0,
|
|
46
|
+
deliveryEnabled: false,
|
|
47
|
+
storePickupEnabled: true,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const out = await runHandler()
|
|
52
|
+
|
|
53
|
+
expect(out.deliveryConfig).toEqual({
|
|
54
|
+
deliveryPrice: 10,
|
|
55
|
+
freeDeliveryEnabled: false,
|
|
56
|
+
freeDeliveryThreshold: 0,
|
|
57
|
+
deliveryEnabled: false,
|
|
58
|
+
storePickupEnabled: true,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves the flags absent when the storefront predates them', async () => {
|
|
63
|
+
// The assemble script distinguishes absent from false and falls back to its
|
|
64
|
+
// own build-time snapshot — asserting the keys are missing, not `false`.
|
|
65
|
+
withStoredSettings({
|
|
66
|
+
deliveryConfig: {
|
|
67
|
+
deliveryPrice: 10,
|
|
68
|
+
freeDeliveryEnabled: false,
|
|
69
|
+
freeDeliveryThreshold: 0,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const out = await runHandler()
|
|
74
|
+
|
|
75
|
+
expect('deliveryEnabled' in out.deliveryConfig).toBe(false)
|
|
76
|
+
expect('storePickupEnabled' in out.deliveryConfig).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns a null deliveryConfig when nothing is stored at all', async () => {
|
|
80
|
+
withStoredSettings(null)
|
|
81
|
+
|
|
82
|
+
const out = await runHandler()
|
|
83
|
+
|
|
84
|
+
expect(out.deliveryConfig).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// into its component body — the cron route previously used a naive inline loop
|
|
5
5
|
// that did neither, so a per-cart reminder email would ship raw {{token}} merge
|
|
6
6
|
// fields and its loop body would never run.
|
|
7
|
+
import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
|
|
7
8
|
import { generateCronAPIRoute } from '../src/api-route-generator'
|
|
8
9
|
|
|
9
10
|
const TRIGGER_ID = 'cron-1'
|
|
@@ -123,12 +124,20 @@ describe('generateCronAPIRoute — loop + templateParams support', () => {
|
|
|
123
124
|
})
|
|
124
125
|
|
|
125
126
|
it('merges templateParams into email body + subject', () => {
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
// The merge lives in the SHARED runtime's `resolveConfig`, not pasted at
|
|
128
|
+
// each call site. That is the whole point: `resolveConfig` is the single
|
|
129
|
+
// function every execution path — main loop, loop body, parallel branch,
|
|
130
|
+
// error branch, across all four route generators and the client executor —
|
|
131
|
+
// funnels a node config through. Pasting it per site is what left the main
|
|
132
|
+
// loop of `generateServerSegmentAPIRoute` (where every ordinary page
|
|
133
|
+
// workflow's email node runs) without it, shipping literal `{{token}}`s.
|
|
134
|
+
const runtime = generateSharedRuntimeUtilsCode()
|
|
135
|
+
expect(runtime).toContain('applyTemplateParams(resolved.body, resolved.templateParams)')
|
|
136
|
+
expect(runtime).toContain('applyTemplateParams(resolved.subject, resolved.templateParams)')
|
|
137
|
+
// …and the route reaches it simply by calling resolveConfig.
|
|
138
|
+
expect(route).toContain('const resolveConfig = utils.resolveConfig')
|
|
139
|
+
// No stale per-site copy left behind to drift out of sync.
|
|
140
|
+
expect(route).not.toContain('utils.applyTemplateParams(')
|
|
132
141
|
})
|
|
133
142
|
|
|
134
143
|
it('bakes the scan + loop + email nodes into the server WORKFLOW_CONFIG', () => {
|
|
@@ -106,3 +106,36 @@ describe('data_create_item — order-notification piggyback payload', () => {
|
|
|
106
106
|
// cover the contract (which fields are read, where they default,
|
|
107
107
|
// fire-and-forget shape, etc.). The full integration is exercised
|
|
108
108
|
// by the existing end-to-end test in the dist project.
|
|
109
|
+
|
|
110
|
+
describe('data-create-item stands down when the workflow owns the send', () => {
|
|
111
|
+
const suppressionHandlerCode = dataCreateItem.generateHandler()
|
|
112
|
+
|
|
113
|
+
it('gates the auto-fire on the builder-set suppressOrderNotification flag', () => {
|
|
114
|
+
// The place-order workflow sets this on its create-order node whenever it
|
|
115
|
+
// also carries a "Send Order-Notification Email" node. Without the gate
|
|
116
|
+
// the merchant receives the SAME order twice — once from here with a NULL
|
|
117
|
+
// order_number (rendered as the raw UUID) and a flat-filled body, once
|
|
118
|
+
// from the workflow with the real order number and the fully expanded
|
|
119
|
+
// template.
|
|
120
|
+
expect(suppressionHandlerCode).toContain('suppressOrderNotification')
|
|
121
|
+
expect(suppressionHandlerCode).toMatch(/suppressOrderNotification\s*!==\s*true/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps firing when the flag is absent, so exported projects are unaffected', () => {
|
|
125
|
+
// `!== true` (not a truthiness check) is what makes "flag missing" mean
|
|
126
|
+
// "behave exactly as before".
|
|
127
|
+
expect(suppressionHandlerCode).not.toMatch(/config\.suppressOrderNotification\s*\)/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('sends both the {{itemsList}} and the array-mapper row keys per item', () => {
|
|
131
|
+
// One payload feeds the endpoint's own <ul> builder AND a builder email
|
|
132
|
+
// template's `tq:each` row block, so neither renderer needs to know which
|
|
133
|
+
// template style the merchant is on.
|
|
134
|
+
for (const key of ['product_name', 'unit_price', 'line_total', 'image_url']) {
|
|
135
|
+
expect(suppressionHandlerCode).toContain(key)
|
|
136
|
+
}
|
|
137
|
+
expect(suppressionHandlerCode).toMatch(
|
|
138
|
+
/it\.image \|\| it\.image_url \|\| it\.imageUrl \|\| it\.thumbnail/
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Blob as NodeBlob } from 'buffer'
|
|
9
9
|
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
10
|
+
import { installUploadGlobals, removeUploadGlobals } from './_helpers/abort-controller-stub'
|
|
10
11
|
|
|
11
12
|
// jest 26's node sandbox exposes none of the browser upload globals the
|
|
12
13
|
// handler uses (FormData/Blob/atob/fetch), so the test installs minimal
|
|
@@ -40,25 +41,27 @@ describe('file-storage-upload: PickedFile array from state', () => {
|
|
|
40
41
|
handler = loadHandler('file-storage-upload')
|
|
41
42
|
fetchMock = jest.fn(async () => ({
|
|
42
43
|
ok: true,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
status: 200,
|
|
45
|
+
// The handler reads the body as TEXT and parses it itself, so a gateway
|
|
46
|
+
// error page cannot masquerade as a JSON parse failure.
|
|
47
|
+
text: async () =>
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
files: [
|
|
50
|
+
{ id: '1', name: 'a.png', url: 'https://storage.example/a.png' },
|
|
51
|
+
{ id: '2', name: 'b.txt', url: 'https://storage.example/b.txt' },
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
49
54
|
}))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Buffer.from(encoded, 'base64').toString('binary')
|
|
55
|
+
installUploadGlobals({
|
|
56
|
+
fetch: fetchMock,
|
|
57
|
+
FormData: FormDataStub,
|
|
58
|
+
Blob: NodeBlob,
|
|
59
|
+
atob: (encoded: string) => Buffer.from(encoded, 'base64').toString('binary'),
|
|
60
|
+
})
|
|
55
61
|
})
|
|
56
62
|
|
|
57
63
|
afterEach(() => {
|
|
58
|
-
|
|
59
|
-
delete (globalThis as any).FormData
|
|
60
|
-
delete (globalThis as any).Blob
|
|
61
|
-
delete (globalThis as any).atob
|
|
64
|
+
removeUploadGlobals(['fetch', 'FormData', 'Blob', 'atob'])
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
it('converts every PickedFile dataURL to a Blob and appends all of them', async () => {
|