@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.
Files changed (172) hide show
  1. package/__tests__/_helpers/load-server-runtime.ts +6 -2
  2. package/__tests__/_helpers/run-account-delete-route.ts +140 -0
  3. package/__tests__/account-delete-current-password.test.ts +73 -0
  4. package/__tests__/account-delete-password-check.test.ts +113 -0
  5. package/__tests__/account-delete-visitor-details.test.ts +206 -0
  6. package/__tests__/ai-error-contract.test.ts +10 -0
  7. package/__tests__/auth-password-change-ends-sessions.test.ts +178 -0
  8. package/__tests__/continue-on-error.test.ts +27 -0
  9. package/__tests__/data-api-invalid-body.test.ts +110 -0
  10. package/__tests__/data-node-not-awaited.test.ts +7 -6
  11. package/__tests__/email-node-recipients.test.ts +288 -0
  12. package/__tests__/invoice-payment-line.test.ts +68 -0
  13. package/__tests__/invoice-tender.test.ts +116 -0
  14. package/__tests__/pending-work-after-reply.test.ts +294 -0
  15. package/__tests__/runtime-condition-operands.test.ts +86 -0
  16. package/__tests__/sent-email-log-module.test.ts +54 -0
  17. package/__tests__/webhook-delivery-lease.test.ts +68 -6
  18. package/dist/cjs/account-delete-route-generator.d.ts +7 -2
  19. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/account-delete-route-generator.js +90 -43
  21. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  22. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  23. package/dist/cjs/api-route-generator.js +12 -13
  24. package/dist/cjs/api-route-generator.js.map +1 -1
  25. package/dist/cjs/auth-generator.d.ts.map +1 -1
  26. package/dist/cjs/auth-generator.js +34 -1
  27. package/dist/cjs/auth-generator.js.map +1 -1
  28. package/dist/cjs/await-result.d.ts +13 -5
  29. package/dist/cjs/await-result.d.ts.map +1 -1
  30. package/dist/cjs/await-result.js +19 -8
  31. package/dist/cjs/await-result.js.map +1 -1
  32. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  33. package/dist/cjs/data-api-route-generator.js +9 -1
  34. package/dist/cjs/data-api-route-generator.js.map +1 -1
  35. package/dist/cjs/executor-generator.d.ts.map +1 -1
  36. package/dist/cjs/executor-generator.js +22 -7
  37. package/dist/cjs/executor-generator.js.map +1 -1
  38. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  39. package/dist/cjs/invoice/invoice-html-code.js +96 -5
  40. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  41. package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
  42. package/dist/cjs/nodes/account/account-delete-current.js +4 -1
  43. package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
  44. package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
  45. package/dist/cjs/nodes/email/email-mailersend.js +55 -48
  46. package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
  47. package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
  48. package/dist/cjs/nodes/email/email-mailgun.js +44 -32
  49. package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
  50. package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
  51. package/dist/cjs/nodes/email/email-postmark.js +44 -31
  52. package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
  53. package/dist/cjs/nodes/email/email-recipients.d.ts +28 -0
  54. package/dist/cjs/nodes/email/email-recipients.d.ts.map +1 -0
  55. package/dist/cjs/nodes/email/email-recipients.js +74 -0
  56. package/dist/cjs/nodes/email/email-recipients.js.map +1 -0
  57. package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
  58. package/dist/cjs/nodes/email/email-resend.js +42 -28
  59. package/dist/cjs/nodes/email/email-resend.js.map +1 -1
  60. package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
  61. package/dist/cjs/nodes/email/email-sendgrid.js +56 -41
  62. package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
  63. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  64. package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -1
  65. package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
  66. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  67. package/dist/cjs/server-runtime-code.js +21 -0
  68. package/dist/cjs/server-runtime-code.js.map +1 -1
  69. package/dist/cjs/transactional-email-code.d.ts +7 -0
  70. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  71. package/dist/cjs/transactional-email-code.js +5 -5
  72. package/dist/cjs/transactional-email-code.js.map +1 -1
  73. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  74. package/dist/cjs/vercel-functions.d.ts +34 -0
  75. package/dist/cjs/vercel-functions.d.ts.map +1 -0
  76. package/dist/cjs/vercel-functions.js +62 -0
  77. package/dist/cjs/vercel-functions.js.map +1 -0
  78. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  79. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  80. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  81. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  82. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  83. package/dist/cjs/workflow-project-plugin.js +3 -0
  84. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  85. package/dist/esm/account-delete-route-generator.d.ts +7 -2
  86. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  87. package/dist/esm/account-delete-route-generator.js +90 -43
  88. package/dist/esm/account-delete-route-generator.js.map +1 -1
  89. package/dist/esm/api-route-generator.d.ts.map +1 -1
  90. package/dist/esm/api-route-generator.js +12 -13
  91. package/dist/esm/api-route-generator.js.map +1 -1
  92. package/dist/esm/auth-generator.d.ts.map +1 -1
  93. package/dist/esm/auth-generator.js +34 -1
  94. package/dist/esm/auth-generator.js.map +1 -1
  95. package/dist/esm/await-result.d.ts +13 -5
  96. package/dist/esm/await-result.d.ts.map +1 -1
  97. package/dist/esm/await-result.js +18 -7
  98. package/dist/esm/await-result.js.map +1 -1
  99. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  100. package/dist/esm/data-api-route-generator.js +9 -1
  101. package/dist/esm/data-api-route-generator.js.map +1 -1
  102. package/dist/esm/executor-generator.d.ts.map +1 -1
  103. package/dist/esm/executor-generator.js +22 -7
  104. package/dist/esm/executor-generator.js.map +1 -1
  105. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  106. package/dist/esm/invoice/invoice-html-code.js +96 -5
  107. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  108. package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
  109. package/dist/esm/nodes/account/account-delete-current.js +4 -1
  110. package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
  111. package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
  112. package/dist/esm/nodes/email/email-mailersend.js +55 -48
  113. package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
  114. package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
  115. package/dist/esm/nodes/email/email-mailgun.js +44 -32
  116. package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
  117. package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
  118. package/dist/esm/nodes/email/email-postmark.js +44 -31
  119. package/dist/esm/nodes/email/email-postmark.js.map +1 -1
  120. package/dist/esm/nodes/email/email-recipients.d.ts +28 -0
  121. package/dist/esm/nodes/email/email-recipients.d.ts.map +1 -0
  122. package/dist/esm/nodes/email/email-recipients.js +71 -0
  123. package/dist/esm/nodes/email/email-recipients.js.map +1 -0
  124. package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
  125. package/dist/esm/nodes/email/email-resend.js +42 -28
  126. package/dist/esm/nodes/email/email-resend.js.map +1 -1
  127. package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
  128. package/dist/esm/nodes/email/email-sendgrid.js +56 -41
  129. package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
  130. package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  131. package/dist/esm/sent-email-log/sent-email-log-code.js +17 -1
  132. package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
  133. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  134. package/dist/esm/server-runtime-code.js +21 -0
  135. package/dist/esm/server-runtime-code.js.map +1 -1
  136. package/dist/esm/transactional-email-code.d.ts +7 -0
  137. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  138. package/dist/esm/transactional-email-code.js +5 -5
  139. package/dist/esm/transactional-email-code.js.map +1 -1
  140. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  141. package/dist/esm/vercel-functions.d.ts +34 -0
  142. package/dist/esm/vercel-functions.d.ts.map +1 -0
  143. package/dist/esm/vercel-functions.js +57 -0
  144. package/dist/esm/vercel-functions.js.map +1 -0
  145. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  146. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  147. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  148. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  149. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  150. package/dist/esm/workflow-project-plugin.js +3 -0
  151. package/dist/esm/workflow-project-plugin.js.map +1 -1
  152. package/package.json +2 -2
  153. package/src/account-delete-route-generator.ts +97 -45
  154. package/src/api-route-generator.ts +12 -13
  155. package/src/auth-generator.ts +34 -1
  156. package/src/await-result.ts +19 -7
  157. package/src/data-api-route-generator.ts +9 -1
  158. package/src/executor-generator.ts +22 -7
  159. package/src/invoice/invoice-html-code.ts +96 -5
  160. package/src/nodes/account/account-delete-current.ts +4 -1
  161. package/src/nodes/email/email-mailersend.ts +72 -55
  162. package/src/nodes/email/email-mailgun.ts +51 -36
  163. package/src/nodes/email/email-postmark.ts +51 -35
  164. package/src/nodes/email/email-recipients.ts +86 -0
  165. package/src/nodes/email/email-resend.ts +49 -31
  166. package/src/nodes/email/email-sendgrid.ts +63 -44
  167. package/src/sent-email-log/sent-email-log-code.ts +17 -1
  168. package/src/server-runtime-code.ts +22 -0
  169. package/src/transactional-email-code.ts +12 -5
  170. package/src/vercel-functions.ts +67 -0
  171. package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
  172. 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 {