@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.
Files changed (141) 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__/auth-password-change-ends-sessions.test.ts +178 -0
  7. package/__tests__/credential-result-reply.test.ts +509 -0
  8. package/__tests__/data-api-invalid-body.test.ts +110 -0
  9. package/__tests__/data-node-not-awaited.test.ts +7 -6
  10. package/__tests__/invoice-payment-line.test.ts +68 -0
  11. package/__tests__/invoice-tender.test.ts +116 -0
  12. package/__tests__/pending-work-after-reply.test.ts +294 -0
  13. package/__tests__/runtime-condition-operands.test.ts +86 -0
  14. package/__tests__/webhook-delivery-lease.test.ts +68 -6
  15. package/dist/cjs/account-delete-route-generator.d.ts +7 -2
  16. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  17. package/dist/cjs/account-delete-route-generator.js +90 -43
  18. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  19. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/api-route-generator.js +17 -15
  21. package/dist/cjs/api-route-generator.js.map +1 -1
  22. package/dist/cjs/auth-generator.d.ts.map +1 -1
  23. package/dist/cjs/auth-generator.js +34 -1
  24. package/dist/cjs/auth-generator.js.map +1 -1
  25. package/dist/cjs/await-result.d.ts +13 -5
  26. package/dist/cjs/await-result.d.ts.map +1 -1
  27. package/dist/cjs/await-result.js +19 -8
  28. package/dist/cjs/await-result.js.map +1 -1
  29. package/dist/cjs/credential-results.d.ts +18 -0
  30. package/dist/cjs/credential-results.d.ts.map +1 -0
  31. package/dist/cjs/credential-results.js +72 -0
  32. package/dist/cjs/credential-results.js.map +1 -0
  33. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  34. package/dist/cjs/data-api-route-generator.js +9 -1
  35. package/dist/cjs/data-api-route-generator.js.map +1 -1
  36. package/dist/cjs/executor-generator.d.ts.map +1 -1
  37. package/dist/cjs/executor-generator.js +26 -7
  38. package/dist/cjs/executor-generator.js.map +1 -1
  39. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  40. package/dist/cjs/invoice/invoice-html-code.js +96 -5
  41. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  42. package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
  43. package/dist/cjs/nodes/account/account-delete-current.js +4 -1
  44. package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
  45. package/dist/cjs/nodes/account/account-hash-password.d.ts.map +1 -1
  46. package/dist/cjs/nodes/account/account-hash-password.js +1 -0
  47. package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
  48. package/dist/cjs/nodes/types.d.ts +6 -0
  49. package/dist/cjs/nodes/types.d.ts.map +1 -1
  50. package/dist/cjs/server-runtime-code.d.ts +8 -0
  51. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  52. package/dist/cjs/server-runtime-code.js +127 -0
  53. package/dist/cjs/server-runtime-code.js.map +1 -1
  54. package/dist/cjs/transactional-email-code.d.ts +7 -0
  55. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  56. package/dist/cjs/transactional-email-code.js +5 -5
  57. package/dist/cjs/transactional-email-code.js.map +1 -1
  58. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  59. package/dist/cjs/vercel-functions.d.ts +34 -0
  60. package/dist/cjs/vercel-functions.d.ts.map +1 -0
  61. package/dist/cjs/vercel-functions.js +62 -0
  62. package/dist/cjs/vercel-functions.js.map +1 -0
  63. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  64. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  65. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  66. package/dist/cjs/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  67. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  68. package/dist/cjs/workflow-project-plugin.js +3 -0
  69. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  70. package/dist/esm/account-delete-route-generator.d.ts +7 -2
  71. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  72. package/dist/esm/account-delete-route-generator.js +90 -43
  73. package/dist/esm/account-delete-route-generator.js.map +1 -1
  74. package/dist/esm/api-route-generator.d.ts.map +1 -1
  75. package/dist/esm/api-route-generator.js +17 -15
  76. package/dist/esm/api-route-generator.js.map +1 -1
  77. package/dist/esm/auth-generator.d.ts.map +1 -1
  78. package/dist/esm/auth-generator.js +34 -1
  79. package/dist/esm/auth-generator.js.map +1 -1
  80. package/dist/esm/await-result.d.ts +13 -5
  81. package/dist/esm/await-result.d.ts.map +1 -1
  82. package/dist/esm/await-result.js +18 -7
  83. package/dist/esm/await-result.js.map +1 -1
  84. package/dist/esm/credential-results.d.ts +18 -0
  85. package/dist/esm/credential-results.d.ts.map +1 -0
  86. package/dist/esm/credential-results.js +68 -0
  87. package/dist/esm/credential-results.js.map +1 -0
  88. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  89. package/dist/esm/data-api-route-generator.js +9 -1
  90. package/dist/esm/data-api-route-generator.js.map +1 -1
  91. package/dist/esm/executor-generator.d.ts.map +1 -1
  92. package/dist/esm/executor-generator.js +26 -7
  93. package/dist/esm/executor-generator.js.map +1 -1
  94. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  95. package/dist/esm/invoice/invoice-html-code.js +96 -5
  96. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  97. package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
  98. package/dist/esm/nodes/account/account-delete-current.js +4 -1
  99. package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
  100. package/dist/esm/nodes/account/account-hash-password.d.ts.map +1 -1
  101. package/dist/esm/nodes/account/account-hash-password.js +1 -0
  102. package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
  103. package/dist/esm/nodes/types.d.ts +6 -0
  104. package/dist/esm/nodes/types.d.ts.map +1 -1
  105. package/dist/esm/server-runtime-code.d.ts +8 -0
  106. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  107. package/dist/esm/server-runtime-code.js +127 -0
  108. package/dist/esm/server-runtime-code.js.map +1 -1
  109. package/dist/esm/transactional-email-code.d.ts +7 -0
  110. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  111. package/dist/esm/transactional-email-code.js +5 -5
  112. package/dist/esm/transactional-email-code.js.map +1 -1
  113. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  114. package/dist/esm/vercel-functions.d.ts +34 -0
  115. package/dist/esm/vercel-functions.d.ts.map +1 -0
  116. package/dist/esm/vercel-functions.js +57 -0
  117. package/dist/esm/vercel-functions.js.map +1 -0
  118. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts +6 -0
  119. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.d.ts.map +1 -1
  120. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js +26 -11
  121. package/dist/esm/webhook-delivery-lease/webhook-delivery-lease-code.js.map +1 -1
  122. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  123. package/dist/esm/workflow-project-plugin.js +3 -0
  124. package/dist/esm/workflow-project-plugin.js.map +1 -1
  125. package/package.json +2 -2
  126. package/src/account-delete-route-generator.ts +97 -45
  127. package/src/api-route-generator.ts +17 -15
  128. package/src/auth-generator.ts +34 -1
  129. package/src/await-result.ts +19 -7
  130. package/src/credential-results.ts +71 -0
  131. package/src/data-api-route-generator.ts +9 -1
  132. package/src/executor-generator.ts +26 -7
  133. package/src/invoice/invoice-html-code.ts +96 -5
  134. package/src/nodes/account/account-delete-current.ts +4 -1
  135. package/src/nodes/account/account-hash-password.ts +1 -0
  136. package/src/nodes/types.ts +6 -0
  137. package/src/server-runtime-code.ts +128 -0
  138. package/src/transactional-email-code.ts +12 -5
  139. package/src/vercel-functions.ts +67 -0
  140. package/src/webhook-delivery-lease/webhook-delivery-lease-code.ts +26 -11
  141. 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 settles the query before it responds, because a
22
- // serverless function may be frozen the instant it replies;
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('settles in-flight queries BEFORE responding (the platform can freeze us)', () => {
452
- expect(route).toContain('await utils.settlePendingNodePromises(context);')
453
- const settleAt = route.indexOf('await utils.settlePendingNodePromises(context);')
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.settlePendingNodePromises(__wfContext); }'
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
+ })