@teleporthq/teleport-plugin-next-workflows 0.43.53 → 0.43.55

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 (174) hide show
  1. package/__tests__/ai-chat-rag-sql-parameterization.test.ts +237 -0
  2. package/__tests__/ai-embedding-optional.test.ts +77 -0
  3. package/__tests__/ai-model-capabilities.test.ts +234 -0
  4. package/__tests__/ai-provider-coverage.test.ts +183 -0
  5. package/__tests__/cart-clear-db-mirror.test.ts +45 -0
  6. package/__tests__/cart-get-total-storefront-tax.test.ts +165 -0
  7. package/__tests__/cron-route-loop-templateparams.test.ts +15 -6
  8. package/__tests__/data-create-item-order-notification.test.ts +33 -0
  9. package/__tests__/ecommerce-customhandler-rewriter.test.ts +1 -1
  10. package/__tests__/internal-request-headers.test.ts +159 -0
  11. package/__tests__/order-details-route.test.ts +238 -0
  12. package/__tests__/resolve-config-template-params.test.ts +174 -0
  13. package/__tests__/utility-nodes.test.ts +27 -11
  14. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  15. package/dist/cjs/api-route-generator.js +27 -15
  16. package/dist/cjs/api-route-generator.js.map +1 -1
  17. package/dist/cjs/ecommerce/order-details-route.d.ts +18 -0
  18. package/dist/cjs/ecommerce/order-details-route.d.ts.map +1 -0
  19. package/dist/cjs/ecommerce/order-details-route.js +172 -0
  20. package/dist/cjs/ecommerce/order-details-route.js.map +1 -0
  21. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +1 -1
  22. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  23. package/dist/cjs/ecommerce-customhandler-rewriter.js +20 -4
  24. package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
  25. package/dist/cjs/executor-generator.d.ts.map +1 -1
  26. package/dist/cjs/executor-generator.js +69 -11
  27. package/dist/cjs/executor-generator.js.map +1 -1
  28. package/dist/cjs/nodes/ai/ai-custom-prompt.js +1 -1
  29. package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
  30. package/dist/cjs/nodes/ai/ai-detect-language.js +1 -1
  31. package/dist/cjs/nodes/ai/ai-detect-language.js.map +1 -1
  32. package/dist/cjs/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
  33. package/dist/cjs/nodes/ai/ai-generate-text-embedding.js +47 -4
  34. package/dist/cjs/nodes/ai/ai-generate-text-embedding.js.map +1 -1
  35. package/dist/cjs/nodes/ai/ai-provider-utils.d.ts +2 -0
  36. package/dist/cjs/nodes/ai/ai-provider-utils.d.ts.map +1 -1
  37. package/dist/cjs/nodes/ai/ai-provider-utils.js +195 -106
  38. package/dist/cjs/nodes/ai/ai-provider-utils.js.map +1 -1
  39. package/dist/cjs/nodes/ai/ai-sentiment-analysis.js +1 -1
  40. package/dist/cjs/nodes/ai/ai-sentiment-analysis.js.map +1 -1
  41. package/dist/cjs/nodes/ai/ai-summarization.js +1 -1
  42. package/dist/cjs/nodes/ai/ai-summarization.js.map +1 -1
  43. package/dist/cjs/nodes/ai/ai-text-classifier.js +1 -1
  44. package/dist/cjs/nodes/ai/ai-text-classifier.js.map +1 -1
  45. package/dist/cjs/nodes/ai/ai-text-transform.js +1 -1
  46. package/dist/cjs/nodes/ai/ai-text-transform.js.map +1 -1
  47. package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
  48. package/dist/cjs/nodes/cart/cart-clear.js +40 -0
  49. package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
  50. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  51. package/dist/cjs/nodes/cart/cart-get-total.js +56 -15
  52. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  53. package/dist/cjs/nodes/data/data-count.d.ts.map +1 -1
  54. package/dist/cjs/nodes/data/data-count.js +6 -1
  55. package/dist/cjs/nodes/data/data-count.js.map +1 -1
  56. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  57. package/dist/cjs/nodes/data/data-create-item.js +45 -9
  58. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  59. package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
  60. package/dist/cjs/nodes/data/data-delete-item.js +7 -4
  61. package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
  62. package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
  63. package/dist/cjs/nodes/data/data-raw-query.js +7 -1
  64. package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
  65. package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
  66. package/dist/cjs/nodes/data/data-select.js +6 -1
  67. package/dist/cjs/nodes/data/data-select.js.map +1 -1
  68. package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
  69. package/dist/cjs/nodes/data/data-update-item.js +7 -4
  70. package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
  71. package/dist/cjs/nodes/utility/utility-hybrid-search.js +2 -2
  72. package/dist/cjs/nodes/utility/utility-hybrid-search.js.map +1 -1
  73. package/dist/cjs/nodes/utility/utility-semantic-search.js +1 -1
  74. package/dist/cjs/nodes/utility/utility-semantic-search.js.map +1 -1
  75. package/dist/cjs/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
  76. package/dist/cjs/nodes/utility/utility-similarity-scoring.js +49 -3
  77. package/dist/cjs/nodes/utility/utility-similarity-scoring.js.map +1 -1
  78. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  79. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  80. package/dist/cjs/workflow-project-plugin.js +10 -4
  81. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  82. package/dist/esm/api-route-generator.d.ts.map +1 -1
  83. package/dist/esm/api-route-generator.js +27 -15
  84. package/dist/esm/api-route-generator.js.map +1 -1
  85. package/dist/esm/ecommerce/order-details-route.d.ts +18 -0
  86. package/dist/esm/ecommerce/order-details-route.d.ts.map +1 -0
  87. package/dist/esm/ecommerce/order-details-route.js +167 -0
  88. package/dist/esm/ecommerce/order-details-route.js.map +1 -0
  89. package/dist/esm/ecommerce-customhandler-rewriter.d.ts +1 -1
  90. package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  91. package/dist/esm/ecommerce-customhandler-rewriter.js +20 -4
  92. package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
  93. package/dist/esm/executor-generator.d.ts.map +1 -1
  94. package/dist/esm/executor-generator.js +69 -11
  95. package/dist/esm/executor-generator.js.map +1 -1
  96. package/dist/esm/nodes/ai/ai-custom-prompt.js +1 -1
  97. package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
  98. package/dist/esm/nodes/ai/ai-detect-language.js +1 -1
  99. package/dist/esm/nodes/ai/ai-detect-language.js.map +1 -1
  100. package/dist/esm/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
  101. package/dist/esm/nodes/ai/ai-generate-text-embedding.js +47 -4
  102. package/dist/esm/nodes/ai/ai-generate-text-embedding.js.map +1 -1
  103. package/dist/esm/nodes/ai/ai-provider-utils.d.ts +2 -0
  104. package/dist/esm/nodes/ai/ai-provider-utils.d.ts.map +1 -1
  105. package/dist/esm/nodes/ai/ai-provider-utils.js +194 -105
  106. package/dist/esm/nodes/ai/ai-provider-utils.js.map +1 -1
  107. package/dist/esm/nodes/ai/ai-sentiment-analysis.js +1 -1
  108. package/dist/esm/nodes/ai/ai-sentiment-analysis.js.map +1 -1
  109. package/dist/esm/nodes/ai/ai-summarization.js +1 -1
  110. package/dist/esm/nodes/ai/ai-summarization.js.map +1 -1
  111. package/dist/esm/nodes/ai/ai-text-classifier.js +1 -1
  112. package/dist/esm/nodes/ai/ai-text-classifier.js.map +1 -1
  113. package/dist/esm/nodes/ai/ai-text-transform.js +1 -1
  114. package/dist/esm/nodes/ai/ai-text-transform.js.map +1 -1
  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 +56 -15
  120. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  121. package/dist/esm/nodes/data/data-count.d.ts.map +1 -1
  122. package/dist/esm/nodes/data/data-count.js +6 -1
  123. package/dist/esm/nodes/data/data-count.js.map +1 -1
  124. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  125. package/dist/esm/nodes/data/data-create-item.js +45 -9
  126. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  127. package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
  128. package/dist/esm/nodes/data/data-delete-item.js +7 -4
  129. package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
  130. package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
  131. package/dist/esm/nodes/data/data-raw-query.js +7 -1
  132. package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
  133. package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
  134. package/dist/esm/nodes/data/data-select.js +6 -1
  135. package/dist/esm/nodes/data/data-select.js.map +1 -1
  136. package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
  137. package/dist/esm/nodes/data/data-update-item.js +7 -4
  138. package/dist/esm/nodes/data/data-update-item.js.map +1 -1
  139. package/dist/esm/nodes/utility/utility-hybrid-search.js +2 -2
  140. package/dist/esm/nodes/utility/utility-hybrid-search.js.map +1 -1
  141. package/dist/esm/nodes/utility/utility-semantic-search.js +1 -1
  142. package/dist/esm/nodes/utility/utility-semantic-search.js.map +1 -1
  143. package/dist/esm/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
  144. package/dist/esm/nodes/utility/utility-similarity-scoring.js +49 -3
  145. package/dist/esm/nodes/utility/utility-similarity-scoring.js.map +1 -1
  146. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  147. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  148. package/dist/esm/workflow-project-plugin.js +10 -4
  149. package/dist/esm/workflow-project-plugin.js.map +1 -1
  150. package/package.json +4 -4
  151. package/src/api-route-generator.ts +27 -15
  152. package/src/ecommerce/order-details-route.ts +195 -0
  153. package/src/ecommerce-customhandler-rewriter.ts +24 -4
  154. package/src/executor-generator.ts +69 -11
  155. package/src/nodes/ai/ai-custom-prompt.ts +2 -2
  156. package/src/nodes/ai/ai-detect-language.ts +2 -2
  157. package/src/nodes/ai/ai-generate-text-embedding.ts +49 -4
  158. package/src/nodes/ai/ai-provider-utils.ts +216 -106
  159. package/src/nodes/ai/ai-sentiment-analysis.ts +2 -2
  160. package/src/nodes/ai/ai-summarization.ts +2 -2
  161. package/src/nodes/ai/ai-text-classifier.ts +2 -2
  162. package/src/nodes/ai/ai-text-transform.ts +2 -2
  163. package/src/nodes/cart/cart-clear.ts +40 -0
  164. package/src/nodes/cart/cart-get-total.ts +61 -17
  165. package/src/nodes/data/data-count.ts +6 -1
  166. package/src/nodes/data/data-create-item.ts +45 -9
  167. package/src/nodes/data/data-delete-item.ts +6 -0
  168. package/src/nodes/data/data-raw-query.ts +8 -1
  169. package/src/nodes/data/data-select.ts +6 -1
  170. package/src/nodes/data/data-update-item.ts +6 -0
  171. package/src/nodes/utility/utility-hybrid-search.ts +2 -2
  172. package/src/nodes/utility/utility-semantic-search.ts +1 -1
  173. package/src/nodes/utility/utility-similarity-scoring.ts +56 -3
  174. package/src/workflow-project-plugin.ts +10 -4
@@ -0,0 +1,183 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import { generateAIStreamingProviderUtils } from '../src/nodes/ai/ai-provider-utils'
4
+ import { nodeRegistry } from '../src/nodes'
5
+
6
+ /**
7
+ * Answers "if a user picks any provider/model in the editor, does the generated
8
+ * app actually call it correctly?"
9
+ *
10
+ * Two independent guarantees are needed:
11
+ * 1. every provider the editor can emit reaches a working branch, in BOTH the
12
+ * streaming and non-streaming paths;
13
+ * 2. every SDK those branches `require()` is declared as a dependency, or the
14
+ * generated project fails at import time rather than at request time.
15
+ */
16
+
17
+ const EDITOR_PROVIDERS = [
18
+ 'openai',
19
+ 'anthropic',
20
+ 'google',
21
+ 'cohere',
22
+ 'mistral',
23
+ 'meta',
24
+ 'perplexity',
25
+ ] as const
26
+
27
+ /** SDK a provider's branch loads. Providers absent here go through OpenAI's. */
28
+ const PROVIDER_SDK: Record<string, string> = {
29
+ openai: 'openai',
30
+ anthropic: '@anthropic-ai/sdk',
31
+ google: '@google/generative-ai',
32
+ cohere: 'cohere-ai',
33
+ mistral: '@mistralai/mistralai',
34
+ meta: 'openai',
35
+ perplexity: 'openai',
36
+ }
37
+
38
+ function loadUtils() {
39
+ const source = generateAIStreamingProviderUtils()
40
+ // eslint-disable-next-line no-new-func
41
+ return new Function(
42
+ `${source}
43
+ return {
44
+ resolveProvider: __ai_resolveProvider,
45
+ providerBaseURL: __ai_providerBaseURL,
46
+ modelCapabilities: __ai_modelCapabilities,
47
+ };`
48
+ )() as {
49
+ resolveProvider: (config: unknown) => string
50
+ providerBaseURL: (provider: string) => string | undefined
51
+ modelCapabilities: (provider: string, model: string, maxTokens?: number) => any
52
+ }
53
+ }
54
+
55
+ /** Model ids the editor offers, read from the GUI catalogue when it is a sibling checkout. */
56
+ function readEditorCatalogue(): Array<[string, string]> | null {
57
+ const catalogue = path.resolve(
58
+ __dirname,
59
+ '../../../../teleport-gui/apps/gui/app/project-page/features/workflows/constants/ai-providers/catalog.ts'
60
+ )
61
+ if (!fs.existsSync(catalogue)) {
62
+ return null
63
+ }
64
+ const source = fs.readFileSync(catalogue, 'utf8')
65
+ const models: Array<[string, string]> = []
66
+ const blockRe = /\{\s*\n\s*id: '([^']+)',([\s\S]*?)\n {6}\}/g
67
+ let match = blockRe.exec(source)
68
+ while (match !== null) {
69
+ const provider = /provider: '([^']+)'/.exec(match[2])
70
+ if (provider) {
71
+ models.push([match[1], provider[1]])
72
+ }
73
+ match = blockRe.exec(source)
74
+ }
75
+ return models.length > 0 ? models : null
76
+ }
77
+
78
+ describe('every editor provider reaches a working branch', () => {
79
+ const utils = loadUtils()
80
+ const source = generateAIStreamingProviderUtils()
81
+ const nonStreaming = source.slice(source.indexOf('function __ai_callProvider'))
82
+ const streaming = source.slice(source.indexOf('__ai_callProviderStreaming'))
83
+
84
+ it.each(EDITOR_PROVIDERS)('%s resolves to itself when stored explicitly', (provider) => {
85
+ expect(utils.resolveProvider({ provider, model: 'anything' })).toBe(provider)
86
+ })
87
+
88
+ it.each(EDITOR_PROVIDERS)('%s is handled in both call paths', (provider) => {
89
+ const hasExplicit = (body: string) => body.includes(`provider === '${provider}'`)
90
+ // A provider without its own branch must be OpenAI-compatible, and then it
91
+ // needs a base URL unless it IS OpenAI — otherwise it would silently post
92
+ // to api.openai.com with someone else's key.
93
+ for (const [label, body] of [
94
+ ['non-streaming', nonStreaming],
95
+ ['streaming', streaming],
96
+ ] as const) {
97
+ if (!hasExplicit(body)) {
98
+ const baseURL = utils.providerBaseURL(provider)
99
+ const reachable = provider === 'openai' || !!baseURL
100
+ if (!reachable) {
101
+ throw new Error(`${provider} has no ${label} branch and no base URL`)
102
+ }
103
+ }
104
+ }
105
+ })
106
+
107
+ it('routes the OpenAI-compatible providers to their own hosts', () => {
108
+ expect(utils.providerBaseURL('meta')).toBe('https://api.together.xyz/v1')
109
+ expect(utils.providerBaseURL('perplexity')).toBe('https://api.perplexity.ai')
110
+ expect(utils.providerBaseURL('openai')).toBeUndefined()
111
+ })
112
+ })
113
+
114
+ describe('every provider SDK is declared as a dependency', () => {
115
+ // The provider is a runtime value, so an AI node must declare every SDK any
116
+ // branch could load — not just the one selected when the project was built.
117
+ const aiNodeTypes = Object.keys(nodeRegistry).filter(
118
+ (nodeType) => nodeType.startsWith('ai-') && nodeType !== 'ai-generate-text-embedding'
119
+ )
120
+
121
+ it('covers all AI chat nodes', () => {
122
+ expect(aiNodeTypes.length).toBeGreaterThanOrEqual(6)
123
+ })
124
+
125
+ it.each(aiNodeTypes)('%s declares every provider SDK', (nodeType) => {
126
+ const declared = nodeRegistry[nodeType].dependencies || {}
127
+ const missing = EDITOR_PROVIDERS.filter(
128
+ (provider) => !Object.keys(declared).includes(PROVIDER_SDK[provider])
129
+ )
130
+ expect(missing).toEqual([])
131
+ })
132
+
133
+ it('embedding node needs only the OpenAI SDK, since embeddings are OpenAI-only', () => {
134
+ const declared = nodeRegistry['ai-generate-text-embedding'].dependencies || {}
135
+ expect(Object.keys(declared)).toEqual(['openai'])
136
+ })
137
+
138
+ it('similarity scoring declares the OpenAI SDK for its semantic algorithm', () => {
139
+ const declared = nodeRegistry['utility-similarity-scoring'].dependencies || {}
140
+ expect(Object.keys(declared)).toContain('openai')
141
+ })
142
+ })
143
+
144
+ describe('the editor catalogue routes correctly end to end', () => {
145
+ const catalogue = readEditorCatalogue()
146
+ const utils = loadUtils()
147
+
148
+ it('finds the GUI catalogue', () => {
149
+ // Skips gracefully in a packaged release where the sibling checkout is absent.
150
+ if (!catalogue) {
151
+ // eslint-disable-next-line no-console
152
+ console.warn('skipping catalogue cross-check — teleport-gui not a sibling checkout')
153
+ }
154
+ expect(true).toBe(true)
155
+ })
156
+
157
+ it('routes every offered model to the provider that owns it', () => {
158
+ if (!catalogue) {
159
+ return
160
+ }
161
+ expect(catalogue.length).toBeGreaterThan(40)
162
+ const mismatches = catalogue.filter(
163
+ ([model, provider]) => utils.resolveProvider({ provider, model }) !== provider
164
+ )
165
+ expect(mismatches).toEqual([])
166
+ })
167
+
168
+ it('never produces an unusable request shape for an offered model', () => {
169
+ if (!catalogue) {
170
+ return
171
+ }
172
+ // Anthropic rejects a request with no max_tokens at all, so every model
173
+ // must come out with a positive budget whichever branch runs.
174
+ const broken = catalogue.filter(([model, provider]) => {
175
+ const caps = utils.modelCapabilities(provider, model, 500)
176
+ return (
177
+ !(caps.maxTokens > 0) ||
178
+ !['max_tokens', 'max_completion_tokens'].includes(caps.maxTokensParam)
179
+ )
180
+ })
181
+ expect(broken).toEqual([])
182
+ })
183
+ })
@@ -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,165 @@
1
+ /* tslint:disable:no-eval */
2
+ import { cartGetTotal } from '../src/nodes/cart/cart-get-total'
3
+
4
+ /**
5
+ * The merchant's "Invoices → Tax → Added on top" setting decides what a buyer
6
+ * actually pays. `teleport_products.price` is the NET price in that mode, so
7
+ * the storefront has to add the tax at every point a price is shown or charged
8
+ * — and `cart-get-total` is the point the PLACE-ORDER workflow charges from.
9
+ *
10
+ * Three contracts are locked in here, each of which silently breaks money:
11
+ *
12
+ * 1. `subtotal` stays NET. Two generations of baked place-order workflows read
13
+ * it and add shipping to produce `teleport_orders.total_amount`. If a NEW
14
+ * handler made it gross, an OLD baked workflow would write a taxed order
15
+ * total while still emitting untaxed payment line items — and Stripe
16
+ * Checkout totals `line_items` and ignores the top-level amount, so the
17
+ * buyer would be undercharged.
18
+ * 2. `tax` is `gross − net`, never a fresh percentage of the sum, so
19
+ * `net line items + tax` reconciles to `total` to the cent.
20
+ * 3. Rounding is PER UNIT, matching `EcommerceProvider`'s
21
+ * `cartItemDisplayPrice`, so the amount charged equals the Subtotal the
22
+ * cart page printed.
23
+ */
24
+
25
+ function evalHandler(code: string): any {
26
+ return eval('(' + code + ')')
27
+ }
28
+
29
+ interface StoredSettings {
30
+ maxQuantityPerProduct?: number | null
31
+ deliveryConfig?: unknown
32
+ taxConfig?: { storefrontTaxRate: number }
33
+ }
34
+
35
+ function withLocalStorage(cart: unknown, settings: StoredSettings | null): void {
36
+ const store: Record<string, string> = { workflow_cart: JSON.stringify(cart) }
37
+ if (settings) {
38
+ store.workflow_cart_settings = JSON.stringify(settings)
39
+ }
40
+ ;(global as any).localStorage = {
41
+ getItem: (key: string) => (key in store ? store[key] : null),
42
+ }
43
+ }
44
+
45
+ const runHandler = async () => evalHandler(cartGetTotal.generateHandler())()
46
+
47
+ afterEach(() => {
48
+ delete (global as any).localStorage
49
+ })
50
+
51
+ describe('cart-get-total — storefront tax', () => {
52
+ it('leaves every total untouched when no tax config is stored (legacy storefront)', async () => {
53
+ withLocalStorage([{ price: 100, quantity: 2 }], { maxQuantityPerProduct: null })
54
+
55
+ const out = await runHandler()
56
+
57
+ expect(out.subtotal).toBe(200)
58
+ expect(out.tax).toBe(0)
59
+ expect(out.total).toBe(200)
60
+ expect(out.itemCount).toBe(2)
61
+ expect(out.taxConfig).toEqual({ storefrontTaxRate: 0 })
62
+ })
63
+
64
+ it('leaves every total untouched when the rate is zero', async () => {
65
+ withLocalStorage([{ price: 100, quantity: 2 }], { taxConfig: { storefrontTaxRate: 0 } })
66
+
67
+ const out = await runHandler()
68
+
69
+ expect(out.subtotal).toBe(200)
70
+ expect(out.tax).toBe(0)
71
+ expect(out.total).toBe(200)
72
+ })
73
+
74
+ it('keeps subtotal NET and reports the tax separately', async () => {
75
+ withLocalStorage([{ price: 100, quantity: 2 }], { taxConfig: { storefrontTaxRate: 19 } })
76
+
77
+ const out = await runHandler()
78
+
79
+ expect(out.subtotal).toBe(200)
80
+ expect(out.tax).toBe(38)
81
+ expect(out.total).toBe(238)
82
+ })
83
+
84
+ it('rounds per unit, so the charge matches the cart page line by line', async () => {
85
+ // 19.99 net → 23.79 gross per unit → 71.37 for three.
86
+ // Rounding the SUM instead would give 71.36 and disagree with the page.
87
+ withLocalStorage([{ price: 19.99, quantity: 3 }], { taxConfig: { storefrontTaxRate: 19 } })
88
+
89
+ const out = await runHandler()
90
+
91
+ expect(out.subtotal).toBe(59.97)
92
+ expect(out.total).toBe(71.37)
93
+ expect(out.tax).toBe(11.4)
94
+ })
95
+
96
+ it('reconciles: net line items + tax === total, for a mixed basket', async () => {
97
+ withLocalStorage(
98
+ [
99
+ { price: 19.99, quantity: 3 },
100
+ { price: 5.5, quantity: 1 },
101
+ { price: 100, quantity: 2 },
102
+ ],
103
+ { taxConfig: { storefrontTaxRate: 7.5 } }
104
+ )
105
+
106
+ const out = await runHandler()
107
+
108
+ // This is the invariant the payment line items depend on: the provider is
109
+ // sent NET item lines plus one "Tax" line, and their sum has to be the
110
+ // order's goods total to the cent.
111
+ expect(out.subtotal + out.tax).toBeCloseTo(out.total, 10)
112
+ // net 59.97 + 5.50 + 200.00
113
+ expect(out.subtotal).toBe(265.47)
114
+ // per-unit gross 21.49×3 + 5.91 + 107.50×2
115
+ expect(out.total).toBe(285.38)
116
+ expect(out.tax).toBe(19.91)
117
+ })
118
+
119
+ it('still surfaces the delivery config alongside the tax config', async () => {
120
+ withLocalStorage([{ price: 10, quantity: 1 }], {
121
+ deliveryConfig: { deliveryPrice: 15, freeDeliveryEnabled: true, freeDeliveryThreshold: 50 },
122
+ taxConfig: { storefrontTaxRate: 19 },
123
+ })
124
+
125
+ const out = await runHandler()
126
+
127
+ expect(out.deliveryConfig).toEqual({
128
+ deliveryPrice: 15,
129
+ freeDeliveryEnabled: true,
130
+ freeDeliveryThreshold: 50,
131
+ })
132
+ expect(out.taxConfig).toEqual({ storefrontTaxRate: 19 })
133
+ })
134
+
135
+ it('ignores a malformed or negative rate instead of corrupting the charge', async () => {
136
+ withLocalStorage([{ price: 100, quantity: 1 }], {
137
+ taxConfig: { storefrontTaxRate: -5 },
138
+ })
139
+ expect((await runHandler()).total).toBe(100)
140
+
141
+ withLocalStorage([{ price: 100, quantity: 1 }], {
142
+ taxConfig: { storefrontTaxRate: 'nonsense' as unknown as number },
143
+ })
144
+ expect((await runHandler()).total).toBe(100)
145
+ })
146
+
147
+ it('returns a fully zeroed shape when localStorage is unreadable', async () => {
148
+ ;(global as any).localStorage = {
149
+ getItem: () => {
150
+ throw new Error('denied')
151
+ },
152
+ }
153
+
154
+ const out = await runHandler()
155
+
156
+ expect(out).toEqual({
157
+ subtotal: 0,
158
+ tax: 0,
159
+ total: 0,
160
+ itemCount: 0,
161
+ deliveryConfig: null,
162
+ taxConfig: { storefrontTaxRate: 0 },
163
+ })
164
+ })
165
+ })
@@ -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
+ })
@@ -373,7 +373,7 @@ describe('buildPaymentMetadataBuilder — runtime semantics', () => {
373
373
  // Build a synthetic params array that mirrors the workflow context:
374
374
  // a mix of unrelated nodes plus an order-create-item-shaped result.
375
375
  const fn = (() => {
376
- const code = buildPaymentMetadataBuilder()
376
+ const code = buildPaymentMetadataBuilder('/order-details')
377
377
  return new Function(code + '\nreturn customHandler;')() as any
378
378
  })()
379
379
 
@@ -0,0 +1,159 @@
1
+ import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
2
+ import {
3
+ generateServerSegmentAPIRoute,
4
+ generateStreamingServerSegmentAPIRoute,
5
+ } from '../src/api-route-generator'
6
+ import { nodeRegistry } from '../src/nodes'
7
+ import type { WorkflowSegment } from '../src/types'
8
+
9
+ /**
10
+ * A generated app's data nodes reach the database by having the server call its
11
+ * OWN `/api/data/<id>/<op>` route over HTTP. On a deployment behind Vercel
12
+ * Deployment Protection that self-call is answered with 401 "Protected
13
+ * deployment" — the page still renders (the visitor's browser holds the bypass
14
+ * cookie) but every data node returns `{ rows: [] }`, so the app looks alive
15
+ * while nothing reads or writes.
16
+ *
17
+ * Measured on a published AI-chat store: all four RAG queries came back empty
18
+ * with `error: { message: 'Protected deployment', code: '401' }`, and the
19
+ * assistant answered "I don't have enough information in my knowledge base" to
20
+ * every question — with a fully populated knowledge base sitting in the
21
+ * database.
22
+ */
23
+
24
+ function loadRuntime() {
25
+ const source = generateSharedRuntimeUtilsCode()
26
+ const fn = source.match(/function internalRequestHeaders[\s\S]*?\n\}/)
27
+ if (!fn) {
28
+ throw new Error('internalRequestHeaders not found in the generated runtime')
29
+ }
30
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
31
+ return {
32
+ internalRequestHeaders: new Function(`${fn[0]}\nreturn internalRequestHeaders;`)() as (
33
+ req: unknown
34
+ ) => Record<string, string>,
35
+ }
36
+ }
37
+
38
+ describe('internalRequestHeaders', () => {
39
+ const utils = loadRuntime()
40
+ const originalBypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET
41
+
42
+ afterEach(() => {
43
+ if (originalBypass === undefined) {
44
+ delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET
45
+ } else {
46
+ process.env.VERCEL_AUTOMATION_BYPASS_SECRET = originalBypass
47
+ }
48
+ })
49
+
50
+ it('forwards the caller cookie that already passed the protection', () => {
51
+ delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET
52
+ const headers = utils.internalRequestHeaders({
53
+ headers: { cookie: '_vercel_jwt=abc; next-auth.session-token=xyz' },
54
+ })
55
+ expect(headers.cookie).toBe('_vercel_jwt=abc; next-auth.session-token=xyz')
56
+ })
57
+
58
+ it('sends the automation bypass secret when the project configured one', () => {
59
+ // The only credential available when no browser sits behind the request.
60
+ process.env.VERCEL_AUTOMATION_BYPASS_SECRET = 'sekret'
61
+ const headers = utils.internalRequestHeaders({ headers: {} })
62
+ expect(headers['x-vercel-protection-bypass']).toBe('sekret')
63
+ expect(headers['x-vercel-set-bypass-cookie']).toBe('false')
64
+ })
65
+
66
+ it('returns an empty object rather than throwing on a request with no headers', () => {
67
+ delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET
68
+ expect(utils.internalRequestHeaders(undefined)).toEqual({})
69
+ expect(utils.internalRequestHeaders({})).toEqual({})
70
+ })
71
+
72
+ it('invents no cookie when the caller sent none', () => {
73
+ delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET
74
+ expect(utils.internalRequestHeaders({ headers: {} }).cookie).toBeUndefined()
75
+ })
76
+ })
77
+
78
+ describe('every data node forwards the internal headers on its self-call', () => {
79
+ const DATA_NODES = [
80
+ 'data-raw-query',
81
+ 'data-select',
82
+ 'data-count',
83
+ 'data-create-item',
84
+ 'data-update-item',
85
+ 'data-delete-item',
86
+ ]
87
+
88
+ it.each(DATA_NODES)('%s reads context.__internalHeaders', (nodeType) => {
89
+ const source = nodeRegistry[nodeType].generateHandler()
90
+ expect(source).toContain('context.__internalHeaders')
91
+ })
92
+
93
+ it.each(DATA_NODES)('%s merges them into every self-call it makes', (nodeType) => {
94
+ const source = nodeRegistry[nodeType].generateHandler()
95
+ // A bare `headers: { 'Content-Type': … }` on a call to this deployment's own
96
+ // API is the shape that 401s; every one must be an Object.assign that folds
97
+ // the internal headers in.
98
+ const selfCalls = source.split('fetch(baseUrl').slice(1)
99
+ expect(selfCalls.length).toBeGreaterThan(0)
100
+ for (const call of selfCalls) {
101
+ const headersAt = call.indexOf('headers:')
102
+ expect(headersAt).toBeGreaterThan(-1)
103
+ expect(call.slice(headersAt, headersAt + 60)).toContain('Object.assign')
104
+ }
105
+ })
106
+ })
107
+
108
+ describe('the segment routes supply the headers but never echo them back', () => {
109
+ const segment: WorkflowSegment = {
110
+ id: 'seg-server-1',
111
+ workflowId: 'wf-1',
112
+ executionEnv: 'server',
113
+ nodes: [
114
+ {
115
+ id: 'n1',
116
+ type: 'data-raw-query',
117
+ config: { dataSourceId: 'ds-1', query: 'SELECT 1' },
118
+ stepNumber: 1,
119
+ label: 'Probe',
120
+ },
121
+ ],
122
+ edges: [],
123
+ } as unknown as WorkflowSegment
124
+
125
+ const routes = [
126
+ ['non-streaming', generateServerSegmentAPIRoute(segment)],
127
+ ['streaming', generateStreamingServerSegmentAPIRoute(segment)],
128
+ ] as const
129
+
130
+ it.each(routes)('%s route puts the headers on the execution context', (_label, route) => {
131
+ expect(route).toContain('context.__internalHeaders = utils.internalRequestHeaders(req)')
132
+ })
133
+
134
+ it.each(routes)('%s route deletes them before serializing the context', (_label, route) => {
135
+ // ⛔ `results` in the response IS this context. The forwarded cookie carries
136
+ // the visitor's session token; writing it into a JS-readable response body
137
+ // would undo the httpOnly flag it was set with.
138
+ const deleteAt = route.indexOf('delete context.__internalHeaders')
139
+ expect(deleteAt).toBeGreaterThan(-1)
140
+ const firstEcho = route.indexOf('results: context')
141
+ expect(firstEcho).toBeGreaterThan(-1)
142
+ expect(deleteAt).toBeLessThan(firstEcho)
143
+ })
144
+
145
+ it.each(routes)('%s route echoes the context exactly once per exit path', (_label, route) => {
146
+ // Guards the assertion above: a second `results: context` added later would
147
+ // sit outside the delete's reach unless it is also after it.
148
+ const positions: number[] = []
149
+ let idx = route.indexOf('results: context')
150
+ while (idx !== -1) {
151
+ positions.push(idx)
152
+ idx = route.indexOf('results: context', idx + 1)
153
+ }
154
+ const deleteAt = route.indexOf('delete context.__internalHeaders')
155
+ for (const position of positions) {
156
+ expect(position).toBeGreaterThan(deleteAt)
157
+ }
158
+ })
159
+ })