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

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 (129) 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-get-total-delivery-flags.test.ts +86 -0
  5. package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
  6. package/__tests__/invoice-delivery-fee.test.ts +155 -0
  7. package/__tests__/invoice-generate-hydration.test.ts +7 -2
  8. package/__tests__/invoice-order-items-settle.test.ts +308 -0
  9. package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
  10. package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
  11. package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
  12. package/__tests__/stock-decrement-audit.test.ts +77 -0
  13. package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
  14. package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
  15. package/dist/cjs/ecommerce/stock-decrement.js +72 -6
  16. package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
  17. package/dist/cjs/executor-generator.d.ts.map +1 -1
  18. package/dist/cjs/executor-generator.js +93 -15
  19. package/dist/cjs/executor-generator.js.map +1 -1
  20. package/dist/cjs/index.d.ts +1 -0
  21. package/dist/cjs/index.d.ts.map +1 -1
  22. package/dist/cjs/index.js +3 -1
  23. package/dist/cjs/index.js.map +1 -1
  24. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  25. package/dist/cjs/invoice/api-routes-code.js +163 -5
  26. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  27. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  28. package/dist/cjs/invoice/invoice-html-code.js +25 -4
  29. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  30. package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
  31. package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
  32. package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
  33. package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
  34. package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
  35. package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
  36. package/dist/cjs/nodes/cache/cache-get.js +39 -0
  37. package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
  38. package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
  39. package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
  40. package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
  41. package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
  42. package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
  43. package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
  44. package/dist/cjs/nodes/cache/cache-set.js +71 -0
  45. package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
  46. package/dist/cjs/nodes/cache/store.d.ts +29 -0
  47. package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
  48. package/dist/cjs/nodes/cache/store.js +83 -0
  49. package/dist/cjs/nodes/cache/store.js.map +1 -0
  50. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  51. package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
  52. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  53. package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  54. package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
  55. package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
  56. package/dist/cjs/nodes/index.d.ts.map +1 -1
  57. package/dist/cjs/nodes/index.js +5 -1
  58. package/dist/cjs/nodes/index.js.map +1 -1
  59. package/dist/cjs/runtime-storage-generator.d.ts +18 -0
  60. package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
  61. package/dist/cjs/runtime-storage-generator.js +63 -3
  62. package/dist/cjs/runtime-storage-generator.js.map +1 -1
  63. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  64. package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
  65. package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
  66. package/dist/esm/ecommerce/stock-decrement.js +72 -6
  67. package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
  68. package/dist/esm/executor-generator.d.ts.map +1 -1
  69. package/dist/esm/executor-generator.js +93 -15
  70. package/dist/esm/executor-generator.js.map +1 -1
  71. package/dist/esm/index.d.ts +1 -0
  72. package/dist/esm/index.d.ts.map +1 -1
  73. package/dist/esm/index.js +1 -0
  74. package/dist/esm/index.js.map +1 -1
  75. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  76. package/dist/esm/invoice/api-routes-code.js +163 -5
  77. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  78. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  79. package/dist/esm/invoice/invoice-html-code.js +25 -4
  80. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  81. package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
  82. package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
  83. package/dist/esm/nodes/account/account-refresh-session.js +93 -0
  84. package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
  85. package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
  86. package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
  87. package/dist/esm/nodes/cache/cache-get.js +36 -0
  88. package/dist/esm/nodes/cache/cache-get.js.map +1 -0
  89. package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
  90. package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
  91. package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
  92. package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
  93. package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
  94. package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
  95. package/dist/esm/nodes/cache/cache-set.js +68 -0
  96. package/dist/esm/nodes/cache/cache-set.js.map +1 -0
  97. package/dist/esm/nodes/cache/store.d.ts +29 -0
  98. package/dist/esm/nodes/cache/store.d.ts.map +1 -0
  99. package/dist/esm/nodes/cache/store.js +80 -0
  100. package/dist/esm/nodes/cache/store.js.map +1 -0
  101. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  102. package/dist/esm/nodes/cart/cart-get-total.js +16 -0
  103. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  104. package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  105. package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
  106. package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
  107. package/dist/esm/nodes/index.d.ts.map +1 -1
  108. package/dist/esm/nodes/index.js +5 -1
  109. package/dist/esm/nodes/index.js.map +1 -1
  110. package/dist/esm/runtime-storage-generator.d.ts +18 -0
  111. package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
  112. package/dist/esm/runtime-storage-generator.js +62 -2
  113. package/dist/esm/runtime-storage-generator.js.map +1 -1
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +4 -4
  116. package/src/ecommerce/stock-decrement.ts +83 -11
  117. package/src/executor-generator.ts +93 -15
  118. package/src/index.ts +1 -0
  119. package/src/invoice/api-routes-code.ts +163 -5
  120. package/src/invoice/invoice-html-code.ts +25 -4
  121. package/src/nodes/account/account-refresh-session.ts +97 -0
  122. package/src/nodes/cache/cache-get.ts +38 -0
  123. package/src/nodes/cache/cache-invalidate.ts +95 -0
  124. package/src/nodes/cache/cache-set.ts +71 -0
  125. package/src/nodes/cache/store.ts +80 -0
  126. package/src/nodes/cart/cart-get-total.ts +18 -0
  127. package/src/nodes/file-storage/file-storage-upload.ts +51 -6
  128. package/src/nodes/index.ts +8 -0
  129. 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,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
+ })
@@ -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 () => {
@@ -0,0 +1,155 @@
1
+ import { generateInvoiceGenerateRouteCode } from '../src/invoice/api-routes-code'
2
+ import { generatePdfGeneratorCode } from '../src/invoice/pdf-generator-code'
3
+ import type { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
4
+
5
+ // The delivery fee on the invoice.
6
+ //
7
+ // `teleport_orders.shipping_amount` is folded into the amount the buyer was
8
+ // actually charged, so an invoice that ignores it prints a Total its own rows
9
+ // do not add up to — and, through `{{totalAmount}}`, asks the customer to pay
10
+ // a different number than their card was debited.
11
+ //
12
+ // Two halves are covered here:
13
+ // 1. `/api/invoices/generate` reads the fee off the order (or the caller)
14
+ // and adds it to the total WITHOUT taxing it — the storefront charges the
15
+ // configured delivery price verbatim, so the invoice has to as well.
16
+ // 2. The PDF renderer publishes `Invoice.shippingAmount` + `Invoice.hasShipping`
17
+ // for the template to bind and gate on, and prints its own Delivery line in
18
+ // the no-template fallback.
19
+
20
+ const FAKE_SETTINGS = {
21
+ invoicePrefix: 'INV-',
22
+ defaultTaxRate: 0,
23
+ showDiscount: false,
24
+ taxIncludedInPrice: false,
25
+ companyDetails: {},
26
+ template: { document: null as unknown },
27
+ tables: { invoicesTable: 'teleport_invoices', invoiceItemsTable: 'teleport_invoice_items' },
28
+ emailDelivery: { enabled: false },
29
+ } as unknown as UIDLInvoiceSettings
30
+
31
+ interface InvoiceScope {
32
+ invoiceData: {
33
+ Invoice: Record<string, string>
34
+ }
35
+ }
36
+
37
+ interface PdfGeneratorModule {
38
+ buildInvoiceDataScope: (invoiceData: Record<string, unknown>) => InvoiceScope
39
+ buildInvoiceHtml: (invoiceData: Record<string, unknown>) => string
40
+ replacePlaceholders: (template: string, data: Record<string, unknown>) => string
41
+ buildDataContext: (invoiceData: Record<string, unknown>) => Record<string, unknown>
42
+ }
43
+
44
+ /**
45
+ * Loads the emitted `utils/invoices/pdf-generator.js` as a real module so the
46
+ * assertions run its arithmetic instead of matching its source text. No
47
+ * template UIDL is passed, which is the fallback-HTML path.
48
+ */
49
+ function loadPdfGenerator(): PdfGeneratorModule {
50
+ const code = generatePdfGeneratorCode(FAKE_SETTINGS, null, null)
51
+ const factory = new Function(
52
+ 'require',
53
+ 'module',
54
+ 'exports',
55
+ `${code}; return module.exports;`
56
+ ) as (
57
+ req: NodeRequire,
58
+ mod: { exports: Record<string, unknown> },
59
+ exp: Record<string, unknown>
60
+ ) => PdfGeneratorModule
61
+ const moduleObject = { exports: {} as Record<string, unknown> }
62
+ return factory(require, moduleObject, moduleObject.exports)
63
+ }
64
+
65
+ describe('/api/invoices/generate — delivery fee', () => {
66
+ const route = generateInvoiceGenerateRouteCode(FAKE_SETTINGS)
67
+
68
+ it('reads the fee off the hydrated order, with a caller override', () => {
69
+ expect(route).toContain(
70
+ 'body.shippingAmount != null ? body.shippingAmount : (orderShippingSource.shipping_amount || 0)'
71
+ )
72
+ })
73
+
74
+ it('coerces a missing / negative / unparseable fee to zero', () => {
75
+ // Orders placed before the column existed hydrate `undefined` here, and
76
+ // that has to read as "this order paid nothing for delivery" rather than
77
+ // poisoning the total with NaN.
78
+ expect(route).toContain('if (!isFinite(shippingAmount) || shippingAmount < 0) {')
79
+ expect(route).toContain('shippingAmount = 0;')
80
+ })
81
+
82
+ it('adds the fee to the total but leaves subtotal and tax to the goods', () => {
83
+ // The storefront never taxes the delivery price (`computeShippingMeta`
84
+ // uses the configured value verbatim), so taxing it here would make the
85
+ // invoice total disagree with `teleport_orders.total_amount`.
86
+ expect(route).toContain('var total = goodsTotal + shippingAmount;')
87
+ expect(route).toContain('subtotal: Math.round(subtotal * 100) / 100,')
88
+ expect(route).toContain('shippingAmount: Math.round(shippingAmount * 100) / 100,')
89
+ })
90
+ })
91
+
92
+ describe('invoice PDF renderer — delivery fee', () => {
93
+ const pdfGenerator = loadPdfGenerator()
94
+
95
+ const baseInvoice = {
96
+ invoiceNumber: 'INV-0001',
97
+ currency: 'USD',
98
+ currencySymbol: '$',
99
+ subtotal: 200,
100
+ taxRate: 0,
101
+ taxAmount: 0,
102
+ discountAmount: 0,
103
+ items: [{ name: 'Espresso Machine', quantity: 1, unitPrice: 200, totalPrice: 200 }],
104
+ }
105
+
106
+ it('publishes the formatted fee and turns the row flag on', () => {
107
+ const { invoiceData } = pdfGenerator.buildInvoiceDataScope({
108
+ ...baseInvoice,
109
+ shippingAmount: 9.99,
110
+ total: 209.99,
111
+ })
112
+
113
+ expect(invoiceData.Invoice.shippingAmount).toBe('$9.99')
114
+ expect(invoiceData.Invoice.hasShipping).toBe('true')
115
+ })
116
+
117
+ it('turns the row flag off for an order that paid nothing to be delivered', () => {
118
+ // Covers store-pickup orders, free-delivery orders, and every invoice
119
+ // generated from an order placed before the column existed (no field at
120
+ // all, so `Number(undefined)` is NaN and must not read as a fee).
121
+ const { invoiceData } = pdfGenerator.buildInvoiceDataScope({
122
+ ...baseInvoice,
123
+ total: 200,
124
+ })
125
+
126
+ expect(invoiceData.Invoice.shippingAmount).toBe('$0.00')
127
+ expect(invoiceData.Invoice.hasShipping).toBe('false')
128
+ })
129
+
130
+ it('prints a Delivery line in the fallback HTML only when there is a fee', () => {
131
+ const withFee = pdfGenerator.buildInvoiceHtml({
132
+ ...baseInvoice,
133
+ shippingAmount: 9.99,
134
+ total: 209.99,
135
+ })
136
+ expect(withFee).toContain('<span>Delivery</span><span>$9.99</span>')
137
+ expect(withFee).toContain('<span>Total</span><span>$209.99</span>')
138
+
139
+ const withoutFee = pdfGenerator.buildInvoiceHtml({ ...baseInvoice, total: 200 })
140
+ expect(withoutFee).not.toContain('<span>Delivery</span>')
141
+ })
142
+
143
+ it('fills the {{shippingAmount}} merge token in the invoice email', () => {
144
+ const data = pdfGenerator.buildDataContext({
145
+ ...baseInvoice,
146
+ shippingAmount: 9.99,
147
+ total: 209.99,
148
+ })
149
+
150
+ expect(pdfGenerator.replacePlaceholders('Delivery: {{shippingAmount}}', data)).toBe(
151
+ 'Delivery: $9.99'
152
+ )
153
+ expect(pdfGenerator.replacePlaceholders('Due: {{totalAmount}}', data)).toBe('Due: $209.99')
154
+ })
155
+ })
@@ -90,8 +90,13 @@ describe('/api/invoices/generate — orderId hydration', () => {
90
90
  it('prefers body.items over hydrated items when both exist', () => {
91
91
  // Admin "regenerate invoice with custom items" use cases need
92
92
  // body.items to win. The condition checks length > 0 so an empty
93
- // explicit array still falls back to DB-hydrated items.
94
- expect(route).toContain('var items = Array.isArray(body.items) && body.items.length > 0')
93
+ // explicit array still falls back to DB-hydrated items. The predicate is
94
+ // named because the settle-wait below keys off it too — a caller that
95
+ // brought its own lines never waits for the DB rows to land.
96
+ expect(route).toContain(
97
+ 'var callerSuppliedItems = Array.isArray(body.items) && body.items.length > 0;'
98
+ )
99
+ expect(route).toContain('var items = callerSuppliedItems')
95
100
  })
96
101
 
97
102
  it('falls back to order.billing_* / shipping_* fields for customer data', () => {