@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.
Files changed (152) hide show
  1. package/__tests__/_helpers/abort-controller-stub.ts +64 -0
  2. package/__tests__/account-refresh-session.test.ts +110 -0
  3. package/__tests__/cache-nodes.test.ts +137 -0
  4. package/__tests__/cart-clear-db-mirror.test.ts +45 -0
  5. package/__tests__/cart-get-total-delivery-flags.test.ts +86 -0
  6. package/__tests__/cron-route-loop-templateparams.test.ts +15 -6
  7. package/__tests__/data-create-item-order-notification.test.ts +33 -0
  8. package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
  9. package/__tests__/invoice-delivery-fee.test.ts +155 -0
  10. package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
  11. package/__tests__/resolve-config-template-params.test.ts +174 -0
  12. package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
  13. package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
  14. package/__tests__/stock-decrement-audit.test.ts +77 -0
  15. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  16. package/dist/cjs/api-route-generator.js +0 -12
  17. package/dist/cjs/api-route-generator.js.map +1 -1
  18. package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
  19. package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
  20. package/dist/cjs/ecommerce/stock-decrement.js +72 -6
  21. package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
  22. package/dist/cjs/executor-generator.d.ts.map +1 -1
  23. package/dist/cjs/executor-generator.js +125 -26
  24. package/dist/cjs/executor-generator.js.map +1 -1
  25. package/dist/cjs/index.d.ts +1 -0
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +3 -1
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  30. package/dist/cjs/invoice/api-routes-code.js +31 -2
  31. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  32. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  33. package/dist/cjs/invoice/invoice-html-code.js +25 -4
  34. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  35. package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
  36. package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
  37. package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
  38. package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
  39. package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
  40. package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
  41. package/dist/cjs/nodes/cache/cache-get.js +39 -0
  42. package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
  43. package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
  44. package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
  45. package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
  46. package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
  47. package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
  48. package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
  49. package/dist/cjs/nodes/cache/cache-set.js +71 -0
  50. package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
  51. package/dist/cjs/nodes/cache/store.d.ts +29 -0
  52. package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
  53. package/dist/cjs/nodes/cache/store.js +83 -0
  54. package/dist/cjs/nodes/cache/store.js.map +1 -0
  55. package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
  56. package/dist/cjs/nodes/cart/cart-clear.js +40 -0
  57. package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
  58. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  59. package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
  60. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  61. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  62. package/dist/cjs/nodes/data/data-create-item.js +37 -6
  63. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  64. package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  65. package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
  66. package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
  67. package/dist/cjs/nodes/index.d.ts.map +1 -1
  68. package/dist/cjs/nodes/index.js +5 -1
  69. package/dist/cjs/nodes/index.js.map +1 -1
  70. package/dist/cjs/runtime-storage-generator.d.ts +18 -0
  71. package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
  72. package/dist/cjs/runtime-storage-generator.js +63 -3
  73. package/dist/cjs/runtime-storage-generator.js.map +1 -1
  74. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  75. package/dist/esm/api-route-generator.d.ts.map +1 -1
  76. package/dist/esm/api-route-generator.js +0 -12
  77. package/dist/esm/api-route-generator.js.map +1 -1
  78. package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
  79. package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
  80. package/dist/esm/ecommerce/stock-decrement.js +72 -6
  81. package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
  82. package/dist/esm/executor-generator.d.ts.map +1 -1
  83. package/dist/esm/executor-generator.js +125 -26
  84. package/dist/esm/executor-generator.js.map +1 -1
  85. package/dist/esm/index.d.ts +1 -0
  86. package/dist/esm/index.d.ts.map +1 -1
  87. package/dist/esm/index.js +1 -0
  88. package/dist/esm/index.js.map +1 -1
  89. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  90. package/dist/esm/invoice/api-routes-code.js +31 -2
  91. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  92. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  93. package/dist/esm/invoice/invoice-html-code.js +25 -4
  94. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  95. package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
  96. package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
  97. package/dist/esm/nodes/account/account-refresh-session.js +93 -0
  98. package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
  99. package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
  100. package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
  101. package/dist/esm/nodes/cache/cache-get.js +36 -0
  102. package/dist/esm/nodes/cache/cache-get.js.map +1 -0
  103. package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
  104. package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
  105. package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
  106. package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
  107. package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
  108. package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
  109. package/dist/esm/nodes/cache/cache-set.js +68 -0
  110. package/dist/esm/nodes/cache/cache-set.js.map +1 -0
  111. package/dist/esm/nodes/cache/store.d.ts +29 -0
  112. package/dist/esm/nodes/cache/store.d.ts.map +1 -0
  113. package/dist/esm/nodes/cache/store.js +80 -0
  114. package/dist/esm/nodes/cache/store.js.map +1 -0
  115. package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
  116. package/dist/esm/nodes/cart/cart-clear.js +40 -0
  117. package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
  118. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  119. package/dist/esm/nodes/cart/cart-get-total.js +16 -0
  120. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  121. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  122. package/dist/esm/nodes/data/data-create-item.js +37 -6
  123. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  124. package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  125. package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
  126. package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
  127. package/dist/esm/nodes/index.d.ts.map +1 -1
  128. package/dist/esm/nodes/index.js +5 -1
  129. package/dist/esm/nodes/index.js.map +1 -1
  130. package/dist/esm/runtime-storage-generator.d.ts +18 -0
  131. package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
  132. package/dist/esm/runtime-storage-generator.js +62 -2
  133. package/dist/esm/runtime-storage-generator.js.map +1 -1
  134. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  135. package/package.json +4 -4
  136. package/src/api-route-generator.ts +0 -12
  137. package/src/ecommerce/stock-decrement.ts +83 -11
  138. package/src/executor-generator.ts +125 -26
  139. package/src/index.ts +1 -0
  140. package/src/invoice/api-routes-code.ts +31 -2
  141. package/src/invoice/invoice-html-code.ts +25 -4
  142. package/src/nodes/account/account-refresh-session.ts +97 -0
  143. package/src/nodes/cache/cache-get.ts +38 -0
  144. package/src/nodes/cache/cache-invalidate.ts +95 -0
  145. package/src/nodes/cache/cache-set.ts +71 -0
  146. package/src/nodes/cache/store.ts +80 -0
  147. package/src/nodes/cart/cart-clear.ts +40 -0
  148. package/src/nodes/cart/cart-get-total.ts +18 -0
  149. package/src/nodes/data/data-create-item.ts +37 -6
  150. package/src/nodes/file-storage/file-storage-upload.ts +51 -6
  151. package/src/nodes/index.ts +8 -0
  152. 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
- // Both the top-level and loop-body execution paths apply the merge; the
127
- // email node lives in the loop body here.
128
- expect(route).toContain('utils.applyTemplateParams(bResolved.body, bResolved.templateParams)')
129
- expect(route).toContain(
130
- 'utils.applyTemplateParams(bResolved.subject, bResolved.templateParams)'
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
- json: async () => ({
44
- files: [
45
- { id: '1', name: 'a.png', url: 'https://storage.example/a.png' },
46
- { id: '2', name: 'b.txt', url: 'https://storage.example/b.txt' },
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
- ;(globalThis as any).fetch = fetchMock
51
- ;(globalThis as any).FormData = FormDataStub
52
- ;(globalThis as any).Blob = NodeBlob
53
- ;(globalThis as any).atob = (encoded: string) =>
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
- delete (globalThis as any).fetch
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 () => {