@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.
- package/__tests__/_helpers/abort-controller-stub.ts +64 -0
- package/__tests__/account-refresh-session.test.ts +110 -0
- package/__tests__/cache-nodes.test.ts +137 -0
- package/__tests__/cart-get-total-delivery-flags.test.ts +86 -0
- package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
- package/__tests__/invoice-delivery-fee.test.ts +155 -0
- package/__tests__/invoice-generate-hydration.test.ts +7 -2
- package/__tests__/invoice-order-items-settle.test.ts +308 -0
- package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
- package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
- package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
- package/__tests__/stock-decrement-audit.test.ts +77 -0
- package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.js +72 -6
- package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +93 -15
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +163 -5
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +25 -4
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
- package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.js +39 -0
- package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.js +71 -0
- package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
- package/dist/cjs/nodes/cache/store.d.ts +29 -0
- package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/store.js +83 -0
- package/dist/cjs/nodes/cache/store.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
- package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +5 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/runtime-storage-generator.d.ts +18 -0
- package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
- package/dist/cjs/runtime-storage-generator.js +63 -3
- package/dist/cjs/runtime-storage-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.js +72 -6
- package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +93 -15
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +163 -5
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +25 -4
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-refresh-session.js +93 -0
- package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-get.js +36 -0
- package/dist/esm/nodes/cache/cache-get.js.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
- package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-set.js +68 -0
- package/dist/esm/nodes/cache/cache-set.js.map +1 -0
- package/dist/esm/nodes/cache/store.d.ts +29 -0
- package/dist/esm/nodes/cache/store.d.ts.map +1 -0
- package/dist/esm/nodes/cache/store.js +80 -0
- package/dist/esm/nodes/cache/store.js.map +1 -0
- package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.js +16 -0
- package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +5 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/runtime-storage-generator.d.ts +18 -0
- package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
- package/dist/esm/runtime-storage-generator.js +62 -2
- package/dist/esm/runtime-storage-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/ecommerce/stock-decrement.ts +83 -11
- package/src/executor-generator.ts +93 -15
- package/src/index.ts +1 -0
- package/src/invoice/api-routes-code.ts +163 -5
- package/src/invoice/invoice-html-code.ts +25 -4
- package/src/nodes/account/account-refresh-session.ts +97 -0
- package/src/nodes/cache/cache-get.ts +38 -0
- package/src/nodes/cache/cache-invalidate.ts +95 -0
- package/src/nodes/cache/cache-set.ts +71 -0
- package/src/nodes/cache/store.ts +80 -0
- package/src/nodes/cart/cart-get-total.ts +18 -0
- package/src/nodes/file-storage/file-storage-upload.ts +51 -6
- package/src/nodes/index.ts +8 -0
- package/src/runtime-storage-generator.ts +63 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// jest 26's node sandbox exposes none of the browser globals the upload
|
|
2
|
+
// handlers use — no `fetch`, no `FormData`, no `Blob`, no `atob` and no
|
|
3
|
+
// `AbortController`. The first four have always been stubbed by the tests that
|
|
4
|
+
// need them; this is the fifth, added when `file-storage-upload` grew a timeout.
|
|
5
|
+
//
|
|
6
|
+
// It is a TEST-ONLY absence: `AbortController` has been available in every
|
|
7
|
+
// browser since 2017 and in Node since 14, which is why the generated handler
|
|
8
|
+
// uses it unguarded.
|
|
9
|
+
|
|
10
|
+
type AbortListener = () => void
|
|
11
|
+
|
|
12
|
+
class AbortSignalStub {
|
|
13
|
+
public aborted = false
|
|
14
|
+
public reason: unknown = undefined
|
|
15
|
+
private listeners: AbortListener[] = []
|
|
16
|
+
|
|
17
|
+
public addEventListener(type: string, listener: AbortListener): void {
|
|
18
|
+
if (type === 'abort') {
|
|
19
|
+
this.listeners.push(listener)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public removeEventListener(type: string, listener: AbortListener): void {
|
|
24
|
+
if (type === 'abort') {
|
|
25
|
+
this.listeners = this.listeners.filter((entry) => entry !== listener)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @internal — driven by the controller, never by the code under test. */
|
|
30
|
+
public dispatchAbort(reason: unknown): void {
|
|
31
|
+
if (this.aborted) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
this.aborted = true
|
|
35
|
+
this.reason = reason
|
|
36
|
+
for (const listener of this.listeners.slice()) {
|
|
37
|
+
listener()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AbortControllerStub {
|
|
43
|
+
public readonly signal = new AbortSignalStub()
|
|
44
|
+
|
|
45
|
+
public abort(reason?: unknown): void {
|
|
46
|
+
this.signal.dispatchAbort(reason)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The browser globals the file-storage upload handler expects to exist. */
|
|
51
|
+
export const installUploadGlobals = (overrides: Record<string, unknown> = {}): void => {
|
|
52
|
+
const globals = globalThis as Record<string, unknown>
|
|
53
|
+
globals.AbortController = AbortControllerStub
|
|
54
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
55
|
+
globals[name] = value
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const removeUploadGlobals = (names: string[]): void => {
|
|
60
|
+
const globals = globalThis as Record<string, unknown>
|
|
61
|
+
for (const name of ['AbortController', ...names]) {
|
|
62
|
+
delete globals[name]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
2
|
+
|
|
3
|
+
// After a profile save the NextAuth JWT still carries the PREVIOUS name/image:
|
|
4
|
+
// the generated jwt callback only re-reads the users row once per refresh
|
|
5
|
+
// interval unless next-auth passes `trigger: 'update'`, which only
|
|
6
|
+
// `useSession().update()` does. `account-refresh-session` is the node that
|
|
7
|
+
// calls it and then republishes the result to the two consumers that would
|
|
8
|
+
// otherwise keep the stale value: the `teleport_auth_user` localStorage mirror
|
|
9
|
+
// `account-get-current` falls back to, and GlobalContext's `currentUser` (which
|
|
10
|
+
// the account-menu avatar binds to) via `teleport:auth-user-changed`.
|
|
11
|
+
|
|
12
|
+
interface RefreshResult {
|
|
13
|
+
id?: string
|
|
14
|
+
image?: string
|
|
15
|
+
user: Record<string, unknown> | null
|
|
16
|
+
refreshed: boolean
|
|
17
|
+
error?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('account-refresh-session', () => {
|
|
21
|
+
const handler: HandlerFn = loadHandler('account-refresh-session')
|
|
22
|
+
let events: Array<{ type: string; detail: unknown }>
|
|
23
|
+
let stored: Record<string, string>
|
|
24
|
+
|
|
25
|
+
const installWindow = (refreshSession: unknown): void => {
|
|
26
|
+
events = []
|
|
27
|
+
stored = { teleport_auth_user: JSON.stringify({ id: 'u-1', image: 'https://cdn/old.png' }) }
|
|
28
|
+
;(globalThis as any).window = {
|
|
29
|
+
__teleportNextAuth: refreshSession === undefined ? {} : { refreshSession },
|
|
30
|
+
localStorage: {
|
|
31
|
+
getItem: (k: string) => (k in stored ? stored[k] : null),
|
|
32
|
+
setItem: (k: string, v: string) => {
|
|
33
|
+
stored[k] = v
|
|
34
|
+
},
|
|
35
|
+
removeItem: (k: string) => {
|
|
36
|
+
delete stored[k]
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
dispatchEvent: (event: { type: string; detail: unknown }) => events.push(event),
|
|
40
|
+
}
|
|
41
|
+
;(globalThis as any).CustomEvent = class {
|
|
42
|
+
public type: string
|
|
43
|
+
public detail: unknown
|
|
44
|
+
constructor(type: string, init?: { detail?: unknown }) {
|
|
45
|
+
this.type = type
|
|
46
|
+
this.detail = init ? init.detail : undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
delete (globalThis as any).window
|
|
53
|
+
delete (globalThis as any).CustomEvent
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('publishes the refreshed user to GlobalContext and the localStorage mirror', async () => {
|
|
57
|
+
const fresh = { id: 'u-1', name: 'Ada Lovelace', image: 'https://cdn/new.png', role: 'user' }
|
|
58
|
+
installWindow(async () => ({ user: fresh, expires: '2099-01-01' }))
|
|
59
|
+
|
|
60
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
61
|
+
|
|
62
|
+
expect(result.refreshed).toBe(true)
|
|
63
|
+
expect(result.user).toEqual(fresh)
|
|
64
|
+
// Flat fields too — same output contract as account-get-current.
|
|
65
|
+
expect(result.image).toBe('https://cdn/new.png')
|
|
66
|
+
expect(result.id).toBe('u-1')
|
|
67
|
+
expect(JSON.parse(stored.teleport_auth_user).image).toBe('https://cdn/new.png')
|
|
68
|
+
expect(events).toHaveLength(1)
|
|
69
|
+
expect(events[0].type).toBe('teleport:auth-user-changed')
|
|
70
|
+
expect(events[0].detail).toEqual({ user: fresh })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('never publishes a null user — that would sign the visitor out of the UI', async () => {
|
|
74
|
+
// `update()` resolves to undefined while the provider is still loading.
|
|
75
|
+
installWindow(async (): Promise<undefined> => undefined)
|
|
76
|
+
|
|
77
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
78
|
+
|
|
79
|
+
expect(result.refreshed).toBe(false)
|
|
80
|
+
expect(events).toHaveLength(0)
|
|
81
|
+
expect(JSON.parse(stored.teleport_auth_user).image).toBe('https://cdn/old.png')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('degrades quietly when the session bridge is unavailable', async () => {
|
|
85
|
+
installWindow(undefined)
|
|
86
|
+
|
|
87
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
88
|
+
|
|
89
|
+
expect(result.refreshed).toBe(false)
|
|
90
|
+
expect(events).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('never returns a string error key (that would abort the workflow)', async () => {
|
|
94
|
+
installWindow(async () => {
|
|
95
|
+
throw new Error('network down')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
99
|
+
|
|
100
|
+
expect(result.refreshed).toBe(false)
|
|
101
|
+
expect(typeof result.error).not.toBe('string')
|
|
102
|
+
expect(events).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is inert on the server', async () => {
|
|
106
|
+
const result = (await handler({}, {})) as RefreshResult
|
|
107
|
+
expect(result.refreshed).toBe(false)
|
|
108
|
+
expect(result.user).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { nodeRegistry } from '../src'
|
|
2
|
+
|
|
3
|
+
const CACHE_NODES = ['cache-get', 'cache-set', 'cache-invalidate']
|
|
4
|
+
|
|
5
|
+
const emittedFor = (nodeType: string): string => {
|
|
6
|
+
const generator = nodeRegistry[nodeType]
|
|
7
|
+
let source = generator.generateHandler()
|
|
8
|
+
if (typeof generator.generateServerHandler === 'function') {
|
|
9
|
+
source += '\n' + generator.generateServerHandler()
|
|
10
|
+
}
|
|
11
|
+
return source
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors `isFatalNodeResult` in the emitted runtime: a node result carrying
|
|
16
|
+
* `success: false`, a string `error`, or `error: true` HALTS the workflow.
|
|
17
|
+
*/
|
|
18
|
+
const runHandler = async (nodeType: string, config: Record<string, unknown>, context = {}) => {
|
|
19
|
+
const source = nodeRegistry[nodeType].generateHandler()
|
|
20
|
+
const entry = nodeType.replace(/-/g, '_')
|
|
21
|
+
// tslint:disable-next-line:function-constructor
|
|
22
|
+
const factory = new Function('globalThis', 'window', 'fetch', `${source}; return ${entry};`)
|
|
23
|
+
const fn = factory({}, undefined, () => Promise.reject(new Error('offline')))
|
|
24
|
+
return fn(config, context)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('cache workflow nodes', () => {
|
|
28
|
+
it('registers all three', () => {
|
|
29
|
+
for (const nodeType of CACHE_NODES) {
|
|
30
|
+
expect(nodeRegistry[nodeType]).toBeDefined()
|
|
31
|
+
expect(nodeRegistry[nodeType].nodeType).toBe(nodeType)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `cache-invalidate` bumps the SHARED version row, which needs the server-only
|
|
37
|
+
* secret — and being server-side puts it in the same segment as the write it
|
|
38
|
+
* follows, so a CRUD workflow pays no extra round trip for it.
|
|
39
|
+
*/
|
|
40
|
+
it('runs the purge on the server and the read/write anywhere', () => {
|
|
41
|
+
expect(nodeRegistry['cache-invalidate'].executionEnv).toBe('server')
|
|
42
|
+
expect(nodeRegistry['cache-get'].executionEnv).toBe('universal')
|
|
43
|
+
expect(nodeRegistry['cache-set'].executionEnv).toBe('universal')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handlers ship as runtime source and are re-bundled by webpack, which
|
|
48
|
+
* rewrites `require(`. The browser must also never be handed the Postgres
|
|
49
|
+
* driver by way of the server cache runtime.
|
|
50
|
+
*/
|
|
51
|
+
it('never requires a module', () => {
|
|
52
|
+
for (const nodeType of CACHE_NODES) {
|
|
53
|
+
expect(emittedFor(nodeType)).not.toMatch(/(?<![\w.])require\s*\(/)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* THE regression that would abort an admin's save. The runtime treats
|
|
59
|
+
* `success: false` / a string `error` as FATAL, so a cache that could not be
|
|
60
|
+
* cleared would halt the workflow AFTER the row was already written — no
|
|
61
|
+
* success toast, no navigation, and an error for an action that worked.
|
|
62
|
+
*/
|
|
63
|
+
describe('never reports failure in a shape the runtime treats as fatal', () => {
|
|
64
|
+
it('cache-invalidate without a configured scope', async () => {
|
|
65
|
+
const result = await runHandler('cache-invalidate', {})
|
|
66
|
+
expect(result.cleared).toBe(false)
|
|
67
|
+
expect(result.success).toBeUndefined()
|
|
68
|
+
expect(typeof result.error).not.toBe('string')
|
|
69
|
+
expect(result.error).not.toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('cache-invalidate with no secret configured', async () => {
|
|
73
|
+
const result = await runHandler('cache-invalidate', { scope: 'ds:products' })
|
|
74
|
+
expect(result.cleared).toBe(false)
|
|
75
|
+
expect(result.warning).toContain('TQ_CACHE_SECRET')
|
|
76
|
+
expect(result.success).toBeUndefined()
|
|
77
|
+
expect(typeof result.error).not.toBe('string')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('cache-set with no scope', async () => {
|
|
81
|
+
const result = await runHandler('cache-set', { key: 'k', value: 1 })
|
|
82
|
+
expect(result.stored).toBe(false)
|
|
83
|
+
expect(result.success).toBeUndefined()
|
|
84
|
+
expect(typeof result.error).not.toBe('string')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('cache-get reports a miss without any failure marker', async () => {
|
|
88
|
+
const result = await runHandler('cache-get', { scope: 'ds:products', key: 'missing' })
|
|
89
|
+
expect(result.hit).toBe(false)
|
|
90
|
+
expect(result.success).toBeUndefined()
|
|
91
|
+
expect(typeof result.error).not.toBe('string')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('round-trips a value through the workflow cache', async () => {
|
|
96
|
+
const shared = {}
|
|
97
|
+
const set = new Function(
|
|
98
|
+
'globalThis',
|
|
99
|
+
'window',
|
|
100
|
+
`${nodeRegistry['cache-set'].generateHandler()}; return cache_set;`
|
|
101
|
+
)(shared, undefined)
|
|
102
|
+
const get = new Function(
|
|
103
|
+
'globalThis',
|
|
104
|
+
'window',
|
|
105
|
+
`${nodeRegistry['cache-get'].generateHandler()}; return cache_get;`
|
|
106
|
+
)(shared, undefined)
|
|
107
|
+
|
|
108
|
+
await set({ scope: 'rates', key: 'EUR', value: { rate: 1.1 }, ttlSeconds: 60 }, {})
|
|
109
|
+
const hit = await get({ scope: 'rates', key: 'EUR' }, {})
|
|
110
|
+
|
|
111
|
+
expect(hit.hit).toBe(true)
|
|
112
|
+
expect(hit.value).toEqual({ rate: 1.1 })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reports a miss once the entry has expired', async () => {
|
|
116
|
+
const shared = {}
|
|
117
|
+
const set = new Function(
|
|
118
|
+
'globalThis',
|
|
119
|
+
'window',
|
|
120
|
+
`${nodeRegistry['cache-set'].generateHandler()}; return cache_set;`
|
|
121
|
+
)(shared, undefined)
|
|
122
|
+
const get = new Function(
|
|
123
|
+
'globalThis',
|
|
124
|
+
'window',
|
|
125
|
+
`${nodeRegistry['cache-get'].generateHandler()}; return cache_get;`
|
|
126
|
+
)(shared, undefined)
|
|
127
|
+
|
|
128
|
+
await set({ scope: 'rates', key: 'EUR', value: 1, ttlSeconds: 60 }, {})
|
|
129
|
+
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
const spy = jest.spyOn(Date, 'now').mockReturnValue(now + 61_000)
|
|
132
|
+
const miss = await get({ scope: 'rates', key: 'EUR' }, {})
|
|
133
|
+
spy.mockRestore()
|
|
134
|
+
|
|
135
|
+
expect(miss.hit).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
status: 200,
|
|
45
|
+
// The handler reads the body as TEXT and parses it itself, so a gateway
|
|
46
|
+
// error page cannot masquerade as a JSON parse failure.
|
|
47
|
+
text: async () =>
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
files: [
|
|
50
|
+
{ id: '1', name: 'a.png', url: 'https://storage.example/a.png' },
|
|
51
|
+
{ id: '2', name: 'b.txt', url: 'https://storage.example/b.txt' },
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
49
54
|
}))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Buffer.from(encoded, 'base64').toString('binary')
|
|
55
|
+
installUploadGlobals({
|
|
56
|
+
fetch: fetchMock,
|
|
57
|
+
FormData: FormDataStub,
|
|
58
|
+
Blob: NodeBlob,
|
|
59
|
+
atob: (encoded: string) => Buffer.from(encoded, 'base64').toString('binary'),
|
|
60
|
+
})
|
|
55
61
|
})
|
|
56
62
|
|
|
57
63
|
afterEach(() => {
|
|
58
|
-
|
|
59
|
-
delete (globalThis as any).FormData
|
|
60
|
-
delete (globalThis as any).Blob
|
|
61
|
-
delete (globalThis as any).atob
|
|
64
|
+
removeUploadGlobals(['fetch', 'FormData', 'Blob', 'atob'])
|
|
62
65
|
})
|
|
63
66
|
|
|
64
67
|
it('converts every PickedFile dataURL to a Blob and appends all of them', async () => {
|
|
@@ -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
|
-
|
|
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', () => {
|