@teleporthq/teleport-plugin-next-workflows 0.43.75 → 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 (248) hide show
  1. package/__tests__/_helpers/fake-store-database.ts +428 -0
  2. package/__tests__/_helpers/invoice-store.ts +382 -0
  3. package/__tests__/booking-calendar-feed-route.test.ts +494 -0
  4. package/__tests__/custom-node-result-taken-branch.test.ts +107 -0
  5. package/__tests__/data-api-date-parser.test.ts +45 -0
  6. package/__tests__/email-locale-senders.test.ts +3 -1
  7. package/__tests__/email-sender-name.test.ts +513 -0
  8. package/__tests__/graph-utils-topological-order.test.ts +155 -1
  9. package/__tests__/invoice-billing-details.test.ts +384 -0
  10. package/__tests__/invoice-data-access-replace.test.ts +236 -2
  11. package/__tests__/invoice-order-items-settle.test.ts +1 -1
  12. package/__tests__/invoice-payment-email.test.ts +191 -0
  13. package/__tests__/invoice-regenerate-email.test.ts +288 -0
  14. package/__tests__/invoice-regenerate-route.test.ts +9 -7
  15. package/__tests__/invoice-reservation.test.ts +1 -1
  16. package/__tests__/invoice-tender.test.ts +1 -1
  17. package/__tests__/middleware-admin-panel-roles.test.ts +127 -0
  18. package/__tests__/page-arrival-remount.test.ts +247 -0
  19. package/__tests__/payment-drivers-module.test.ts +19 -0
  20. package/__tests__/raw-query-error-code.test.ts +47 -0
  21. package/__tests__/runtime-template-token-resolution.test.ts +16 -0
  22. package/__tests__/segment-reply-changed-keys.test.ts +31 -0
  23. package/__tests__/segment-route-skipped-branch.test.ts +101 -0
  24. package/__tests__/segment-splitter-checkout-order.test.ts +306 -0
  25. package/__tests__/streaming-on-end-env-filter.test.ts +18 -18
  26. package/__tests__/streaming-on-end-order.test.ts +387 -0
  27. package/dist/cjs/account-delete-route-generator.d.ts +1 -0
  28. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  29. package/dist/cjs/account-delete-route-generator.js +2 -1
  30. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  31. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  32. package/dist/cjs/api-route-generator.js +23 -13
  33. package/dist/cjs/api-route-generator.js.map +1 -1
  34. package/dist/cjs/auth-generator.d.ts +1 -0
  35. package/dist/cjs/auth-generator.d.ts.map +1 -1
  36. package/dist/cjs/auth-generator.js +77 -28
  37. package/dist/cjs/auth-generator.js.map +1 -1
  38. package/dist/cjs/booking-calendar-feed-route-generator.d.ts +28 -0
  39. package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -0
  40. package/dist/cjs/booking-calendar-feed-route-generator.js +296 -0
  41. package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -0
  42. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  43. package/dist/cjs/data-api-route-generator.js +6 -1
  44. package/dist/cjs/data-api-route-generator.js.map +1 -1
  45. package/dist/cjs/executor-generator.d.ts.map +1 -1
  46. package/dist/cjs/executor-generator.js +39 -28
  47. package/dist/cjs/executor-generator.js.map +1 -1
  48. package/dist/cjs/graph-utils.d.ts +31 -1
  49. package/dist/cjs/graph-utils.d.ts.map +1 -1
  50. package/dist/cjs/graph-utils.js +112 -10
  51. package/dist/cjs/graph-utils.js.map +1 -1
  52. package/dist/cjs/index.d.ts +1 -1
  53. package/dist/cjs/index.d.ts.map +1 -1
  54. package/dist/cjs/index.js +2 -1
  55. package/dist/cjs/index.js.map +1 -1
  56. package/dist/cjs/invoice/api-routes-code.d.ts +12 -3
  57. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  58. package/dist/cjs/invoice/api-routes-code.js +152 -21
  59. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  60. package/dist/cjs/invoice/data-access-code.d.ts.map +1 -1
  61. package/dist/cjs/invoice/data-access-code.js +133 -21
  62. package/dist/cjs/invoice/data-access-code.js.map +1 -1
  63. package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
  64. package/dist/cjs/invoice/email-sender-code.js +15 -25
  65. package/dist/cjs/invoice/email-sender-code.js.map +1 -1
  66. package/dist/cjs/invoice/index.js +1 -1
  67. package/dist/cjs/invoice/index.js.map +1 -1
  68. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  69. package/dist/cjs/invoice/invoice-assembly-code.js +50 -6
  70. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  71. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  72. package/dist/cjs/invoice/invoice-html-code.js +13 -2
  73. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  74. package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
  75. package/dist/cjs/nodes/data/data-raw-query.js +11 -1
  76. package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
  77. package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
  78. package/dist/cjs/nodes/email/email-mailgun.js +4 -2
  79. package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
  80. package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
  81. package/dist/cjs/nodes/email/email-postmark.js +5 -2
  82. package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
  83. package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
  84. package/dist/cjs/nodes/email/email-resend.js +5 -2
  85. package/dist/cjs/nodes/email/email-resend.js.map +1 -1
  86. package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
  87. package/dist/cjs/nodes/email/email-sendgrid.js +8 -2
  88. package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
  89. package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
  90. package/dist/cjs/nodes/general/general-custom-js.js +11 -0
  91. package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
  92. package/dist/cjs/nodes/storage/storage-local-remove.d.ts.map +1 -1
  93. package/dist/cjs/nodes/storage/storage-local-remove.js +2 -0
  94. package/dist/cjs/nodes/storage/storage-local-remove.js.map +1 -1
  95. package/dist/cjs/nodes/storage/storage-local-set.d.ts.map +1 -1
  96. package/dist/cjs/nodes/storage/storage-local-set.js +4 -0
  97. package/dist/cjs/nodes/storage/storage-local-set.js.map +1 -1
  98. package/dist/cjs/page-arrival.d.ts +10 -0
  99. package/dist/cjs/page-arrival.d.ts.map +1 -0
  100. package/dist/cjs/page-arrival.js +86 -0
  101. package/dist/cjs/page-arrival.js.map +1 -0
  102. package/dist/cjs/payments/index.d.ts +10 -4
  103. package/dist/cjs/payments/index.d.ts.map +1 -1
  104. package/dist/cjs/payments/index.js +18 -7
  105. package/dist/cjs/payments/index.js.map +1 -1
  106. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  107. package/dist/cjs/segment-splitter.js +65 -27
  108. package/dist/cjs/segment-splitter.js.map +1 -1
  109. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  110. package/dist/cjs/transactional-email-code.js +32 -18
  111. package/dist/cjs/transactional-email-code.js.map +1 -1
  112. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  113. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  114. package/dist/cjs/workflow-component-plugin.js +35 -149
  115. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  116. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  117. package/dist/cjs/workflow-project-plugin.js +16 -4
  118. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  119. package/dist/cjs/workflow-scope.d.ts +19 -0
  120. package/dist/cjs/workflow-scope.d.ts.map +1 -0
  121. package/dist/cjs/workflow-scope.js +147 -0
  122. package/dist/cjs/workflow-scope.js.map +1 -0
  123. package/dist/esm/account-delete-route-generator.d.ts +1 -0
  124. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  125. package/dist/esm/account-delete-route-generator.js +2 -1
  126. package/dist/esm/account-delete-route-generator.js.map +1 -1
  127. package/dist/esm/api-route-generator.d.ts.map +1 -1
  128. package/dist/esm/api-route-generator.js +23 -13
  129. package/dist/esm/api-route-generator.js.map +1 -1
  130. package/dist/esm/auth-generator.d.ts +1 -0
  131. package/dist/esm/auth-generator.d.ts.map +1 -1
  132. package/dist/esm/auth-generator.js +77 -28
  133. package/dist/esm/auth-generator.js.map +1 -1
  134. package/dist/esm/booking-calendar-feed-route-generator.d.ts +28 -0
  135. package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -0
  136. package/dist/esm/booking-calendar-feed-route-generator.js +291 -0
  137. package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -0
  138. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  139. package/dist/esm/data-api-route-generator.js +6 -1
  140. package/dist/esm/data-api-route-generator.js.map +1 -1
  141. package/dist/esm/executor-generator.d.ts.map +1 -1
  142. package/dist/esm/executor-generator.js +39 -28
  143. package/dist/esm/executor-generator.js.map +1 -1
  144. package/dist/esm/graph-utils.d.ts +31 -1
  145. package/dist/esm/graph-utils.d.ts.map +1 -1
  146. package/dist/esm/graph-utils.js +109 -9
  147. package/dist/esm/graph-utils.js.map +1 -1
  148. package/dist/esm/index.d.ts +1 -1
  149. package/dist/esm/index.d.ts.map +1 -1
  150. package/dist/esm/index.js +1 -1
  151. package/dist/esm/index.js.map +1 -1
  152. package/dist/esm/invoice/api-routes-code.d.ts +12 -3
  153. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  154. package/dist/esm/invoice/api-routes-code.js +152 -21
  155. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  156. package/dist/esm/invoice/data-access-code.d.ts.map +1 -1
  157. package/dist/esm/invoice/data-access-code.js +133 -21
  158. package/dist/esm/invoice/data-access-code.js.map +1 -1
  159. package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
  160. package/dist/esm/invoice/email-sender-code.js +15 -25
  161. package/dist/esm/invoice/email-sender-code.js.map +1 -1
  162. package/dist/esm/invoice/index.js +1 -1
  163. package/dist/esm/invoice/index.js.map +1 -1
  164. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  165. package/dist/esm/invoice/invoice-assembly-code.js +50 -6
  166. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  167. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  168. package/dist/esm/invoice/invoice-html-code.js +13 -2
  169. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  170. package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
  171. package/dist/esm/nodes/data/data-raw-query.js +11 -1
  172. package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
  173. package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
  174. package/dist/esm/nodes/email/email-mailgun.js +4 -2
  175. package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
  176. package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
  177. package/dist/esm/nodes/email/email-postmark.js +5 -2
  178. package/dist/esm/nodes/email/email-postmark.js.map +1 -1
  179. package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
  180. package/dist/esm/nodes/email/email-resend.js +5 -2
  181. package/dist/esm/nodes/email/email-resend.js.map +1 -1
  182. package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
  183. package/dist/esm/nodes/email/email-sendgrid.js +8 -2
  184. package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
  185. package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
  186. package/dist/esm/nodes/general/general-custom-js.js +11 -0
  187. package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
  188. package/dist/esm/nodes/storage/storage-local-remove.d.ts.map +1 -1
  189. package/dist/esm/nodes/storage/storage-local-remove.js +2 -0
  190. package/dist/esm/nodes/storage/storage-local-remove.js.map +1 -1
  191. package/dist/esm/nodes/storage/storage-local-set.d.ts.map +1 -1
  192. package/dist/esm/nodes/storage/storage-local-set.js +4 -0
  193. package/dist/esm/nodes/storage/storage-local-set.js.map +1 -1
  194. package/dist/esm/page-arrival.d.ts +10 -0
  195. package/dist/esm/page-arrival.d.ts.map +1 -0
  196. package/dist/esm/page-arrival.js +81 -0
  197. package/dist/esm/page-arrival.js.map +1 -0
  198. package/dist/esm/payments/index.d.ts +10 -4
  199. package/dist/esm/payments/index.d.ts.map +1 -1
  200. package/dist/esm/payments/index.js +16 -6
  201. package/dist/esm/payments/index.js.map +1 -1
  202. package/dist/esm/segment-splitter.d.ts.map +1 -1
  203. package/dist/esm/segment-splitter.js +66 -28
  204. package/dist/esm/segment-splitter.js.map +1 -1
  205. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  206. package/dist/esm/transactional-email-code.js +32 -18
  207. package/dist/esm/transactional-email-code.js.map +1 -1
  208. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  209. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  210. package/dist/esm/workflow-component-plugin.js +32 -146
  211. package/dist/esm/workflow-component-plugin.js.map +1 -1
  212. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  213. package/dist/esm/workflow-project-plugin.js +16 -4
  214. package/dist/esm/workflow-project-plugin.js.map +1 -1
  215. package/dist/esm/workflow-scope.d.ts +19 -0
  216. package/dist/esm/workflow-scope.d.ts.map +1 -0
  217. package/dist/esm/workflow-scope.js +142 -0
  218. package/dist/esm/workflow-scope.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/account-delete-route-generator.ts +3 -1
  221. package/src/api-route-generator.ts +23 -13
  222. package/src/auth-generator.ts +83 -26
  223. package/src/booking-calendar-feed-route-generator.ts +293 -0
  224. package/src/data-api-route-generator.ts +6 -1
  225. package/src/executor-generator.ts +39 -28
  226. package/src/graph-utils.ts +143 -11
  227. package/src/index.ts +1 -0
  228. package/src/invoice/api-routes-code.ts +166 -21
  229. package/src/invoice/data-access-code.ts +139 -21
  230. package/src/invoice/email-sender-code.ts +15 -25
  231. package/src/invoice/index.ts +1 -1
  232. package/src/invoice/invoice-assembly-code.ts +50 -6
  233. package/src/invoice/invoice-html-code.ts +13 -2
  234. package/src/nodes/data/data-raw-query.ts +11 -1
  235. package/src/nodes/email/email-mailgun.ts +11 -2
  236. package/src/nodes/email/email-postmark.ts +12 -2
  237. package/src/nodes/email/email-resend.ts +12 -2
  238. package/src/nodes/email/email-sendgrid.ts +15 -2
  239. package/src/nodes/general/general-custom-js.ts +10 -0
  240. package/src/nodes/storage/storage-local-remove.ts +4 -0
  241. package/src/nodes/storage/storage-local-set.ts +6 -0
  242. package/src/page-arrival.ts +115 -0
  243. package/src/payments/index.ts +18 -5
  244. package/src/segment-splitter.ts +90 -36
  245. package/src/transactional-email-code.ts +32 -21
  246. package/src/workflow-component-plugin.ts +43 -188
  247. package/src/workflow-project-plugin.ts +17 -4
  248. package/src/workflow-scope.ts +184 -0
@@ -0,0 +1,191 @@
1
+ import {
2
+ BUYER,
3
+ ORDER_ID,
4
+ Row,
5
+ Store,
6
+ historyLines,
7
+ invoiceOf,
8
+ ledgerRows,
9
+ openStore,
10
+ payWhilePdfServiceIsDown,
11
+ runsAsTheStore,
12
+ seedPaidOrder,
13
+ } from './_helpers/invoice-store'
14
+
15
+ /**
16
+ * The invoice email a payment sends is on its order's history.
17
+ *
18
+ * With Invoice Email Delivery on, the payment's `/api/invoices/generate`
19
+ * emails the buyer the invoice it just issued. The admin panel's Regenerate
20
+ * writes an "email sent" line on the order's history when it emails one; the
21
+ * payment wrote none, so the order never showed that its buyer got the
22
+ * invoice.
23
+ *
24
+ * Everything here is the code the store runs, as emitted (see
25
+ * `_helpers/invoice-store.ts`); only its database, the PDF service and the
26
+ * email provider are faked.
27
+ */
28
+ describe("the payment's invoice email is on its order's history", () => {
29
+ runsAsTheStore()
30
+
31
+ it('the email the payment sends writes one "email sent" line, by the store', async () => {
32
+ const store = openStore()
33
+ seedPaidOrder(store.db, ORDER_ID)
34
+
35
+ const paid = await store.pay(ORDER_ID)
36
+
37
+ expect(paid.status).toBe(200)
38
+ const invoice = invoiceOf(store, ORDER_ID)
39
+ expect(store.emails.map((email) => email.To)).toEqual([BUYER])
40
+ expect(ledgerRows(store)).toEqual([
41
+ expect.objectContaining({ status: 'sent', source_ref: 'api/invoices/generate' }),
42
+ ])
43
+ // The line the regeneration writes, keyed on the same invoice: an
44
+ // invoice's email is on its order's history once, whoever sent it.
45
+ expect(historyLines(store)).toEqual([
46
+ expect.objectContaining({
47
+ event_type: 'order-email-sent',
48
+ dedupe_key: 'order-email-sent:' + String(invoice.id) + ':invoice-email',
49
+ order_id: ORDER_ID,
50
+ payload: JSON.stringify({ purpose: 'invoice-email', to: BUYER }),
51
+ actor: '',
52
+ actor_type: 'system',
53
+ status: 'recorded',
54
+ }),
55
+ ])
56
+ })
57
+
58
+ it('a payment reported again sends nothing and writes no second line', async () => {
59
+ const store = openStore()
60
+ seedPaidOrder(store.db, ORDER_ID)
61
+ expect((await store.pay(ORDER_ID)).status).toBe(200)
62
+
63
+ const again = await store.pay(ORDER_ID)
64
+
65
+ expect(again.body).toMatchObject({ success: true, alreadyIssued: true })
66
+ expect(store.emails).toHaveLength(1)
67
+ expect(historyLines(store)).toHaveLength(1)
68
+ })
69
+
70
+ it('an invoice an admin issues by hand is on the history under their name', async () => {
71
+ const store = openStore()
72
+ seedPaidOrder(store.db, ORDER_ID)
73
+
74
+ expect((await store.issueAsAdmin(ORDER_ID)).status).toBe(200)
75
+
76
+ expect(store.emails).toHaveLength(1)
77
+ expect(historyLines(store)).toEqual([
78
+ expect.objectContaining({ actor: 'Store admin', actor_type: 'merchant' }),
79
+ ])
80
+ })
81
+
82
+ it('a refused email writes no line, and the log says it failed', async () => {
83
+ const store = openStore()
84
+ seedPaidOrder(store.db, ORDER_ID)
85
+ store.provider.refuses = true
86
+
87
+ const paid = await store.pay(ORDER_ID)
88
+
89
+ expect(paid.status).toBe(200)
90
+ expect(store.emails).toHaveLength(1)
91
+ expect(ledgerRows(store)).toEqual([expect.objectContaining({ status: 'failed' })])
92
+ expect(historyLines(store)).toEqual([])
93
+ expect(store.errors).toContainEqual(expect.stringContaining('Email delivery FAILED'))
94
+ })
95
+
96
+ it('a history that cannot be written costs nothing else: the email goes and the payment is answered', async () => {
97
+ const store = openStore()
98
+ seedPaidOrder(store.db, ORDER_ID)
99
+ store.db.failNext(/^INSERT INTO teleport_events /, 'relation "teleport_events" does not exist')
100
+
101
+ const paid = await store.pay(ORDER_ID)
102
+
103
+ expect(paid.status).toBe(200)
104
+ expect(paid.body).toMatchObject({ success: true, invoiceNumber: 'INV-0001' })
105
+ expect(store.emails).toHaveLength(1)
106
+ expect(historyLines(store)).toEqual([])
107
+ expect(store.errors).toContainEqual(
108
+ expect.stringContaining('INV-0001 was emailed, but the order history could not record it')
109
+ )
110
+ })
111
+
112
+ it('Invoice Email Delivery off: no email and no line', async () => {
113
+ const store = openStore({ emailDelivery: false })
114
+ seedPaidOrder(store.db, ORDER_ID)
115
+
116
+ expect((await store.pay(ORDER_ID)).status).toBe(200)
117
+
118
+ expect(store.emails).toEqual([])
119
+ expect(historyLines(store)).toEqual([])
120
+ })
121
+ })
122
+
123
+ /**
124
+ * A retried payment and the merchant's Regenerate finishing the same invoice.
125
+ *
126
+ * The PDF service was down at payment, so the invoice is reserved without a
127
+ * PDF. A quarter of an hour later the provider retries the payment, which
128
+ * claims the stale reservation and renders it — and the merchant clicks
129
+ * Regenerate meanwhile. Each route emails the buyer when it gives the invoice
130
+ * its first PDF, so the database must give that to exactly one of them.
131
+ */
132
+ describe('a retried payment and a regeneration finishing the same invoice', () => {
133
+ runsAsTheStore()
134
+
135
+ const PAYMENT_PDF = /^UPDATE teleport_invoices SET "?pdf_data"? = \$1/
136
+ const REGENERATION_PDF = /^UPDATE teleport_invoices SET "invoice_number" = \$1/
137
+
138
+ /** The order paid while the PDF service was down, its reservation older than any request lives. */
139
+ async function staleInvoice(store: Store): Promise<Row> {
140
+ seedPaidOrder(store.db, ORDER_ID)
141
+ const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
142
+ invoice.updated_at = new Date(Date.now() - 20 * 60 * 1000).toISOString()
143
+ return invoice
144
+ }
145
+
146
+ it("the regeneration's PDF lands first: it emails the buyer, and the payment answers with its invoice", async () => {
147
+ const store = openStore()
148
+ const invoice = await staleInvoice(store)
149
+ const paymentsPdf = store.db.pauseNext(PAYMENT_PDF)
150
+
151
+ const payment = store.pay(ORDER_ID)
152
+ await paymentsPdf.reached
153
+ const regenerated = await store.regenerate(String(invoice.id))
154
+ paymentsPdf.release()
155
+ const paid = await payment
156
+
157
+ expect(store.emails.map((email) => email.To)).toEqual([BUYER])
158
+ expect(ledgerRows(store).map((row) => row.source_ref)).toEqual(['api/invoices/[id]/regenerate'])
159
+ expect(historyLines(store)).toEqual([expect.objectContaining({ actor_type: 'merchant' })])
160
+ expect(regenerated.status).toBe(200)
161
+ expect(paid.status).toBe(200)
162
+ expect(paid.body).toMatchObject({
163
+ success: true,
164
+ alreadyIssued: true,
165
+ invoiceId: invoice.id,
166
+ invoiceNumber: 'INV-0001',
167
+ pdfUrl: '/api/invoices/' + String(invoice.id) + '/pdf',
168
+ })
169
+ expect(store.db.row('teleport_orders', ORDER_ID)).toMatchObject({ invoice_id: invoice.id })
170
+ })
171
+
172
+ it("the payment's PDF lands first: it emails the buyer, and the regeneration does not", async () => {
173
+ const store = openStore()
174
+ const invoice = await staleInvoice(store)
175
+ const regenerationsPdf = store.db.pauseNext(REGENERATION_PDF)
176
+
177
+ const regeneration = store.regenerate(String(invoice.id))
178
+ await regenerationsPdf.reached
179
+ const paid = await store.pay(ORDER_ID)
180
+ regenerationsPdf.release()
181
+ const regenerated = await regeneration
182
+
183
+ expect(store.emails.map((email) => email.To)).toEqual([BUYER])
184
+ expect(ledgerRows(store).map((row) => row.source_ref)).toEqual(['api/invoices/generate'])
185
+ expect(historyLines(store)).toEqual([expect.objectContaining({ actor_type: 'system' })])
186
+ expect(paid.status).toBe(200)
187
+ expect(paid.body).toMatchObject({ success: true, invoiceId: invoice.id })
188
+ expect(paid.body).not.toHaveProperty('alreadyIssued')
189
+ expect(regenerated.status).toBe(200)
190
+ })
191
+ })
@@ -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,127 @@
1
+ import { UIDLAuthentication } from '@teleporthq/teleport-types'
2
+ import { generateMiddlewareFile } from '../src/auth-generator'
3
+ import {
4
+ compileGeneratedMiddleware,
5
+ extractProtectedRoutes,
6
+ } from './_helpers/run-generated-middleware'
7
+
8
+ /**
9
+ * An admin panel whose folder admits admins only, some of whose pages (the
10
+ * dashboard first among them) also admit staff, whose built-in entities stay
11
+ * admin only, and whose project redirects the home page into the dashboard
12
+ * (`homeRedirect`). Two things must follow from the ADMIN FOLDER's protection,
13
+ * never from whichever page happens to come first:
14
+ * - the `/admin` catch-all, which guards `/admin` itself and every admin page
15
+ * the middleware has no entry for (one an owner added without protection);
16
+ * - where a role denial lands: never `/` when `/` leads straight back into a
17
+ * page the visitor may not open — that is a redirect loop.
18
+ */
19
+
20
+ const page = (route: string, allowedRoles: string[]) => ({
21
+ requiresAuth: true,
22
+ allowedRoles,
23
+ pageName: route,
24
+ route,
25
+ })
26
+
27
+ const panelAuth = (homeRedirect?: string): UIDLAuthentication => ({
28
+ enabled: true,
29
+ dataSourceId: 'ds-1',
30
+ dataSourceType: 'teleport',
31
+ passwordAuthEnabled: true,
32
+ providers: [],
33
+ roles: ['admin', 'staff', 'user'],
34
+ tables: {},
35
+ // The dashboard is the first admin route, as the GUI emits it.
36
+ pageProtection: {
37
+ 'p-dashboard': page('/admin/dashboard', ['admin', 'staff']),
38
+ 'p-tasks': page('/admin/tasks', ['admin', 'staff']),
39
+ 'p-users': page('/admin/users', ['admin']),
40
+ },
41
+ folderProtection: {
42
+ 'f-admin': {
43
+ requiresAuth: true,
44
+ allowedRoles: ['admin'],
45
+ folderName: 'admin-panel',
46
+ parentId: 'pages',
47
+ children: { 'p-dashboard': 'page', 'f-tasks': 'folder', 'f-users': 'folder' },
48
+ },
49
+ 'f-tasks': {
50
+ requiresAuth: true,
51
+ allowedRoles: ['admin', 'staff'],
52
+ folderName: 'tasks',
53
+ parentId: 'f-admin',
54
+ children: { 'p-tasks': 'page' },
55
+ },
56
+ 'f-users': {
57
+ requiresAuth: true,
58
+ allowedRoles: ['admin'],
59
+ folderName: 'users',
60
+ parentId: 'f-admin',
61
+ children: { 'p-users': 'page' },
62
+ },
63
+ },
64
+ authPages: {
65
+ signIn: { pageId: 'p-sign-in', pageName: 'sign-in', route: '/auth/sign-in' },
66
+ },
67
+ callbackBaseUrl: '/api/auth/callback',
68
+ envKeys: {},
69
+ customUserProperties: [],
70
+ ...(homeRedirect ? { homeRedirect } : {}),
71
+ })
72
+
73
+ const ORIGIN = 'https://panel.test'
74
+
75
+ describe('the /admin catch-all', () => {
76
+ it("admits the admin folder's roles, not the first admin page's", () => {
77
+ const routes = extractProtectedRoutes(generateMiddlewareFile(panelAuth('/admin/dashboard')))
78
+ expect(routes['/admin']).toEqual({ requiresAuth: true, allowedRoles: ['admin'] })
79
+ })
80
+
81
+ it('keeps staff out of an admin page the middleware has no entry for', async () => {
82
+ const run = compileGeneratedMiddleware(panelAuth('/admin/dashboard'))
83
+ const staff = { id: 's1', role: 'staff' }
84
+ expect(await run('/admin/reports', { sessionUser: staff, origin: ORIGIN })).toMatchObject({
85
+ kind: 'redirect',
86
+ })
87
+ expect(await run('/admin/tasks', { sessionUser: staff, origin: ORIGIN })).toEqual({
88
+ kind: 'next',
89
+ })
90
+ expect(
91
+ await run('/admin/reports', { sessionUser: { id: 'a1', role: 'admin' }, origin: ORIGIN })
92
+ ).toEqual({ kind: 'next' })
93
+ })
94
+ })
95
+
96
+ describe('a role denial when the home page redirects into the admin panel', () => {
97
+ const run = compileGeneratedMiddleware(panelAuth('/admin/dashboard'))
98
+ const signInLocation = (callbackUrl: string) =>
99
+ `${ORIGIN}/auth/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}&error=forbidden`
100
+
101
+ it('sends a signed-in account the panel does not admit to sign-in, explained — never to "/"', async () => {
102
+ const user = { id: 'u1', role: 'user' }
103
+ // "/" would redirect to the dashboard, which denies the role again: a loop.
104
+ expect(await run('/admin/dashboard', { sessionUser: user, origin: ORIGIN })).toEqual({
105
+ kind: 'redirect',
106
+ location: signInLocation('/admin/dashboard'),
107
+ })
108
+ expect(await run('/admin/users', { sessionUser: user, origin: ORIGIN })).toEqual({
109
+ kind: 'redirect',
110
+ location: signInLocation('/admin/users'),
111
+ })
112
+ })
113
+
114
+ it('still sends staff denied an admin-only page home, where the dashboard admits them', async () => {
115
+ expect(
116
+ await run('/admin/users', { sessionUser: { id: 's1', role: 'staff' }, origin: ORIGIN })
117
+ ).toEqual({ kind: 'redirect', location: `${ORIGIN}/` })
118
+ })
119
+
120
+ it('leaves a project whose home is not redirected as it was', () => {
121
+ const code = generateMiddlewareFile(panelAuth())
122
+ expect(code).toContain('function roleDeniedRedirect(request, pathname) {')
123
+ expect(code).toContain('return roleDeniedRedirect(request, pathname);')
124
+ expect(code).not.toContain('homeRedirect')
125
+ expect(code).not.toContain("'forbidden'")
126
+ })
127
+ })