@teleporthq/teleport-plugin-next-workflows 0.44.8 → 0.44.9
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/load-server-runtime.ts +6 -2
- package/__tests__/_helpers/run-account-delete-route.ts +140 -0
- package/__tests__/account-delete-current-password.test.ts +73 -0
- package/__tests__/account-delete-password-check.test.ts +113 -0
- package/__tests__/account-delete-visitor-details.test.ts +206 -0
- package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
- package/__tests__/data-api-invalid-body.test.ts +110 -0
- package/__tests__/data-node-not-awaited.test.ts +7 -6
- package/__tests__/invoice-payment-line.test.ts +68 -0
- package/__tests__/invoice-tender.test.ts +116 -0
- package/__tests__/pending-work-after-reply.test.ts +294 -0
- package/__tests__/runtime-condition-operands.test.ts +86 -0
- package/__tests__/webhook-delivery-lease.test.ts +68 -6
- package/dist/cjs/account-delete-route-generator.d.ts +7 -2
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +90 -43
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +12 -13
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +34 -1
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +13 -5
- package/dist/cjs/await-result.d.ts.map +1 -1
- package/dist/cjs/await-result.js +19 -8
- package/dist/cjs/await-result.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +9 -1
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +18 -6
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +96 -5
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.js +4 -1
- package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +21 -0
- package/dist/cjs/server-runtime-code.js.map +1 -1
- package/dist/cjs/transactional-email-code.d.ts +7 -0
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +5 -5
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/vercel-functions.d.ts +34 -0
- package/dist/cjs/vercel-functions.d.ts.map +1 -0
- package/dist/cjs/vercel-functions.js +62 -0
- package/dist/cjs/vercel-functions.js.map +1 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +3 -0
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +7 -2
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +90 -43
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +12 -13
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +34 -1
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +13 -5
- package/dist/esm/await-result.d.ts.map +1 -1
- package/dist/esm/await-result.js +18 -7
- package/dist/esm/await-result.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +9 -1
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +18 -6
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +96 -5
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.js +4 -1
- package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +21 -0
- package/dist/esm/server-runtime-code.js.map +1 -1
- package/dist/esm/transactional-email-code.d.ts +7 -0
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +5 -5
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/vercel-functions.d.ts +34 -0
- package/dist/esm/vercel-functions.d.ts.map +1 -0
- package/dist/esm/vercel-functions.js +57 -0
- package/dist/esm/vercel-functions.js.map +1 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
- package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +3 -0
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/account-delete-route-generator.ts +97 -45
- package/src/api-route-generator.ts +12 -13
- package/src/auth-generator.ts +34 -1
- package/src/await-result.ts +19 -7
- package/src/data-api-route-generator.ts +9 -1
- package/src/executor-generator.ts +18 -6
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/server-runtime-code.ts +22 -0
- package/src/transactional-email-code.ts +12 -5
- package/src/vercel-functions.ts +67 -0
- package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
- package/src/workflow-project-plugin.ts +3 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as crypto from 'crypto'
|
|
2
|
+
import { UIDLAuthentication } from '@teleporthq/teleport-types'
|
|
3
|
+
import { generateAuthOptionsFile, USER_REFRESH_INTERVAL_MS } from '../src/auth-generator'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A password change, a password reset included, signs the account out of every
|
|
7
|
+
* session opened before it. The session token carries a short fingerprint of
|
|
8
|
+
* the stored password hash (never the hash); the periodic refresh compares it
|
|
9
|
+
* with the account's current hash and ends a session whose fingerprint no
|
|
10
|
+
* longer matches. Sign-in with the new password works as before. Run on the
|
|
11
|
+
* EMITTED auth options.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const EMAIL = 'ada@example.com'
|
|
15
|
+
const FINGERPRINT = '__passwordFingerprint'
|
|
16
|
+
|
|
17
|
+
const auth = {
|
|
18
|
+
enabled: true,
|
|
19
|
+
dataSourceId: 'ds-1',
|
|
20
|
+
dataSourceType: 'postgresql',
|
|
21
|
+
passwordAuthEnabled: true,
|
|
22
|
+
providers: [],
|
|
23
|
+
roles: ['user'],
|
|
24
|
+
tables: {
|
|
25
|
+
users: [
|
|
26
|
+
{ name: 'id', type: 'UUID', nullable: false, isPrimaryKey: true },
|
|
27
|
+
{ name: 'name', type: 'VARCHAR(255)', nullable: true },
|
|
28
|
+
{ name: 'email', type: 'VARCHAR(255)', nullable: true },
|
|
29
|
+
{ name: 'password', type: 'TEXT', nullable: true },
|
|
30
|
+
{ name: 'role', type: 'VARCHAR(255)', nullable: false },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
pageProtection: {},
|
|
34
|
+
folderProtection: {},
|
|
35
|
+
authPages: {},
|
|
36
|
+
callbackBaseUrl: '/api/auth/callback',
|
|
37
|
+
envKeys: {},
|
|
38
|
+
customUserProperties: [],
|
|
39
|
+
} as unknown as UIDLAuthentication
|
|
40
|
+
|
|
41
|
+
// The stand-in for utils/auth/hash-password.js: a "hash" the test can name.
|
|
42
|
+
const hashOf = (password: string) => `hash:${password}`
|
|
43
|
+
|
|
44
|
+
const bootAuthOptions = () => {
|
|
45
|
+
const store: { row: Record<string, unknown> | null; failReads: boolean } = {
|
|
46
|
+
row: { id: 'u-1', email: EMAIL, name: 'Ada', role: 'user', password: hashOf('old secret') },
|
|
47
|
+
failReads: false,
|
|
48
|
+
}
|
|
49
|
+
class FakeClient {
|
|
50
|
+
public async connect(): Promise<void> {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
public async query(): Promise<{ rows: Array<Record<string, unknown>> }> {
|
|
54
|
+
if (store.failReads) {
|
|
55
|
+
throw new Error('connection refused')
|
|
56
|
+
}
|
|
57
|
+
return { rows: store.row ? [{ ...store.row }] : [] }
|
|
58
|
+
}
|
|
59
|
+
public async end(): Promise<void> {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const fakeRequire = (name: string): unknown => {
|
|
64
|
+
if (name === 'pg') {
|
|
65
|
+
return { Client: FakeClient }
|
|
66
|
+
}
|
|
67
|
+
if (name === 'crypto') {
|
|
68
|
+
return crypto
|
|
69
|
+
}
|
|
70
|
+
if (name === './hash-password') {
|
|
71
|
+
return { verifyPassword: (password: string, hash: string) => hash === hashOf(password) }
|
|
72
|
+
}
|
|
73
|
+
if (name === './db-health') {
|
|
74
|
+
return { classifyAuthInfrastructureError: () => 'unknown' }
|
|
75
|
+
}
|
|
76
|
+
const provider = (config: unknown) => config
|
|
77
|
+
;(provider as any).default = provider
|
|
78
|
+
return provider
|
|
79
|
+
}
|
|
80
|
+
const moduleObject: { exports: any } = { exports: {} }
|
|
81
|
+
// tslint:disable-next-line:function-constructor
|
|
82
|
+
new Function('require', 'module', 'exports', 'process', generateAuthOptionsFile(auth, null))(
|
|
83
|
+
fakeRequire,
|
|
84
|
+
moduleObject,
|
|
85
|
+
moduleObject.exports,
|
|
86
|
+
{
|
|
87
|
+
env: { TELEPORT_DB_CONNECTION_STRING: 'postgres://stub', NEXTAUTH_SECRET: 'secret-1' },
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
const authOptions = moduleObject.exports
|
|
91
|
+
const authorize = (password: string) =>
|
|
92
|
+
authOptions.providers[0].authorize({ email: EMAIL, password })
|
|
93
|
+
const signIn = async (password: string) =>
|
|
94
|
+
authOptions.callbacks.jwt({ token: {}, user: await authorize(password) })
|
|
95
|
+
// The refresh /api/auth/session runs once the token's interval has passed.
|
|
96
|
+
const refresh = (token: Record<string, unknown>) =>
|
|
97
|
+
authOptions.callbacks.jwt({
|
|
98
|
+
token: { ...token, __userRefreshedAt: Date.now() - USER_REFRESH_INTERVAL_MS - 1 },
|
|
99
|
+
})
|
|
100
|
+
return { authOptions, store, authorize, signIn, refresh }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('a password change ends the sessions opened before it', () => {
|
|
104
|
+
it('keeps a session whose password has not changed', async () => {
|
|
105
|
+
const site = bootAuthOptions()
|
|
106
|
+
const token = await site.signIn('old secret')
|
|
107
|
+
|
|
108
|
+
expect(typeof token[FINGERPRINT]).toBe('string')
|
|
109
|
+
const refreshed = await site.refresh(token)
|
|
110
|
+
expect(refreshed.email).toBe(EMAIL)
|
|
111
|
+
expect(refreshed[FINGERPRINT]).toBe(token[FINGERPRINT])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('refuses the older session at its next refresh once the hash changed', async () => {
|
|
115
|
+
const site = bootAuthOptions()
|
|
116
|
+
const before = await site.signIn('old secret')
|
|
117
|
+
|
|
118
|
+
site.store.row!.password = hashOf('new secret')
|
|
119
|
+
|
|
120
|
+
await expect(site.refresh(before)).rejects.toThrow()
|
|
121
|
+
// A profile save refreshes at once, and is refused the same way.
|
|
122
|
+
await expect(
|
|
123
|
+
site.authOptions.callbacks.jwt({ token: { ...before }, trigger: 'update' })
|
|
124
|
+
).rejects.toThrow()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('signs in with the new password, and keeps that session', async () => {
|
|
128
|
+
const site = bootAuthOptions()
|
|
129
|
+
site.store.row!.password = hashOf('new secret')
|
|
130
|
+
|
|
131
|
+
await expect(site.authorize('old secret')).rejects.toThrow('InvalidCredentials')
|
|
132
|
+
const after = await site.signIn('new secret')
|
|
133
|
+
const refreshed = await site.refresh(after)
|
|
134
|
+
expect(refreshed.email).toBe(EMAIL)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('fingerprints a session that has none at its first refresh, then holds it to that', async () => {
|
|
138
|
+
const site = bootAuthOptions()
|
|
139
|
+
const legacy = { id: 'u-1', email: EMAIL, name: 'Ada', role: 'user' }
|
|
140
|
+
|
|
141
|
+
const adopted = await site.refresh(legacy)
|
|
142
|
+
expect(typeof adopted[FINGERPRINT]).toBe('string')
|
|
143
|
+
|
|
144
|
+
site.store.row!.password = hashOf('new secret')
|
|
145
|
+
await expect(site.refresh(adopted)).rejects.toThrow()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('never carries the hash, and never shows the fingerprint on session.user', async () => {
|
|
149
|
+
const site = bootAuthOptions()
|
|
150
|
+
const token = await site.signIn('old secret')
|
|
151
|
+
|
|
152
|
+
expect(JSON.stringify(token)).not.toContain(hashOf('old secret'))
|
|
153
|
+
const session = await site.authOptions.callbacks.session({
|
|
154
|
+
session: { user: {}, expires: '' },
|
|
155
|
+
token,
|
|
156
|
+
})
|
|
157
|
+
expect(session.user.email).toBe(EMAIL)
|
|
158
|
+
expect(FINGERPRINT in session.user).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('keeps the session when the database cannot be read', async () => {
|
|
162
|
+
const site = bootAuthOptions()
|
|
163
|
+
const token = await site.signIn('old secret')
|
|
164
|
+
|
|
165
|
+
site.store.failReads = true
|
|
166
|
+
const kept = await site.refresh(token)
|
|
167
|
+
expect(kept.email).toBe(EMAIL)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('reads nothing within the refresh interval', async () => {
|
|
171
|
+
const site = bootAuthOptions()
|
|
172
|
+
const token = await site.refresh(await site.signIn('old secret'))
|
|
173
|
+
|
|
174
|
+
site.store.row!.password = hashOf('new secret')
|
|
175
|
+
const within = await site.authOptions.callbacks.jwt({ token: { ...token } })
|
|
176
|
+
expect(within.email).toBe(EMAIL)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* tslint:disable:function-constructor */
|
|
2
|
+
import { generateDataAPIRoute } from '../src/data-api-route-generator'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The generated `/api/data/<ds>/<op>` route answers a request whose body is
|
|
6
|
+
* empty or is not a JSON object with 400, without opening a database
|
|
7
|
+
* connection; it used to fail with a 500 parsing it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SECRET = 'server-secret'
|
|
11
|
+
|
|
12
|
+
interface RouteResult {
|
|
13
|
+
status: number
|
|
14
|
+
body: any
|
|
15
|
+
connections: number
|
|
16
|
+
queries: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bootRoute() {
|
|
20
|
+
const queries: string[] = []
|
|
21
|
+
let connections = 0
|
|
22
|
+
class FakeClient {
|
|
23
|
+
public async connect(): Promise<void> {
|
|
24
|
+
connections++
|
|
25
|
+
}
|
|
26
|
+
public async end(): Promise<void> {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
public async query(sql: string): Promise<{ rows: any[]; rowCount: number }> {
|
|
30
|
+
queries.push(sql)
|
|
31
|
+
if (/^SELECT COUNT/i.test(sql)) {
|
|
32
|
+
return { rows: [{ count: '0' }], rowCount: 1 }
|
|
33
|
+
}
|
|
34
|
+
return { rows: [], rowCount: 0 }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const moduleObj: { exports: any } = { exports: {} }
|
|
38
|
+
const fakeRequire = (name: string): any => {
|
|
39
|
+
if (name === 'pg') {
|
|
40
|
+
return { Client: FakeClient }
|
|
41
|
+
}
|
|
42
|
+
if (name === 'next-auth/jwt') {
|
|
43
|
+
return { getToken: async () => null }
|
|
44
|
+
}
|
|
45
|
+
throw new Error('unexpected require: ' + name)
|
|
46
|
+
}
|
|
47
|
+
new Function('require', 'module', 'exports', 'process', 'console', generateDataAPIRoute())(
|
|
48
|
+
fakeRequire,
|
|
49
|
+
moduleObj,
|
|
50
|
+
moduleObj.exports,
|
|
51
|
+
{ env: { NEXTAUTH_SECRET: SECRET, TELEPORT_DB_CONNECTION_STRING: 'postgresql://x' } },
|
|
52
|
+
{ error: () => undefined, warn: () => undefined, log: () => undefined }
|
|
53
|
+
)
|
|
54
|
+
const handler = moduleObj.exports
|
|
55
|
+
return async (body: unknown): Promise<RouteResult> => {
|
|
56
|
+
queries.length = 0
|
|
57
|
+
connections = 0
|
|
58
|
+
const result: RouteResult = { status: 0, body: null, connections: 0, queries: [] }
|
|
59
|
+
const res = {
|
|
60
|
+
status(statusCode: number) {
|
|
61
|
+
result.status = statusCode
|
|
62
|
+
return this
|
|
63
|
+
},
|
|
64
|
+
json(payload: any) {
|
|
65
|
+
result.body = payload
|
|
66
|
+
return this
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
await handler(
|
|
70
|
+
{
|
|
71
|
+
method: 'POST',
|
|
72
|
+
query: { params: ['ds-1', 'select'] },
|
|
73
|
+
headers: { 'x-internal-data-secret': SECRET },
|
|
74
|
+
body,
|
|
75
|
+
},
|
|
76
|
+
res
|
|
77
|
+
)
|
|
78
|
+
result.connections = connections
|
|
79
|
+
result.queries = queries.slice()
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('data API — a request body that is not a JSON object', () => {
|
|
85
|
+
const run = bootRoute()
|
|
86
|
+
|
|
87
|
+
it.each([
|
|
88
|
+
['an empty body', ''],
|
|
89
|
+
['text that is not JSON', 'tableName=products'],
|
|
90
|
+
['truncated JSON', '{"tableName": "prod'],
|
|
91
|
+
['no body', undefined],
|
|
92
|
+
['a JSON null', 'null'],
|
|
93
|
+
['a JSON array', '[{"tableName":"products"}]'],
|
|
94
|
+
['a parsed array', [{ tableName: 'products' }]],
|
|
95
|
+
])('answers 400 to %s, without touching the database', async (_label, body) => {
|
|
96
|
+
const result = await run(body)
|
|
97
|
+
|
|
98
|
+
expect(result.status).toBe(400)
|
|
99
|
+
expect(result.body).toEqual({ error: 'Invalid request body. Expected a JSON object.' })
|
|
100
|
+
expect(result.connections).toBe(0)
|
|
101
|
+
expect(result.queries).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('still reads a JSON object sent as a string', async () => {
|
|
105
|
+
const result = await run(JSON.stringify({ tableName: 'products' }))
|
|
106
|
+
|
|
107
|
+
expect(result.status).toBe(200)
|
|
108
|
+
expect(result.queries.some((sql) => /FROM "?products"?/i.test(sql))).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -18,8 +18,9 @@ import type { WorkflowSegment } from '../src/types'
|
|
|
18
18
|
// 1. the workflow does not wait for the query;
|
|
19
19
|
// 2. the node's context entry is `null` — never a partial or stale value;
|
|
20
20
|
// 3. a failure cannot abort the workflow (it is logged, not thrown);
|
|
21
|
-
// 4. the server route STILL
|
|
22
|
-
//
|
|
21
|
+
// 4. the server route STILL lands the query, because a serverless function
|
|
22
|
+
// may be frozen the instant it replies: on Vercel through waitUntil, after
|
|
23
|
+
// the reply; elsewhere before it (pending-work-after-reply.test.ts);
|
|
23
24
|
// 5. the client dispatches an all-fire-and-forget SEGMENT without awaiting the
|
|
24
25
|
// round trip, which is where the latency the visitor feels actually goes.
|
|
25
26
|
|
|
@@ -448,9 +449,9 @@ describe('generated server segment route', () => {
|
|
|
448
449
|
expect(route).toContain('context[node.id] = null;')
|
|
449
450
|
})
|
|
450
451
|
|
|
451
|
-
it('
|
|
452
|
-
expect(route).toContain('await utils.
|
|
453
|
-
const settleAt = route.indexOf('await utils.
|
|
452
|
+
it('hands in-flight queries off (or settles them) BEFORE responding (the platform can freeze us)', () => {
|
|
453
|
+
expect(route).toContain('await utils.handOffPendingNodePromises(context);')
|
|
454
|
+
const settleAt = route.indexOf('await utils.handOffPendingNodePromises(context);')
|
|
454
455
|
const respondAt = route.indexOf(
|
|
455
456
|
'res.status(200).json({ success: true, results: utils.segmentReply(SEGMENT_CONFIG, context, __incomingSnapshot) });'
|
|
456
457
|
)
|
|
@@ -464,7 +465,7 @@ describe('generated server segment route', () => {
|
|
|
464
465
|
|
|
465
466
|
it('drains even when the segment throws', () => {
|
|
466
467
|
expect(route).toContain(
|
|
467
|
-
'if (__wfContext) { await utils.
|
|
468
|
+
'if (__wfContext) { await utils.handOffPendingNodePromises(__wfContext); }'
|
|
468
469
|
)
|
|
469
470
|
})
|
|
470
471
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
|
|
2
|
+
import { generatePdfGeneratorCode } from '../src/invoice/pdf-generator-code'
|
|
3
|
+
import { evaluateEmittedModule } from './_helpers/load-invoice-route'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The invoice's payment line says how the order was paid in words, with the
|
|
7
|
+
* provider that took the money ("Card (Stripe)", "Cash on delivery", "Paid
|
|
8
|
+
* online" for a booking), and the provider's payment id as "Ref. pi_…". It
|
|
9
|
+
* used to print the stored ids ("card", "cash_on_delivery", "pi_…") as they
|
|
10
|
+
* are. Run on the EMITTED renderer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SETTINGS = {
|
|
14
|
+
invoicePrefix: 'INV-',
|
|
15
|
+
defaultTaxRate: 0,
|
|
16
|
+
companyDetails: {},
|
|
17
|
+
template: { document: null as unknown },
|
|
18
|
+
tables: { invoicesTable: 'teleport_invoices', invoiceItemsTable: 'teleport_invoice_items' },
|
|
19
|
+
emailDelivery: { enabled: false },
|
|
20
|
+
} as unknown as UIDLInvoiceSettings
|
|
21
|
+
|
|
22
|
+
interface PdfGeneratorModule {
|
|
23
|
+
buildInvoiceDataScope: (invoiceData: Record<string, unknown>) => {
|
|
24
|
+
invoiceData: { Payment: Record<string, string> }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const renderer = evaluateEmittedModule<PdfGeneratorModule>(
|
|
29
|
+
generatePdfGeneratorCode(SETTINGS, null, null),
|
|
30
|
+
require
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const paymentOf = (invoice: Record<string, unknown>) =>
|
|
34
|
+
renderer.buildInvoiceDataScope({ items: [], ...invoice }).invoiceData.Payment
|
|
35
|
+
|
|
36
|
+
describe('the invoice payment line', () => {
|
|
37
|
+
it.each([
|
|
38
|
+
['card', 'stripe', 'Card (Stripe)'],
|
|
39
|
+
['card', '', 'Card'],
|
|
40
|
+
['online', '', 'Paid online'],
|
|
41
|
+
['online', 'mollie', 'Paid online (Mollie)'],
|
|
42
|
+
['cash_on_delivery', '', 'Cash on delivery'],
|
|
43
|
+
['cash_on_delivery', 'cash_on_delivery', 'Cash on delivery'],
|
|
44
|
+
['cod', '', 'Cash on delivery'],
|
|
45
|
+
['cash', '', 'Cash'],
|
|
46
|
+
['store_pickup', '', 'Paid at pickup'],
|
|
47
|
+
['gift_card', '', 'Gift card'],
|
|
48
|
+
['bank_transfer', '', 'Bank transfer'],
|
|
49
|
+
['paypal', '', 'Paid online (PayPal)'],
|
|
50
|
+
['stripe', '', 'Paid online (Stripe)'],
|
|
51
|
+
['', 'coingate', 'Paid online (CoinGate)'],
|
|
52
|
+
['apple_pay', '', 'Apple pay'],
|
|
53
|
+
['', '', ''],
|
|
54
|
+
])('prints method %j with provider %j as %j', (method, provider, label) => {
|
|
55
|
+
expect(paymentOf({ paymentMethod: method, paymentProvider: provider }).method).toBe(label)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('names the provider in words', () => {
|
|
59
|
+
expect(paymentOf({ paymentProvider: 'paypal' }).provider).toBe('PayPal')
|
|
60
|
+
expect(paymentOf({ paymentProvider: '' }).provider).toBe('')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('prints the payment id as a reference, and nothing without one', () => {
|
|
64
|
+
expect(paymentOf({ paymentIntentId: 'pi_3Nabc' }).intentId).toBe('Ref. pi_3Nabc')
|
|
65
|
+
expect(paymentOf({ paymentIntentId: '' }).intentId).toBe('')
|
|
66
|
+
expect(paymentOf({}).intentId).toBe('')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -387,6 +387,122 @@ describe('invoice PDF renderer — vouchers and the gift-card tender', () => {
|
|
|
387
387
|
expect(voucherHtml).not.toContain('Amount due:')
|
|
388
388
|
})
|
|
389
389
|
|
|
390
|
+
describe('an order paid partly by gift card and the rest by card', () => {
|
|
391
|
+
// 96.00 in all: 40.00 from the gift card, 56.00 charged to the buyer's card
|
|
392
|
+
// or collected by the courier.
|
|
393
|
+
const splitTender: Row = {
|
|
394
|
+
...giftCardInvoice,
|
|
395
|
+
giftCardAmount: 40,
|
|
396
|
+
amountDue: 56,
|
|
397
|
+
}
|
|
398
|
+
const paidSplitTender: Row = { ...splitTender, status: 'paid', paidAt: '2026-10-20' }
|
|
399
|
+
// Cash on delivery the courier has not collected yet.
|
|
400
|
+
const unpaidSplitTender: Row = { ...splitTender, status: 'issued' }
|
|
401
|
+
|
|
402
|
+
const textNode = (children: unknown[]) => ({
|
|
403
|
+
type: 'element',
|
|
404
|
+
content: { elementType: 'text', semanticType: 'p', children },
|
|
405
|
+
})
|
|
406
|
+
const totalsLine = (label: string, valuePath: string) => ({
|
|
407
|
+
type: 'element',
|
|
408
|
+
content: {
|
|
409
|
+
elementType: 'container',
|
|
410
|
+
children: [
|
|
411
|
+
textNode([{ type: 'static', content: label }]),
|
|
412
|
+
textNode([{ type: 'expr', content: `invoiceData?.Invoice?.${valuePath}` }]),
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
const giftCardGated = (line: unknown) => ({
|
|
417
|
+
type: 'conditional',
|
|
418
|
+
content: {
|
|
419
|
+
reference: { type: 'expr', content: 'invoiceData?.Invoice?.hasGiftCard' },
|
|
420
|
+
condition: { conditions: [{ operation: '=', operand: 'true' }] },
|
|
421
|
+
node: line,
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
// The editor's default layout: the grand total, then a block holding the
|
|
425
|
+
// gift-card line and the amount-due line, both shown when a card was used.
|
|
426
|
+
const editorTemplate = loadPdfGenerator({
|
|
427
|
+
name: 'invoice-template',
|
|
428
|
+
node: {
|
|
429
|
+
type: 'element',
|
|
430
|
+
content: {
|
|
431
|
+
elementType: 'container',
|
|
432
|
+
children: [
|
|
433
|
+
totalsLine('Total:', 'total'),
|
|
434
|
+
{
|
|
435
|
+
type: 'element',
|
|
436
|
+
content: {
|
|
437
|
+
elementType: 'container',
|
|
438
|
+
children: [
|
|
439
|
+
giftCardGated(totalsLine('Paid by gift card:', 'giftCardAmount')),
|
|
440
|
+
giftCardGated(totalsLine('Amount due:', 'amountDue')),
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('prints no amount due on the PDF once the order is paid, and keeps the gift card line', () => {
|
|
450
|
+
const html = editorTemplate.buildInvoiceHtml(paidSplitTender)
|
|
451
|
+
expect(html).toContain('<p>Total:</p><p>$96.00</p>')
|
|
452
|
+
expect(html).toContain('<p>Paid by gift card:</p><p>$40.00</p>')
|
|
453
|
+
expect(html).not.toContain('Amount due')
|
|
454
|
+
expect(html).not.toContain('$56.00')
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('still prints the real amount due while the rest is not collected', () => {
|
|
458
|
+
const html = editorTemplate.buildInvoiceHtml(unpaidSplitTender)
|
|
459
|
+
expect(html).toContain('<p>Paid by gift card:</p><p>$40.00</p>')
|
|
460
|
+
expect(html).toContain('<p>Amount due:</p><p>$56.00</p>')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('keeps a block of the merchant that holds the amount due beside another field, owing nothing', () => {
|
|
464
|
+
const merchantTemplate = loadPdfGenerator({
|
|
465
|
+
name: 'invoice-template',
|
|
466
|
+
node: {
|
|
467
|
+
type: 'element',
|
|
468
|
+
content: {
|
|
469
|
+
elementType: 'container',
|
|
470
|
+
children: [
|
|
471
|
+
textNode([
|
|
472
|
+
{ type: 'static', content: 'Total ' },
|
|
473
|
+
{ type: 'expr', content: 'invoiceData?.Invoice?.total' },
|
|
474
|
+
]),
|
|
475
|
+
textNode([
|
|
476
|
+
{ type: 'static', content: 'Balance ' },
|
|
477
|
+
{ type: 'expr', content: 'invoiceData?.Invoice?.amountDue' },
|
|
478
|
+
]),
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
const html = merchantTemplate.buildInvoiceHtml(paidSplitTender)
|
|
484
|
+
expect(html).toContain('<p>Total $96.00</p><p>Balance $0.00</p>')
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('prints no amount due in the fallback HTML once the order is paid', () => {
|
|
488
|
+
const paidHtml = pdfGenerator.buildInvoiceHtml(paidSplitTender)
|
|
489
|
+
expect(paidHtml).toContain('<span>Paid by gift card ••••1234</span><span>−$40.00</span>')
|
|
490
|
+
expect(paidHtml).not.toContain('Amount due')
|
|
491
|
+
|
|
492
|
+
const unpaidHtml = pdfGenerator.buildInvoiceHtml(unpaidSplitTender)
|
|
493
|
+
expect(unpaidHtml).toContain('<span>Paid by gift card ••••1234</span><span>−$40.00</span>')
|
|
494
|
+
expect(unpaidHtml).toContain('<span>Amount due</span><span>$56.00</span>')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('leaves the amount due out of the email of the paid order as well', () => {
|
|
498
|
+
const email = pdfGenerator.replacePlaceholders(
|
|
499
|
+
'<p>Paid by gift card −{{giftCardAmount}}</p><p>Amount due {{amountDue}}</p>',
|
|
500
|
+
pdfGenerator.buildDataContext(paidSplitTender)
|
|
501
|
+
)
|
|
502
|
+
expect(email).toBe('<p>Paid by gift card −$40.00</p>')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
390
506
|
it('fills the {{amountDue}}, {{giftCardAmount}} and {{discountAmount}} merge tokens', () => {
|
|
391
507
|
const giftCard = pdfGenerator.buildDataContext(giftCardInvoice)
|
|
392
508
|
expect(
|