@teleporthq/teleport-plugin-next-workflows 0.44.7 → 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__/credential-result-reply.test.ts +509 -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 +17 -15
- 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/credential-results.d.ts +18 -0
- package/dist/cjs/credential-results.d.ts.map +1 -0
- package/dist/cjs/credential-results.js +72 -0
- package/dist/cjs/credential-results.js.map +1 -0
- 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 +26 -7
- 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/nodes/account/account-hash-password.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-hash-password.js +1 -0
- package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
- package/dist/cjs/nodes/types.d.ts +6 -0
- package/dist/cjs/nodes/types.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts +8 -0
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +127 -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 +17 -15
- 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/credential-results.d.ts +18 -0
- package/dist/esm/credential-results.d.ts.map +1 -0
- package/dist/esm/credential-results.js +68 -0
- package/dist/esm/credential-results.js.map +1 -0
- 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 +26 -7
- 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/nodes/account/account-hash-password.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-hash-password.js +1 -0
- package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
- package/dist/esm/nodes/types.d.ts +6 -0
- package/dist/esm/nodes/types.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.d.ts +8 -0
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +127 -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 +17 -15
- package/src/auth-generator.ts +34 -1
- package/src/await-result.ts +19 -7
- package/src/credential-results.ts +71 -0
- package/src/data-api-route-generator.ts +9 -1
- package/src/executor-generator.ts +26 -7
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/nodes/account/account-hash-password.ts +1 -0
- package/src/nodes/types.ts +6 -0
- package/src/server-runtime-code.ts +128 -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,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(
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { generateServerSegmentAPIRoute } from '../src/api-route-generator'
|
|
2
|
+
import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
|
|
3
|
+
import { isFireAndForgetNode } from '../src/await-result'
|
|
4
|
+
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
|
|
5
|
+
import { VERCEL_FUNCTIONS_PACKAGE, VERCEL_FUNCTIONS_VERSION } from '../src/vercel-functions'
|
|
6
|
+
import type { WorkflowSegment } from '../src/types'
|
|
7
|
+
import { loadServerRuntime } from './_helpers/load-server-runtime'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A step the author chose not to wait for still has to finish on the server,
|
|
11
|
+
* but the visitor no longer waits for it: a cash order's invoice (rendered,
|
|
12
|
+
* stored and mailed, 8–14 s) runs on after the segment has replied. On Vercel
|
|
13
|
+
* the route hands that work to waitUntil; anywhere else it awaits it before
|
|
14
|
+
* replying, as before.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const REQUEST_CONTEXT = Symbol.for('@vercel/request-context')
|
|
18
|
+
|
|
19
|
+
const invoiceNode = (awaitResult?: boolean) => ({
|
|
20
|
+
id: 'invoice',
|
|
21
|
+
type: 'ecommerce-generate-invoice',
|
|
22
|
+
label: 'Generate Invoice',
|
|
23
|
+
stepNumber: 1,
|
|
24
|
+
config: { orderId: 'order-1', ...(awaitResult === undefined ? {} : { awaitResult }) },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const segmentOf = (node: ReturnType<typeof invoiceNode>): WorkflowSegment =>
|
|
28
|
+
({
|
|
29
|
+
id: 'seg-cash',
|
|
30
|
+
env: 'server',
|
|
31
|
+
nodeIds: [node.id],
|
|
32
|
+
nodes: [node],
|
|
33
|
+
edges: [],
|
|
34
|
+
} as unknown as WorkflowSegment)
|
|
35
|
+
|
|
36
|
+
const deferred = () => {
|
|
37
|
+
let resolve: () => void = () => undefined
|
|
38
|
+
const promise = new Promise<void>((done) => {
|
|
39
|
+
resolve = done
|
|
40
|
+
})
|
|
41
|
+
return { promise, resolve }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const settleMicrotasks = () => new Promise((done) => setTimeout(done, 20))
|
|
45
|
+
|
|
46
|
+
interface Reply {
|
|
47
|
+
status: number
|
|
48
|
+
body: any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const originalFetch = globalThis.fetch
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
jest.spyOn(console, 'info').mockImplementation(() => undefined)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
jest.restoreAllMocks()
|
|
59
|
+
globalThis.fetch = originalFetch
|
|
60
|
+
delete (globalThis as Record<symbol, unknown>)[REQUEST_CONTEXT]
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Boots the generated segment route with `modules` installed, and an invoice
|
|
65
|
+
* route that answers only once `invoiceGate` opens.
|
|
66
|
+
*/
|
|
67
|
+
const bootRoute = (node: ReturnType<typeof invoiceNode>, modules: Record<string, unknown>) => {
|
|
68
|
+
const invoiceGate = deferred()
|
|
69
|
+
const progress = { invoiceFinished: false }
|
|
70
|
+
globalThis.fetch = (async () => {
|
|
71
|
+
await invoiceGate.promise
|
|
72
|
+
progress.invoiceFinished = true
|
|
73
|
+
return { ok: true, status: 200, json: async () => ({ invoiceId: 'inv-1' }) }
|
|
74
|
+
}) as unknown as typeof fetch
|
|
75
|
+
|
|
76
|
+
const runtime = loadServerRuntime(modules)
|
|
77
|
+
const routeModule: { exports: unknown } = { exports: {} }
|
|
78
|
+
// tslint:disable-next-line:function-constructor
|
|
79
|
+
new Function('module', 'exports', 'require', generateServerSegmentAPIRoute(segmentOf(node)))(
|
|
80
|
+
routeModule,
|
|
81
|
+
routeModule.exports,
|
|
82
|
+
(id: string) => {
|
|
83
|
+
if (id.endsWith('server-runtime')) {
|
|
84
|
+
return runtime
|
|
85
|
+
}
|
|
86
|
+
throw new Error('Unexpected require: ' + id)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
const handler = routeModule.exports as (req: unknown, res: unknown) => Promise<void>
|
|
90
|
+
const reply: Reply = { status: 0, body: undefined }
|
|
91
|
+
const res = {
|
|
92
|
+
status(code: number) {
|
|
93
|
+
reply.status = code
|
|
94
|
+
return this
|
|
95
|
+
},
|
|
96
|
+
json(payload: unknown) {
|
|
97
|
+
reply.body = payload
|
|
98
|
+
return this
|
|
99
|
+
},
|
|
100
|
+
setHeader: (): void => undefined,
|
|
101
|
+
}
|
|
102
|
+
const handled = handler(
|
|
103
|
+
{ method: 'POST', body: { context: {} }, headers: { host: 'shop.example' } },
|
|
104
|
+
res
|
|
105
|
+
)
|
|
106
|
+
return { handled, reply, progress, openInvoiceGate: invoiceGate.resolve }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const onVercel = () => {
|
|
110
|
+
;(globalThis as Record<symbol, unknown>)[REQUEST_CONTEXT] = {
|
|
111
|
+
get: () => ({ waitUntil: (): void => undefined }),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fakeVercelFunctions = () => {
|
|
116
|
+
const handedOff: Array<Promise<unknown>> = []
|
|
117
|
+
return {
|
|
118
|
+
handedOff,
|
|
119
|
+
module: {
|
|
120
|
+
waitUntil: (promise: Promise<unknown>) => {
|
|
121
|
+
handedOff.push(promise)
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('the invoice step may be left not awaited', () => {
|
|
128
|
+
it('is fire-and-forget only on an explicit awaitResult: false', () => {
|
|
129
|
+
expect(isFireAndForgetNode(invoiceNode(false) as never)).toBe(true)
|
|
130
|
+
expect(isFireAndForgetNode(invoiceNode(true) as never)).toBe(false)
|
|
131
|
+
expect(isFireAndForgetNode(invoiceNode() as never)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('is mirrored inside the generated runtime', () => {
|
|
135
|
+
const shared: { exports: Record<string, any> } = { exports: {} }
|
|
136
|
+
// tslint:disable-next-line:function-constructor
|
|
137
|
+
new Function('module', 'exports', generateSharedRuntimeUtilsCode())(shared, shared.exports)
|
|
138
|
+
expect(shared.exports.isFireAndForgetNode(invoiceNode(false))).toBe(true)
|
|
139
|
+
expect(shared.exports.isFireAndForgetNode(invoiceNode())).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('a segment route with a not-awaited step', () => {
|
|
144
|
+
it('replies on Vercel before the step finishes, and hands the step to waitUntil', async () => {
|
|
145
|
+
onVercel()
|
|
146
|
+
const vercel = fakeVercelFunctions()
|
|
147
|
+
const run = bootRoute(invoiceNode(false), { [VERCEL_FUNCTIONS_PACKAGE]: vercel.module })
|
|
148
|
+
|
|
149
|
+
const outcome = await Promise.race([
|
|
150
|
+
run.handled.then(() => 'replied'),
|
|
151
|
+
settleMicrotasks().then(() => 'still waiting'),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
expect(outcome).toBe('replied')
|
|
155
|
+
expect(run.reply.status).toBe(200)
|
|
156
|
+
expect(run.reply.body.success).toBe(true)
|
|
157
|
+
expect(run.progress.invoiceFinished).toBe(false)
|
|
158
|
+
expect(vercel.handedOff).toHaveLength(1)
|
|
159
|
+
|
|
160
|
+
run.openInvoiceGate()
|
|
161
|
+
await vercel.handedOff[0]
|
|
162
|
+
expect(run.progress.invoiceFinished).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('waits for the step before replying outside a Vercel request', async () => {
|
|
166
|
+
const vercel = fakeVercelFunctions()
|
|
167
|
+
const run = bootRoute(invoiceNode(false), { [VERCEL_FUNCTIONS_PACKAGE]: vercel.module })
|
|
168
|
+
|
|
169
|
+
await settleMicrotasks()
|
|
170
|
+
expect(run.reply.status).toBe(0)
|
|
171
|
+
|
|
172
|
+
run.openInvoiceGate()
|
|
173
|
+
await run.handled
|
|
174
|
+
expect(run.progress.invoiceFinished).toBe(true)
|
|
175
|
+
expect(run.reply.status).toBe(200)
|
|
176
|
+
expect(vercel.handedOff).toHaveLength(0)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('waits for the step before replying when @vercel/functions is not installed', async () => {
|
|
180
|
+
onVercel()
|
|
181
|
+
const run = bootRoute(invoiceNode(false), {})
|
|
182
|
+
|
|
183
|
+
await settleMicrotasks()
|
|
184
|
+
expect(run.reply.status).toBe(0)
|
|
185
|
+
|
|
186
|
+
run.openInvoiceGate()
|
|
187
|
+
await run.handled
|
|
188
|
+
expect(run.progress.invoiceFinished).toBe(true)
|
|
189
|
+
expect(run.reply.status).toBe(200)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('still runs an awaited invoice step before replying, on Vercel too', async () => {
|
|
193
|
+
onVercel()
|
|
194
|
+
const vercel = fakeVercelFunctions()
|
|
195
|
+
const run = bootRoute(invoiceNode(), { [VERCEL_FUNCTIONS_PACKAGE]: vercel.module })
|
|
196
|
+
|
|
197
|
+
await settleMicrotasks()
|
|
198
|
+
expect(run.reply.status).toBe(0)
|
|
199
|
+
|
|
200
|
+
run.openInvoiceGate()
|
|
201
|
+
await run.handled
|
|
202
|
+
expect(run.reply.body.results.invoice.invoiceId).toBe('inv-1')
|
|
203
|
+
expect(vercel.handedOff).toHaveLength(0)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('the generated project declares @vercel/functions', () => {
|
|
208
|
+
const generate = async (
|
|
209
|
+
workflows: Record<string, unknown>,
|
|
210
|
+
dependencies: Record<string, string> = {}
|
|
211
|
+
) => {
|
|
212
|
+
const structure: any = {
|
|
213
|
+
uidl: {
|
|
214
|
+
name: 'Store',
|
|
215
|
+
globals: { settings: { title: 'Store', language: 'en' }, env: {} },
|
|
216
|
+
root: {
|
|
217
|
+
name: 'Root',
|
|
218
|
+
stateDefinitions: {
|
|
219
|
+
route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
|
|
220
|
+
},
|
|
221
|
+
node: { type: 'element', content: { elementType: 'container', children: [] } },
|
|
222
|
+
},
|
|
223
|
+
components: {},
|
|
224
|
+
workflows: { workflows },
|
|
225
|
+
},
|
|
226
|
+
strategy: { pages: { options: {} } },
|
|
227
|
+
files: new Map(),
|
|
228
|
+
dependencies: { ...dependencies },
|
|
229
|
+
devDependencies: {},
|
|
230
|
+
}
|
|
231
|
+
await new NextWorkflowProjectPlugin().runAfter(structure)
|
|
232
|
+
return structure
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const cronWorkflow = {
|
|
236
|
+
id: 'wf-cron',
|
|
237
|
+
name: 'Nightly count',
|
|
238
|
+
trigger: {
|
|
239
|
+
type: 'event-cron-triggered',
|
|
240
|
+
nodeId: 't',
|
|
241
|
+
scope: 'global',
|
|
242
|
+
config: { schedule: '0 * * * *', urlPath: 'cron_nightly' },
|
|
243
|
+
},
|
|
244
|
+
nodes: [
|
|
245
|
+
{
|
|
246
|
+
id: 'count',
|
|
247
|
+
type: 'data-count',
|
|
248
|
+
config: { dataSourceId: 'ds-1', tableName: 'orders', filters: [] },
|
|
249
|
+
stepNumber: 1,
|
|
250
|
+
label: 'Count orders',
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
edges: [{ id: 'e1', source: 't', target: 'count' }],
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const filesRequiringIt = (structure: any): string[] => {
|
|
257
|
+
const names: string[] = []
|
|
258
|
+
for (const record of structure.files.values()) {
|
|
259
|
+
for (const file of record.files || []) {
|
|
260
|
+
if (
|
|
261
|
+
typeof file.content === 'string' &&
|
|
262
|
+
file.content.includes(`require('${VERCEL_FUNCTIONS_PACKAGE}')`)
|
|
263
|
+
) {
|
|
264
|
+
names.push([...record.path, file.name].join('/'))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return names
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it('when an emitted file requires it', async () => {
|
|
272
|
+
const structure = await generate({ [cronWorkflow.id]: cronWorkflow })
|
|
273
|
+
|
|
274
|
+
expect(filesRequiringIt(structure)).toContain('utils/workflows/server-runtime')
|
|
275
|
+
expect(structure.dependencies[VERCEL_FUNCTIONS_PACKAGE]).toBe(VERCEL_FUNCTIONS_VERSION)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('keeps a version the project already declares', async () => {
|
|
279
|
+
const structure = await generate(
|
|
280
|
+
{ [cronWorkflow.id]: cronWorkflow },
|
|
281
|
+
{ [VERCEL_FUNCTIONS_PACKAGE]: '^2.2.10' }
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
expect(filesRequiringIt(structure)).toContain('utils/workflows/server-runtime')
|
|
285
|
+
expect(structure.dependencies[VERCEL_FUNCTIONS_PACKAGE]).toBe('^2.2.10')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('never without a file that requires it', async () => {
|
|
289
|
+
const structure = await generate({})
|
|
290
|
+
|
|
291
|
+
expect(filesRequiringIt(structure)).toEqual([])
|
|
292
|
+
expect(structure.dependencies[VERCEL_FUNCTIONS_PACKAGE]).toBeUndefined()
|
|
293
|
+
})
|
|
294
|
+
})
|