@teleporthq/teleport-plugin-next-workflows 0.44.0 → 0.44.1

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 (158) hide show
  1. package/__tests__/_helpers/fake-store-database.ts +428 -0
  2. package/__tests__/_helpers/invoice-store.ts +382 -0
  3. package/__tests__/email-locale-senders.test.ts +3 -1
  4. package/__tests__/email-sender-name.test.ts +513 -0
  5. package/__tests__/graph-utils-topological-order.test.ts +155 -1
  6. package/__tests__/invoice-billing-details.test.ts +107 -1
  7. package/__tests__/invoice-data-access-replace.test.ts +236 -2
  8. package/__tests__/invoice-order-items-settle.test.ts +1 -1
  9. package/__tests__/invoice-payment-email.test.ts +191 -0
  10. package/__tests__/invoice-regenerate-email.test.ts +288 -0
  11. package/__tests__/invoice-regenerate-route.test.ts +9 -7
  12. package/__tests__/invoice-reservation.test.ts +1 -1
  13. package/__tests__/invoice-tender.test.ts +1 -1
  14. package/__tests__/segment-splitter-checkout-order.test.ts +306 -0
  15. package/__tests__/streaming-on-end-env-filter.test.ts +18 -18
  16. package/__tests__/streaming-on-end-order.test.ts +387 -0
  17. package/dist/cjs/account-delete-route-generator.d.ts +1 -0
  18. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  19. package/dist/cjs/account-delete-route-generator.js +2 -1
  20. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  21. package/dist/cjs/auth-generator.d.ts +1 -0
  22. package/dist/cjs/auth-generator.d.ts.map +1 -1
  23. package/dist/cjs/auth-generator.js +2 -1
  24. package/dist/cjs/auth-generator.js.map +1 -1
  25. package/dist/cjs/executor-generator.d.ts.map +1 -1
  26. package/dist/cjs/executor-generator.js +9 -26
  27. package/dist/cjs/executor-generator.js.map +1 -1
  28. package/dist/cjs/graph-utils.d.ts +31 -1
  29. package/dist/cjs/graph-utils.d.ts.map +1 -1
  30. package/dist/cjs/graph-utils.js +112 -10
  31. package/dist/cjs/graph-utils.js.map +1 -1
  32. package/dist/cjs/invoice/api-routes-code.d.ts +12 -3
  33. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  34. package/dist/cjs/invoice/api-routes-code.js +152 -21
  35. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  36. package/dist/cjs/invoice/data-access-code.d.ts.map +1 -1
  37. package/dist/cjs/invoice/data-access-code.js +133 -21
  38. package/dist/cjs/invoice/data-access-code.js.map +1 -1
  39. package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
  40. package/dist/cjs/invoice/email-sender-code.js +15 -25
  41. package/dist/cjs/invoice/email-sender-code.js.map +1 -1
  42. package/dist/cjs/invoice/index.js +1 -1
  43. package/dist/cjs/invoice/index.js.map +1 -1
  44. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  45. package/dist/cjs/invoice/invoice-assembly-code.js +10 -6
  46. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  47. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  48. package/dist/cjs/invoice/invoice-html-code.js +3 -0
  49. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  50. package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
  51. package/dist/cjs/nodes/email/email-mailgun.js +4 -2
  52. package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
  53. package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
  54. package/dist/cjs/nodes/email/email-postmark.js +5 -2
  55. package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
  56. package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
  57. package/dist/cjs/nodes/email/email-resend.js +5 -2
  58. package/dist/cjs/nodes/email/email-resend.js.map +1 -1
  59. package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
  60. package/dist/cjs/nodes/email/email-sendgrid.js +8 -2
  61. package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
  62. package/dist/cjs/nodes/storage/storage-local-remove.d.ts.map +1 -1
  63. package/dist/cjs/nodes/storage/storage-local-remove.js +2 -0
  64. package/dist/cjs/nodes/storage/storage-local-remove.js.map +1 -1
  65. package/dist/cjs/nodes/storage/storage-local-set.d.ts.map +1 -1
  66. package/dist/cjs/nodes/storage/storage-local-set.js +4 -0
  67. package/dist/cjs/nodes/storage/storage-local-set.js.map +1 -1
  68. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  69. package/dist/cjs/segment-splitter.js +65 -27
  70. package/dist/cjs/segment-splitter.js.map +1 -1
  71. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  72. package/dist/cjs/transactional-email-code.js +32 -18
  73. package/dist/cjs/transactional-email-code.js.map +1 -1
  74. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  75. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  76. package/dist/cjs/workflow-project-plugin.js +2 -0
  77. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  78. package/dist/esm/account-delete-route-generator.d.ts +1 -0
  79. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  80. package/dist/esm/account-delete-route-generator.js +2 -1
  81. package/dist/esm/account-delete-route-generator.js.map +1 -1
  82. package/dist/esm/auth-generator.d.ts +1 -0
  83. package/dist/esm/auth-generator.d.ts.map +1 -1
  84. package/dist/esm/auth-generator.js +2 -1
  85. package/dist/esm/auth-generator.js.map +1 -1
  86. package/dist/esm/executor-generator.d.ts.map +1 -1
  87. package/dist/esm/executor-generator.js +9 -26
  88. package/dist/esm/executor-generator.js.map +1 -1
  89. package/dist/esm/graph-utils.d.ts +31 -1
  90. package/dist/esm/graph-utils.d.ts.map +1 -1
  91. package/dist/esm/graph-utils.js +109 -9
  92. package/dist/esm/graph-utils.js.map +1 -1
  93. package/dist/esm/invoice/api-routes-code.d.ts +12 -3
  94. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  95. package/dist/esm/invoice/api-routes-code.js +152 -21
  96. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  97. package/dist/esm/invoice/data-access-code.d.ts.map +1 -1
  98. package/dist/esm/invoice/data-access-code.js +133 -21
  99. package/dist/esm/invoice/data-access-code.js.map +1 -1
  100. package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
  101. package/dist/esm/invoice/email-sender-code.js +15 -25
  102. package/dist/esm/invoice/email-sender-code.js.map +1 -1
  103. package/dist/esm/invoice/index.js +1 -1
  104. package/dist/esm/invoice/index.js.map +1 -1
  105. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  106. package/dist/esm/invoice/invoice-assembly-code.js +10 -6
  107. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  108. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  109. package/dist/esm/invoice/invoice-html-code.js +3 -0
  110. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  111. package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
  112. package/dist/esm/nodes/email/email-mailgun.js +4 -2
  113. package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
  114. package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
  115. package/dist/esm/nodes/email/email-postmark.js +5 -2
  116. package/dist/esm/nodes/email/email-postmark.js.map +1 -1
  117. package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
  118. package/dist/esm/nodes/email/email-resend.js +5 -2
  119. package/dist/esm/nodes/email/email-resend.js.map +1 -1
  120. package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
  121. package/dist/esm/nodes/email/email-sendgrid.js +8 -2
  122. package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
  123. package/dist/esm/nodes/storage/storage-local-remove.d.ts.map +1 -1
  124. package/dist/esm/nodes/storage/storage-local-remove.js +2 -0
  125. package/dist/esm/nodes/storage/storage-local-remove.js.map +1 -1
  126. package/dist/esm/nodes/storage/storage-local-set.d.ts.map +1 -1
  127. package/dist/esm/nodes/storage/storage-local-set.js +4 -0
  128. package/dist/esm/nodes/storage/storage-local-set.js.map +1 -1
  129. package/dist/esm/segment-splitter.d.ts.map +1 -1
  130. package/dist/esm/segment-splitter.js +66 -28
  131. package/dist/esm/segment-splitter.js.map +1 -1
  132. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  133. package/dist/esm/transactional-email-code.js +32 -18
  134. package/dist/esm/transactional-email-code.js.map +1 -1
  135. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  136. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  137. package/dist/esm/workflow-project-plugin.js +2 -0
  138. package/dist/esm/workflow-project-plugin.js.map +1 -1
  139. package/package.json +3 -3
  140. package/src/account-delete-route-generator.ts +3 -1
  141. package/src/auth-generator.ts +3 -1
  142. package/src/executor-generator.ts +9 -26
  143. package/src/graph-utils.ts +143 -11
  144. package/src/invoice/api-routes-code.ts +166 -21
  145. package/src/invoice/data-access-code.ts +139 -21
  146. package/src/invoice/email-sender-code.ts +15 -25
  147. package/src/invoice/index.ts +1 -1
  148. package/src/invoice/invoice-assembly-code.ts +10 -6
  149. package/src/invoice/invoice-html-code.ts +3 -0
  150. package/src/nodes/email/email-mailgun.ts +11 -2
  151. package/src/nodes/email/email-postmark.ts +12 -2
  152. package/src/nodes/email/email-resend.ts +12 -2
  153. package/src/nodes/email/email-sendgrid.ts +15 -2
  154. package/src/nodes/storage/storage-local-remove.ts +4 -0
  155. package/src/nodes/storage/storage-local-set.ts +6 -0
  156. package/src/segment-splitter.ts +90 -36
  157. package/src/transactional-email-code.ts +32 -21
  158. package/src/workflow-project-plugin.ts +2 -0
@@ -0,0 +1,288 @@
1
+ import {
2
+ BUYER,
3
+ ORDER_ID,
4
+ SECOND_BUYER,
5
+ SECOND_ORDER_ID,
6
+ PostmarkMessage,
7
+ historyLines,
8
+ invoiceOf,
9
+ ledgerRows,
10
+ openStore,
11
+ payWhilePdfServiceIsDown,
12
+ runsAsTheStore,
13
+ seedPaidOrder,
14
+ } from './_helpers/invoice-store'
15
+
16
+ /**
17
+ * Regenerate emails the buyer the invoice their payment could not.
18
+ *
19
+ * When the PDF service is down at payment, the order is still paid and its
20
+ * invoice row stays reserved without a PDF, so the payment's invoice email
21
+ * never goes out. The merchant then gets the PDF with the admin panel's
22
+ * Regenerate — and that regeneration, the FIRST to give the invoice a PDF,
23
+ * emails the buyer, once, when the store emails invoices.
24
+ *
25
+ * Everything here is the code the store runs, as emitted: the payment's
26
+ * `/api/invoices/generate`, the admin panel's `/api/invoices/[id]/regenerate`,
27
+ * the data access, the assembly, the PDF client, the invoice sender, the
28
+ * sent-email ledger and the locale module. Only what lies outside the store is
29
+ * faked: its Postgres database, the PDF service and the email provider.
30
+ */
31
+
32
+ describe('Regenerate emails the buyer the invoice their payment could not', () => {
33
+ runsAsTheStore()
34
+
35
+ it('no PDF before: the regeneration that gives the invoice its PDF emails the buyer once', async () => {
36
+ const store = openStore()
37
+ seedPaidOrder(store.db, ORDER_ID)
38
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
39
+ expect(store.emails).toHaveLength(0)
40
+
41
+ const regenerated = await store.regenerate(String(invoice.id))
42
+
43
+ expect(regenerated.status).toBe(200)
44
+ expect(Number(invoice.pdf_size_bytes)).toBeGreaterThan(0)
45
+ expect(store.emails).toHaveLength(1)
46
+ const [email] = store.emails
47
+ expect(email.To).toBe(BUYER)
48
+ expect(email.From).toBe('Lumen Pottery <shop@lumen.test>')
49
+ expect(email.Attachments).toEqual([
50
+ {
51
+ Name: 'INV-0001.pdf',
52
+ Content: Buffer.from('%PDF-1.4 INV-0001').toString('base64'),
53
+ ContentType: 'application/pdf',
54
+ },
55
+ ])
56
+
57
+ // The sent-email ledger: the row the payment's email writes, naming the
58
+ // route that sent it.
59
+ expect(ledgerRows(store)).toEqual([
60
+ expect.objectContaining({
61
+ email_type: 'invoice-email',
62
+ audience: 'customer',
63
+ recipient_email: BUYER,
64
+ from_email: 'shop@lumen.test',
65
+ from_name: 'Lumen Pottery',
66
+ status: 'sent',
67
+ provider: 'postmark',
68
+ source: 'invoice',
69
+ source_ref: 'api/invoices/[id]/regenerate',
70
+ order_id: ORDER_ID,
71
+ reference_type: 'invoice',
72
+ reference_id: invoice.id,
73
+ }),
74
+ ])
75
+ // The order's history: one "email sent" line, by the store admin.
76
+ expect(historyLines(store)).toEqual([
77
+ expect.objectContaining({
78
+ event_type: 'order-email-sent',
79
+ dedupe_key: 'order-email-sent:' + String(invoice.id) + ':invoice-email',
80
+ order_id: ORDER_ID,
81
+ payload: JSON.stringify({ purpose: 'invoice-email', to: BUYER }),
82
+ actor: 'Store admin',
83
+ actor_type: 'merchant',
84
+ status: 'recorded',
85
+ }),
86
+ ])
87
+ })
88
+
89
+ it.each([
90
+ [
91
+ 'served from the database',
92
+ false,
93
+ (invoiceId: string) => '/api/invoices/' + invoiceId + '/pdf',
94
+ ],
95
+ ['in runtime storage', true, () => 'https://cdn.test/INV-0001.pdf'],
96
+ ])(
97
+ 'sends exactly the email the payment would have sent — sender, template, attachment, language, and the link of a PDF %s',
98
+ async (_, storageConfigured, expectedLink) => {
99
+ // A payment the PDF service served: the invoice email it sends.
100
+ const paidOk = openStore()
101
+ paidOk.storage.configured = storageConfigured
102
+ seedPaidOrder(paidOk.db, ORDER_ID)
103
+ expect((await paidOk.pay(ORDER_ID)).status).toBe(200)
104
+ expect(paidOk.emails).toHaveLength(1)
105
+ const atPayment = paidOk.emails[0]
106
+ const paidInvoiceId = String(invoiceOf(paidOk, ORDER_ID).id)
107
+
108
+ // The same order, paid while the PDF service was down, then regenerated.
109
+ const paidDown = openStore()
110
+ paidDown.storage.configured = storageConfigured
111
+ seedPaidOrder(paidDown.db, ORDER_ID)
112
+ const invoice = await payWhilePdfServiceIsDown(paidDown, ORDER_ID)
113
+ expect((await paidDown.regenerate(String(invoice.id))).status).toBe(200)
114
+ expect(paidDown.emails).toHaveLength(1)
115
+ const atRegeneration = paidDown.emails[0]
116
+
117
+ // Nothing differs but the invoice's own id, inside a database link.
118
+ const sameLink = (message: PostmarkMessage, id: string): PostmarkMessage => ({
119
+ ...message,
120
+ HtmlBody: message.HtmlBody.split(id).join('<invoice id>'),
121
+ })
122
+ expect(sameLink(atRegeneration, String(invoice.id))).toEqual(
123
+ sameLink(atPayment, paidInvoiceId)
124
+ )
125
+ // The buyer's language (the order is Spanish), not the merchant's German admin.
126
+ expect(atRegeneration.Subject).toBe('Factura INV-0001')
127
+ expect(atRegeneration.HtmlBody).toBe(
128
+ '<p>Hola Ana Pop, factura INV-0001 por €100.00: ' +
129
+ expectedLink(String(invoice.id)) +
130
+ '</p>'
131
+ )
132
+ }
133
+ )
134
+
135
+ it("an order with no language of its own gets the store's main language, never the merchant's", async () => {
136
+ const store = openStore()
137
+ seedPaidOrder(store.db, ORDER_ID, { locale: null })
138
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
139
+
140
+ await store.regenerate(String(invoice.id))
141
+
142
+ expect(store.emails.map((email) => email.Subject)).toEqual(['Invoice INV-0001'])
143
+ })
144
+
145
+ it('had a PDF: only the invoice that never had one is emailed', async () => {
146
+ const store = openStore()
147
+ seedPaidOrder(store.db, ORDER_ID)
148
+ seedPaidOrder(store.db, SECOND_ORDER_ID, {}, SECOND_BUYER)
149
+ // The first order's invoice was issued and emailed at payment.
150
+ expect((await store.pay(ORDER_ID)).status).toBe(200)
151
+ const issued = invoiceOf(store, ORDER_ID)
152
+ expect(store.emails.map((email) => email.To)).toEqual([BUYER])
153
+ // The second order's was not: the PDF service was down.
154
+ const unfinished = await payWhilePdfServiceIsDown(store, SECOND_ORDER_ID)
155
+
156
+ expect((await store.regenerate(String(issued.id))).status).toBe(200)
157
+ expect((await store.regenerate(String(unfinished.id))).status).toBe(200)
158
+
159
+ // The payment's email, then the unfinished invoice's — nothing for the
160
+ // invoice that already had its PDF.
161
+ expect(store.emails.map((email) => email.To)).toEqual([BUYER, SECOND_BUYER])
162
+ // Each email is on its own order's history once: the payment's, by the
163
+ // store, and the regeneration's, by the admin.
164
+ expect(historyLines(store).map((line) => [line.order_id, line.actor_type])).toEqual([
165
+ [ORDER_ID, 'system'],
166
+ [SECOND_ORDER_ID, 'merchant'],
167
+ ])
168
+ })
169
+
170
+ it('a second regeneration never emails again', async () => {
171
+ const store = openStore()
172
+ seedPaidOrder(store.db, ORDER_ID)
173
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
174
+
175
+ expect((await store.regenerate(String(invoice.id))).status).toBe(200)
176
+ expect(store.emails).toHaveLength(1)
177
+ expect((await store.regenerate(String(invoice.id))).status).toBe(200)
178
+ expect((await store.regenerate(String(invoice.id))).status).toBe(200)
179
+
180
+ expect(store.emails).toHaveLength(1)
181
+ expect(ledgerRows(store)).toHaveLength(1)
182
+ expect(historyLines(store)).toHaveLength(1)
183
+ })
184
+
185
+ it('two overlapping regenerations (a double click) email once', async () => {
186
+ const store = openStore()
187
+ seedPaidOrder(store.db, ORDER_ID)
188
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
189
+
190
+ // Both requests read the invoice without a PDF and render before either
191
+ // writes: the PDF service answers neither until it has both.
192
+ let releaseRenders: () => void = () => undefined
193
+ store.pdfService.hold = new Promise((resolve) => {
194
+ releaseRenders = resolve
195
+ })
196
+ const first = store.regenerate(String(invoice.id))
197
+ const second = store.regenerate(String(invoice.id))
198
+ while (store.pdfService.renders < 2) {
199
+ await new Promise((resolve) => setImmediate(resolve))
200
+ }
201
+ releaseRenders()
202
+ const answers = await Promise.all([first, second])
203
+
204
+ expect(answers.map((answer) => answer.status)).toEqual([200, 200])
205
+ expect(store.emails).toHaveLength(1)
206
+ expect(ledgerRows(store)).toHaveLength(1)
207
+ expect(historyLines(store)).toHaveLength(1)
208
+ })
209
+
210
+ it('Invoice Email Delivery off: the same regeneration emails nobody', async () => {
211
+ const delivering = openStore()
212
+ seedPaidOrder(delivering.db, ORDER_ID)
213
+ const deliveredInvoice = await payWhilePdfServiceIsDown(delivering, ORDER_ID)
214
+ await delivering.regenerate(String(deliveredInvoice.id))
215
+ expect(delivering.emails).toHaveLength(1)
216
+
217
+ const silent = openStore({ emailDelivery: false })
218
+ seedPaidOrder(silent.db, ORDER_ID)
219
+ const invoice = await payWhilePdfServiceIsDown(silent, ORDER_ID)
220
+ const regenerated = await silent.regenerate(String(invoice.id))
221
+
222
+ expect(regenerated.status).toBe(200)
223
+ expect(Number(invoice.pdf_size_bytes)).toBeGreaterThan(0)
224
+ expect(silent.emails).toHaveLength(0)
225
+ expect(ledgerRows(silent)).toHaveLength(0)
226
+ expect(historyLines(silent)).toHaveLength(0)
227
+ // The route does not even load the sender.
228
+ expect(silent.routeCode).not.toContain('email-sender')
229
+ })
230
+
231
+ it('a refused email still regenerates the invoice, is logged, and is never sent again', async () => {
232
+ const store = openStore()
233
+ seedPaidOrder(store.db, ORDER_ID)
234
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
235
+ store.provider.refuses = true
236
+
237
+ const regenerated = await store.regenerate(String(invoice.id))
238
+
239
+ // The merchant has the PDF, and the order links to it.
240
+ expect(regenerated.status).toBe(200)
241
+ expect(regenerated.body).toMatchObject({ success: true, invoiceNumber: 'INV-0001' })
242
+ expect(Number(invoice.pdf_size_bytes)).toBeGreaterThan(0)
243
+ expect(store.db.row('teleport_orders', ORDER_ID)).toMatchObject({
244
+ invoice_id: invoice.id,
245
+ invoice_number: 'INV-0001',
246
+ invoice_pdf_url: '/api/invoices/' + String(invoice.id) + '/pdf',
247
+ })
248
+ // The provider was asked once and refused: the ledger says so, the
249
+ // order's history does not claim an email went out, the log names it.
250
+ expect(store.emails).toHaveLength(1)
251
+ expect(ledgerRows(store)).toEqual([
252
+ expect.objectContaining({
253
+ status: 'failed',
254
+ error_message: '422 The From address is not a confirmed sender signature',
255
+ }),
256
+ ])
257
+ expect(historyLines(store)).toHaveLength(0)
258
+ expect(store.errors).toContainEqual(
259
+ expect.stringContaining('invoice INV-0001 could not be emailed to its customer')
260
+ )
261
+
262
+ store.provider.refuses = false
263
+ expect((await store.regenerate(String(invoice.id))).status).toBe(200)
264
+ expect(store.emails).toHaveLength(1)
265
+ })
266
+
267
+ it('an order that cannot be updated still gets its invoice emailed, once', async () => {
268
+ const store = openStore()
269
+ seedPaidOrder(store.db, ORDER_ID)
270
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
271
+ store.db.failNext(/^UPDATE teleport_orders SET invoice_id = \$1/, 'Connection terminated')
272
+
273
+ const failed = await store.regenerate(String(invoice.id))
274
+
275
+ // The invoice has its PDF, so its buyer is emailed it, with the link the
276
+ // database serves it from.
277
+ expect(failed.status).toBe(500)
278
+ expect(store.emails).toHaveLength(1)
279
+ expect(store.emails[0].HtmlBody).toContain('/api/invoices/' + String(invoice.id) + '/pdf')
280
+ expect(ledgerRows(store)).toHaveLength(1)
281
+
282
+ // Regenerating again links the order, and emails nobody.
283
+ expect((await store.regenerate(String(invoice.id))).status).toBe(200)
284
+ expect(store.db.row('teleport_orders', ORDER_ID)).toMatchObject({ invoice_id: invoice.id })
285
+ expect(store.emails).toHaveLength(1)
286
+ expect(historyLines(store)).toHaveLength(1)
287
+ })
288
+ })
@@ -16,8 +16,10 @@ import {
16
16
  *
17
17
  * Every test runs the EMITTED route against the REAL emitted assembly, with
18
18
  * the database, the PDF service and the session stubbed: a regenerated invoice
19
- * must be priced by exactly the code that issued it, keep its identity, take
20
- * the merchant's corrections over the order, and never email anybody.
19
+ * must be priced by exactly the code that issued it, keep its identity and
20
+ * take the merchant's corrections over the order. These run a store that does
21
+ * not email invoices; the one email a regeneration can send is proved in
22
+ * `invoice-regenerate-email.test.ts`.
21
23
  */
22
24
 
23
25
  const SETTINGS = {
@@ -27,7 +29,7 @@ const SETTINGS = {
27
29
  companyDetails: {},
28
30
  template: { document: null as unknown },
29
31
  tables: { invoicesTable: 'teleport_invoices', invoiceItemsTable: 'teleport_invoice_items' },
30
- emailDelivery: { enabled: true, provider: 'postmark' },
32
+ emailDelivery: { enabled: false },
31
33
  } as unknown as UIDLInvoiceSettings
32
34
 
33
35
  type Row = Record<string, unknown>
@@ -190,7 +192,7 @@ async function run(options: RunOptions = {}): Promise<Harness> {
190
192
 
191
193
  const token = options.token === undefined ? { role: 'admin', id: 'u1' } : options.token
192
194
  const handler = evaluateEmittedModule<(req: unknown, res: unknown) => Promise<void>>(
193
- generateInvoiceRegenerateRouteCode(),
195
+ generateInvoiceRegenerateRouteCode(SETTINGS),
194
196
  createInvoiceRouteRequire(SETTINGS, {
195
197
  dataAccess,
196
198
  pdfGenerator,
@@ -333,9 +335,9 @@ describe('/api/invoices/[id]/regenerate', () => {
333
335
  })
334
336
  })
335
337
 
336
- it('never emails the customer', async () => {
338
+ it('never emails the customer of a store that does not email invoices', async () => {
337
339
  // The route does not even load the sender: a require of it would throw here.
338
- const code = generateInvoiceRegenerateRouteCode()
340
+ const code = generateInvoiceRegenerateRouteCode(SETTINGS)
339
341
  expect(code).not.toContain('email-sender')
340
342
  expect(code).not.toContain('sendInvoiceEmail')
341
343
  })
@@ -402,7 +404,7 @@ describe('/api/invoices/generate — remembers where its PDF is stored', () => {
402
404
  invoiceData.invoiceNumber = 'INV-0009'
403
405
  return { invoice: invoiceData, created: true }
404
406
  },
405
- storeInvoicePdf: async () => ({}),
407
+ storeInvoicePdf: async () => ({ firstPdf: true }),
406
408
  updateInvoice: async (id: string, fields: Row) => {
407
409
  updates.push({ id, fields })
408
410
  return fields
@@ -61,7 +61,7 @@ async function run(
61
61
  },
62
62
  storeInvoicePdf: async (id: string, pdf: Buffer) => {
63
63
  stored.push({ id, bytes: pdf.length })
64
- return {}
64
+ return { firstPdf: true }
65
65
  },
66
66
  updateInvoice: async () => ({}),
67
67
  }
@@ -86,7 +86,7 @@ async function runHandler(order: Row, items: Row[], body: Row): Promise<HandlerR
86
86
  invoice = invoiceData
87
87
  return { invoice: invoiceData, created: true }
88
88
  },
89
- storeInvoicePdf: async () => ({}),
89
+ storeInvoicePdf: async () => ({ firstPdf: true }),
90
90
  updateInvoice: async () => ({}),
91
91
  }
92
92
  const pdfGeneratorStub = {
@@ -0,0 +1,306 @@
1
+ import type { UIDLWorkflow, UIDLWorkflowEdge, UIDLWorkflowNode } from '@teleporthq/teleport-types'
2
+ import { splitIntoSegments } from '../src/segment-splitter'
3
+ import type { WorkflowSegment } from '../src/types'
4
+
5
+ // Every server segment the browser reaches is one awaited round trip. A
6
+ // checkout is one long chain of server steps with a refusal branch (a toast, a
7
+ // reset) at almost every gate, all at the same depth as the chain's next step.
8
+ // Ordered by depth alone, each refusal step sat between two server steps of the
9
+ // chain and cut it into one more round trip; the loop over the cart lines held
10
+ // everything after it in a depth-only order. Built here the way the editor's
11
+ // checkout builds Place Order, with synthetic ids and labels.
12
+
13
+ type Kind = 'client' | 'server' | 'gate' | 'loop' | 'custom-node' | 'navigate'
14
+
15
+ const TYPE: Record<Kind, string> = {
16
+ client: 'state-update-local-state',
17
+ server: 'data-raw-query',
18
+ gate: 'general-if-statement',
19
+ loop: 'general-loop',
20
+ 'custom-node': 'general-custom-node',
21
+ navigate: 'navigation-go-to-page',
22
+ }
23
+
24
+ const ENV: Record<Kind, UIDLWorkflowNode['executionEnv']> = {
25
+ client: 'client',
26
+ server: 'server',
27
+ gate: 'universal',
28
+ loop: 'universal',
29
+ 'custom-node': 'universal',
30
+ navigate: 'client',
31
+ }
32
+
33
+ const buildWorkflow = (
34
+ steps: Array<[string, Kind]>,
35
+ links: Array<[string, string, string?, string?]>
36
+ ): UIDLWorkflow => {
37
+ const edges: UIDLWorkflowEdge[] = links.map(([source, target, sourceHandle, targetHandle]) => ({
38
+ id: `${source}->${target}`,
39
+ source,
40
+ target,
41
+ ...(sourceHandle ? { sourceHandle } : {}),
42
+ ...(targetHandle ? { targetHandle } : {}),
43
+ }))
44
+ // The editor's step numbers: breadth-first depth from the trigger.
45
+ const depth = new Map<string, number>()
46
+ const queue: Array<[string, number]> = [['trigger', 0]]
47
+ while (queue.length > 0) {
48
+ const [id, d] = queue.shift() as [string, number]
49
+ if (depth.has(id)) {
50
+ continue
51
+ }
52
+ depth.set(id, d)
53
+ edges.filter((e) => e.source === id).forEach((e) => queue.push([e.target, d + 1]))
54
+ }
55
+ return {
56
+ id: 'place-order',
57
+ name: 'Place Order',
58
+ trigger: { type: 'event-form-submitted', nodeId: 'trigger' },
59
+ nodes: steps.map(([id, kind]) => ({
60
+ id,
61
+ type: TYPE[kind],
62
+ label: id,
63
+ config: {},
64
+ executionEnv: ENV[kind],
65
+ stepNumber: depth.get(id) || 0,
66
+ })),
67
+ edges,
68
+ } as unknown as UIDLWorkflow
69
+ }
70
+
71
+ // A gate whose condition reads a server step, its refusal branch three or four
72
+ // browser steps long, its accepted branch the rest of the chain.
73
+ const placeOrder = buildWorkflow(
74
+ [
75
+ ['setBusy', 'client'],
76
+ ['formValid', 'gate'],
77
+ ['formToast', 'client'],
78
+ ['fetchStock', 'server'],
79
+ ['checkStock', 'server'],
80
+ ['inStock', 'gate'],
81
+ ['stockToast', 'client'],
82
+ ['stockReset', 'client'],
83
+ ['resolveUser', 'custom-node'],
84
+ ['loadDiscounts', 'server'],
85
+ ['countOrders', 'server'],
86
+ ['recheckVoucher', 'server'],
87
+ ['voucherValid', 'gate'],
88
+ ['forgetVoucher', 'client'],
89
+ ['voucherToast', 'client'],
90
+ ['voucherReset', 'client'],
91
+ ['voucherClear', 'client'],
92
+ ['priceOrder', 'server'],
93
+ ['assemble', 'server'],
94
+ ['checkAddress', 'server'],
95
+ ['canBill', 'gate'],
96
+ ['billToast', 'client'],
97
+ ['billReset', 'client'],
98
+ ['createOrder', 'server'],
99
+ ['redeem', 'server'],
100
+ ['redeemed', 'gate'],
101
+ ['cancelOrder', 'server'],
102
+ ['redeemToast', 'client'],
103
+ ['orderNumber', 'server'],
104
+ ['eachLine', 'loop'],
105
+ ['buildLine', 'server'],
106
+ ['saveLine', 'server'],
107
+ ['decrementStock', 'server'],
108
+ ['recordPlaced', 'custom-node'],
109
+ ['saveDetails', 'server'],
110
+ ['settled', 'gate'],
111
+ ['markSettled', 'server'],
112
+ ['clearCart', 'client'],
113
+ ['emailPayload', 'server'],
114
+ ['sendEmail', 'server'],
115
+ ['goToOrder', 'navigate'],
116
+ ['redirectUrls', 'server'],
117
+ ['charge', 'server'],
118
+ ['opened', 'gate'],
119
+ ['cancelRefused', 'server'],
120
+ ['refusedToast', 'client'],
121
+ ['recordCheckout', 'server'],
122
+ ['sendToCheckout', 'navigate'],
123
+ ],
124
+ [
125
+ ['trigger', 'setBusy'],
126
+ ['setBusy', 'formValid'],
127
+ ['formValid', 'formToast', 'false'],
128
+ ['formValid', 'fetchStock', 'true'],
129
+ ['fetchStock', 'checkStock'],
130
+ ['checkStock', 'inStock'],
131
+ ['inStock', 'stockToast', 'false'],
132
+ ['stockToast', 'stockReset'],
133
+ ['inStock', 'resolveUser', 'true'],
134
+ ['resolveUser', 'loadDiscounts'],
135
+ ['loadDiscounts', 'countOrders'],
136
+ ['countOrders', 'recheckVoucher'],
137
+ ['recheckVoucher', 'voucherValid'],
138
+ ['voucherValid', 'forgetVoucher', 'false'],
139
+ ['forgetVoucher', 'voucherToast'],
140
+ ['voucherToast', 'voucherReset'],
141
+ ['voucherReset', 'voucherClear'],
142
+ ['voucherValid', 'priceOrder', 'true'],
143
+ ['priceOrder', 'assemble'],
144
+ ['assemble', 'checkAddress'],
145
+ ['checkAddress', 'canBill'],
146
+ ['canBill', 'billToast', 'false'],
147
+ ['billToast', 'billReset'],
148
+ ['canBill', 'createOrder', 'true'],
149
+ ['createOrder', 'redeem'],
150
+ ['redeem', 'redeemed'],
151
+ ['redeemed', 'cancelOrder', 'false'],
152
+ ['cancelOrder', 'redeemToast'],
153
+ ['redeemed', 'orderNumber', 'true'],
154
+ ['orderNumber', 'eachLine'],
155
+ ['eachLine', 'buildLine', 'loop', 'loop-body-in'],
156
+ ['buildLine', 'saveLine'],
157
+ ['saveLine', 'eachLine', 'loop-body-out', 'loop-back'],
158
+ ['eachLine', 'decrementStock', 'exit'],
159
+ ['decrementStock', 'recordPlaced'],
160
+ ['recordPlaced', 'saveDetails'],
161
+ ['saveDetails', 'settled'],
162
+ ['settled', 'markSettled', 'true'],
163
+ ['markSettled', 'clearCart'],
164
+ ['clearCart', 'emailPayload'],
165
+ ['emailPayload', 'sendEmail'],
166
+ ['sendEmail', 'goToOrder'],
167
+ ['settled', 'redirectUrls', 'false'],
168
+ ['redirectUrls', 'charge'],
169
+ ['charge', 'opened'],
170
+ ['opened', 'cancelRefused', 'false'],
171
+ ['cancelRefused', 'refusedToast'],
172
+ ['opened', 'recordCheckout', 'true'],
173
+ ['recordCheckout', 'sendToCheckout'],
174
+ ]
175
+ )
176
+
177
+ const REFUSALS = [
178
+ 'formToast',
179
+ 'stockToast',
180
+ 'stockReset',
181
+ 'forgetVoucher',
182
+ 'voucherToast',
183
+ 'voucherReset',
184
+ 'voucherClear',
185
+ 'billToast',
186
+ 'billReset',
187
+ 'cancelOrder',
188
+ 'redeemToast',
189
+ 'cancelRefused',
190
+ 'refusedToast',
191
+ ]
192
+ const CASH_ONLY = ['markSettled', 'clearCart', 'emailPayload', 'sendEmail', 'goToOrder']
193
+ const ONLINE_ONLY = ['redirectUrls', 'charge', 'opened', 'recordCheckout', 'sendToCheckout']
194
+
195
+ // The server segments an order that every gate accepts calls, in order.
196
+ const roundTrips = (segments: WorkflowSegment[], skipped: string[]): string[][] =>
197
+ segments
198
+ .filter((s) => s.env === 'server')
199
+ .map((s) => s.nodes.map((n) => n.id).filter((id) => !skipped.includes(id)))
200
+ .filter((ids) => ids.length > 0)
201
+
202
+ const segmentOf = (segments: WorkflowSegment[], id: string): WorkflowSegment =>
203
+ segments.find((s) => s.nodes.some((n) => n.id === id)) as WorkflowSegment
204
+
205
+ describe('splitIntoSegments — a checkout waits on as few server round trips as it can', () => {
206
+ const segments = splitIntoSegments(placeOrder)
207
+
208
+ it('calls the server once between each pair of steps that need the browser', () => {
209
+ // Browser steps on the accepted path: resolveUser (custom node) and
210
+ // recordPlaced (custom node) — plus clearCart for a cash order.
211
+ expect(roundTrips(segments, [...REFUSALS, ...ONLINE_ONLY])).toEqual([
212
+ ['fetchStock', 'checkStock', 'inStock'],
213
+ [
214
+ 'loadDiscounts',
215
+ 'countOrders',
216
+ 'recheckVoucher',
217
+ 'voucherValid',
218
+ 'priceOrder',
219
+ 'assemble',
220
+ 'checkAddress',
221
+ 'canBill',
222
+ 'createOrder',
223
+ 'redeem',
224
+ 'redeemed',
225
+ 'orderNumber',
226
+ 'eachLine',
227
+ 'buildLine',
228
+ 'saveLine',
229
+ 'decrementStock',
230
+ ],
231
+ ['saveDetails', 'settled', 'markSettled'],
232
+ ['emailPayload', 'sendEmail'],
233
+ ])
234
+ expect(roundTrips(segments, [...REFUSALS, ...CASH_ONLY])).toHaveLength(3)
235
+ })
236
+
237
+ it('decides every gate that reads a server step inside the server route', () => {
238
+ // A gate the browser decides is one a crafted request can answer: the
239
+ // route then takes the browser's word for which branch was skipped.
240
+ for (const gate of ['inStock', 'voucherValid', 'canBill', 'redeemed', 'settled', 'opened']) {
241
+ expect(segmentOf(segments, gate).env).toBe('server')
242
+ }
243
+ // A refusal step on the server shares its gate's route, which skips it itself.
244
+ expect(segmentOf(segments, 'cancelOrder')).toBe(segmentOf(segments, 'redeemed'))
245
+ expect(segmentOf(segments, 'cancelRefused')).toBe(segmentOf(segments, 'opened'))
246
+ })
247
+
248
+ it('keeps the loop and its body in one segment, in order', () => {
249
+ const loopSegment = segmentOf(segments, 'eachLine')
250
+ const ids = loopSegment.nodes.map((n) => n.id)
251
+ expect(ids.slice(ids.indexOf('eachLine'), ids.indexOf('eachLine') + 3)).toEqual([
252
+ 'eachLine',
253
+ 'buildLine',
254
+ 'saveLine',
255
+ ])
256
+ expect(ids.indexOf('decrementStock')).toBeGreaterThan(ids.indexOf('saveLine'))
257
+ })
258
+
259
+ it('runs every step after everything it depends on', () => {
260
+ const order = segments.flatMap((s) => s.nodes.map((n) => n.id))
261
+ for (const e of placeOrder.edges) {
262
+ if (e.targetHandle === 'loop-back' || e.source === 'trigger') {
263
+ continue
264
+ }
265
+ expect([e.source, order.indexOf(e.source) < order.indexOf(e.target)]).toEqual([
266
+ e.source,
267
+ true,
268
+ ])
269
+ }
270
+ })
271
+ })
272
+
273
+ describe('splitIntoSegments — a server route runs its steps by step number', () => {
274
+ it('never puts a step in the segment of an input with a later step number', () => {
275
+ // `total` hangs off the trigger (step 1) but reads `line3` (step 3): the
276
+ // route, which sorts its segment by step, would run it before that value.
277
+ const workflow = {
278
+ id: 'w',
279
+ name: 'w',
280
+ trigger: { type: 'event-element-clicked', nodeId: 'trigger' },
281
+ nodes: [
282
+ { id: 'line1', type: 'data-select', stepNumber: 1, executionEnv: 'server', config: {} },
283
+ { id: 'line2', type: 'data-select', stepNumber: 2, executionEnv: 'server', config: {} },
284
+ { id: 'line3', type: 'data-select', stepNumber: 3, executionEnv: 'server', config: {} },
285
+ {
286
+ id: 'total',
287
+ type: 'data-create-item',
288
+ stepNumber: 1,
289
+ executionEnv: 'server',
290
+ config: { columnMappings: [{ value: { type: 'workflowContext', nodeId: 'line3' } }] },
291
+ },
292
+ ],
293
+ edges: [
294
+ { id: 'e0', source: 'trigger', target: 'line1' },
295
+ { id: 'e1', source: 'line1', target: 'line2' },
296
+ { id: 'e2', source: 'line2', target: 'line3' },
297
+ { id: 'e3', source: 'trigger', target: 'total' },
298
+ ],
299
+ } as unknown as UIDLWorkflow
300
+ const segments = splitIntoSegments(workflow)
301
+ expect(segments.map((s) => [s.env, s.nodes.map((n) => n.id)])).toEqual([
302
+ ['server', ['line1', 'line2', 'line3']],
303
+ ['server', ['total']],
304
+ ])
305
+ })
306
+ })