@teleporthq/teleport-plugin-next-workflows 0.44.8 → 0.44.10
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__/ai-error-contract.test.ts +10 -0
- package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
- package/__tests__/continue-on-error.test.ts +27 -0
- package/__tests__/data-api-invalid-body.test.ts +110 -0
- package/__tests__/data-node-not-awaited.test.ts +7 -6
- package/__tests__/email-node-recipients.test.ts +288 -0
- 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__/sent-email-log-module.test.ts +54 -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 +22 -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/email/email-mailersend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailersend.js +55 -48
- package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +44 -32
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +44 -31
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-recipients.d.ts +28 -0
- package/dist/cjs/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/cjs/nodes/email/email-recipients.js +74 -0
- package/dist/cjs/nodes/email/email-recipients.js.map +1 -0
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +42 -28
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +56 -41
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/cjs/sent-email-log/sent-email-log-code.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 +22 -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/email/email-mailersend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailersend.js +55 -48
- package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +44 -32
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +44 -31
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-recipients.d.ts +28 -0
- package/dist/esm/nodes/email/email-recipients.d.ts.map +1 -0
- package/dist/esm/nodes/email/email-recipients.js +71 -0
- package/dist/esm/nodes/email/email-recipients.js.map +1 -0
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +42 -28
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +56 -41
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
- package/dist/esm/sent-email-log/sent-email-log-code.js +17 -1
- package/dist/esm/sent-email-log/sent-email-log-code.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 +22 -7
- package/src/invoice/invoice-html-code.ts +96 -5
- package/src/nodes/account/account-delete-current.ts +4 -1
- package/src/nodes/email/email-mailersend.ts +72 -55
- package/src/nodes/email/email-mailgun.ts +51 -36
- package/src/nodes/email/email-postmark.ts +51 -35
- package/src/nodes/email/email-recipients.ts +86 -0
- package/src/nodes/email/email-resend.ts +49 -31
- package/src/nodes/email/email-sendgrid.ts +63 -44
- package/src/sent-email-log/sent-email-log-code.ts +17 -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,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
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { generateSharedRuntimeUtilsCode } from '../src'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The editor's workflow templates (delete account, password reset request,
|
|
5
|
+
* bookings, reviews, social, loyalty) write an if-statement's sides as
|
|
6
|
+
* `leftOperand` / `rightOperand` with the operator `'='`. The runtime read only
|
|
7
|
+
* `leftValue` / `rightValue`, so each of those checks compared undefined with
|
|
8
|
+
* undefined and always took its "true" branch. Both spellings are read now,
|
|
9
|
+
* and `'='` is equality.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type SharedUtils = {
|
|
13
|
+
resolveConfig: (cfg: unknown, ctx: Record<string, unknown>) => Record<string, unknown>
|
|
14
|
+
evaluateCondition: (cfg: unknown, ctx: Record<string, unknown>) => boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadSharedRuntime(): SharedUtils {
|
|
18
|
+
const wrapper: { exports: Record<string, unknown> } = { exports: {} }
|
|
19
|
+
// tslint:disable-next-line:function-constructor
|
|
20
|
+
new Function('module', 'exports', generateSharedRuntimeUtilsCode())(wrapper, wrapper.exports)
|
|
21
|
+
return wrapper.exports as unknown as SharedUtils
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ref = (nodeId: string, ...path: string[]) => ({
|
|
25
|
+
type: 'workflowContext',
|
|
26
|
+
nodeId,
|
|
27
|
+
path: [nodeId, ...path],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// The "Is Password Correct?" step of the editor's delete-account template.
|
|
31
|
+
const isPasswordCorrect = {
|
|
32
|
+
conditionType: 'simple-comparison',
|
|
33
|
+
leftOperand: ref('verify-password', 'isMatch'),
|
|
34
|
+
operator: '=',
|
|
35
|
+
rightOperand: true,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('an if-statement written with leftOperand / rightOperand', () => {
|
|
39
|
+
const utils = loadSharedRuntime()
|
|
40
|
+
// What every route does with an if-statement node: resolve, then evaluate.
|
|
41
|
+
const evaluate = (config: unknown, context: Record<string, unknown>) =>
|
|
42
|
+
utils.evaluateCondition(utils.resolveConfig(config, context), context)
|
|
43
|
+
|
|
44
|
+
it('takes the branch its operands choose', () => {
|
|
45
|
+
expect(evaluate(isPasswordCorrect, { 'verify-password': { isMatch: true } })).toBe(true)
|
|
46
|
+
expect(evaluate(isPasswordCorrect, { 'verify-password': { isMatch: false } })).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('reads a missing operand as missing, not as equal to the other side', () => {
|
|
50
|
+
expect(evaluate(isPasswordCorrect, {})).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("treats '=' as equality without warning about the operator", () => {
|
|
54
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
55
|
+
try {
|
|
56
|
+
expect(evaluate({ leftOperand: 'a', operator: '=', rightOperand: 'a' }, {})).toBe(true)
|
|
57
|
+
expect(evaluate({ leftOperand: 'a', operator: '=', rightOperand: 'b' }, {})).toBe(false)
|
|
58
|
+
expect(evaluate({ leftValue: 3, operator: '=', rightValue: '3' }, {})).toBe(true)
|
|
59
|
+
expect(warn).not.toHaveBeenCalled()
|
|
60
|
+
} finally {
|
|
61
|
+
warn.mockRestore()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('applies inside multiple conditions', () => {
|
|
66
|
+
const both = {
|
|
67
|
+
conditionType: 'multiple-conditions',
|
|
68
|
+
logicOperator: 'AND',
|
|
69
|
+
conditions: [
|
|
70
|
+
{ leftOperand: ref('form', 'agreed'), operator: '=', rightOperand: true },
|
|
71
|
+
{ leftOperand: ref('form', 'email'), operator: 'is-not-empty' },
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
expect(evaluate(both, { form: { agreed: true, email: 'a@b.c' } })).toBe(true)
|
|
75
|
+
expect(evaluate(both, { form: { agreed: false, email: 'a@b.c' } })).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps leftValue / rightValue first when a config carries both', () => {
|
|
79
|
+
expect(
|
|
80
|
+
evaluate(
|
|
81
|
+
{ leftValue: 'x', leftOperand: 'y', operator: '===', rightValue: 'x', rightOperand: 'z' },
|
|
82
|
+
{}
|
|
83
|
+
)
|
|
84
|
+
).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -340,6 +340,60 @@ describe('sent-email-log runtime module', () => {
|
|
|
340
340
|
}
|
|
341
341
|
})
|
|
342
342
|
|
|
343
|
+
it('writes one row per delivery when the node reached its recipients one call each', async () => {
|
|
344
|
+
const { api, queries, restore } = loadModule({})
|
|
345
|
+
try {
|
|
346
|
+
const wrapped = api.wrapEmailNodeHandler('email-resend', async () => ({
|
|
347
|
+
id: 're-1',
|
|
348
|
+
success: true,
|
|
349
|
+
error: 'refused for blocked@lumen.test (only the owner)',
|
|
350
|
+
deliveries: [
|
|
351
|
+
{ to: 'jane@lumen.test', success: true, id: 're-1' },
|
|
352
|
+
{ to: 'blocked@lumen.test', success: false, error: 'only the owner' },
|
|
353
|
+
{ to: 'ana@lumen.test', success: true, id: 're-3' },
|
|
354
|
+
],
|
|
355
|
+
}))
|
|
356
|
+
const context: any = {}
|
|
357
|
+
await wrapped(
|
|
358
|
+
{
|
|
359
|
+
to: ['jane@lumen.test', 'blocked@lumen.test', 'ana@lumen.test'],
|
|
360
|
+
cc: 'ops@lumen.test',
|
|
361
|
+
from: 'shop@acme.com',
|
|
362
|
+
subject: 'Hi',
|
|
363
|
+
body: '<p>x</p>',
|
|
364
|
+
emailPurpose: 'welcome',
|
|
365
|
+
emailAudience: 'customer',
|
|
366
|
+
},
|
|
367
|
+
context
|
|
368
|
+
)
|
|
369
|
+
expect(context.__pendingNodePromises).toHaveLength(3)
|
|
370
|
+
await Promise.all(context.__pendingNodePromises)
|
|
371
|
+
const rows = queries.map(rowOf)
|
|
372
|
+
expect(
|
|
373
|
+
rows.map((row) => [
|
|
374
|
+
row.recipient_email,
|
|
375
|
+
row.recipients,
|
|
376
|
+
row.status,
|
|
377
|
+
row.provider_message_id,
|
|
378
|
+
row.error_message,
|
|
379
|
+
])
|
|
380
|
+
).toEqual([
|
|
381
|
+
['jane@lumen.test', '["jane@lumen.test"]', 'sent', 're-1', null],
|
|
382
|
+
['blocked@lumen.test', '["blocked@lumen.test"]', 'failed', null, 'only the owner'],
|
|
383
|
+
['ana@lumen.test', '["ana@lumen.test"]', 'sent', 're-3', null],
|
|
384
|
+
])
|
|
385
|
+
for (const row of rows) {
|
|
386
|
+
expect(row.cc).toBe('["ops@lumen.test"]')
|
|
387
|
+
expect(row.email_type).toBe('welcome')
|
|
388
|
+
expect(row.audience).toBe('customer')
|
|
389
|
+
expect(row.provider).toBe('resend')
|
|
390
|
+
expect(row.source_ref).toBe('email-resend')
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
restore()
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
343
397
|
it('records an integration node only for its send-email action, and forwards extra arguments', async () => {
|
|
344
398
|
const { api, queries, restore } = loadModule({})
|
|
345
399
|
try {
|