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

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 (148) hide show
  1. package/__tests__/booking-calendar-feed-route.test.ts +494 -0
  2. package/__tests__/custom-node-result-taken-branch.test.ts +107 -0
  3. package/__tests__/data-api-date-parser.test.ts +45 -0
  4. package/__tests__/emit-custom-event-channel.test.ts +13 -0
  5. package/__tests__/invoice-billing-details.test.ts +278 -0
  6. package/__tests__/middleware-admin-panel-roles.test.ts +127 -0
  7. package/__tests__/page-arrival-remount.test.ts +247 -0
  8. package/__tests__/payment-driver-stripe.test.ts +23 -0
  9. package/__tests__/payment-drivers-module.test.ts +19 -0
  10. package/__tests__/payment-subscription-nodes.test.ts +8 -0
  11. package/__tests__/raw-query-error-code.test.ts +47 -0
  12. package/__tests__/runtime-template-token-resolution.test.ts +16 -0
  13. package/__tests__/segment-reply-changed-keys.test.ts +31 -0
  14. package/__tests__/segment-route-skipped-branch.test.ts +101 -0
  15. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  16. package/dist/cjs/api-route-generator.js +23 -13
  17. package/dist/cjs/api-route-generator.js.map +1 -1
  18. package/dist/cjs/auth-generator.d.ts.map +1 -1
  19. package/dist/cjs/auth-generator.js +75 -27
  20. package/dist/cjs/auth-generator.js.map +1 -1
  21. package/dist/cjs/booking-calendar-feed-route-generator.d.ts +28 -0
  22. package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -0
  23. package/dist/cjs/booking-calendar-feed-route-generator.js +296 -0
  24. package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -0
  25. package/dist/cjs/data-api-route-generator.d.ts +6 -8
  26. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  27. package/dist/cjs/data-api-route-generator.js +12 -16
  28. package/dist/cjs/data-api-route-generator.js.map +1 -1
  29. package/dist/cjs/executor-generator.d.ts.map +1 -1
  30. package/dist/cjs/executor-generator.js +30 -2
  31. package/dist/cjs/executor-generator.js.map +1 -1
  32. package/dist/cjs/index.d.ts +1 -1
  33. package/dist/cjs/index.d.ts.map +1 -1
  34. package/dist/cjs/index.js +2 -1
  35. package/dist/cjs/index.js.map +1 -1
  36. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  37. package/dist/cjs/invoice/invoice-assembly-code.js +46 -6
  38. package/dist/cjs/invoice/invoice-assembly-code.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 +10 -2
  41. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  42. package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
  43. package/dist/cjs/nodes/data/data-raw-query.js +11 -1
  44. package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
  45. package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
  46. package/dist/cjs/nodes/general/general-custom-js.js +11 -0
  47. package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
  48. package/dist/cjs/nodes/general/general-emit-custom-event.d.ts.map +1 -1
  49. package/dist/cjs/nodes/general/general-emit-custom-event.js +3 -1
  50. package/dist/cjs/nodes/general/general-emit-custom-event.js.map +1 -1
  51. package/dist/cjs/page-arrival.d.ts +10 -0
  52. package/dist/cjs/page-arrival.d.ts.map +1 -0
  53. package/dist/cjs/page-arrival.js +86 -0
  54. package/dist/cjs/page-arrival.js.map +1 -0
  55. package/dist/cjs/payments/drivers/stripe-driver-code.d.ts.map +1 -1
  56. package/dist/cjs/payments/drivers/stripe-driver-code.js +11 -0
  57. package/dist/cjs/payments/drivers/stripe-driver-code.js.map +1 -1
  58. package/dist/cjs/payments/index.d.ts +10 -4
  59. package/dist/cjs/payments/index.d.ts.map +1 -1
  60. package/dist/cjs/payments/index.js +18 -7
  61. package/dist/cjs/payments/index.js.map +1 -1
  62. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  63. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  64. package/dist/cjs/workflow-component-plugin.js +35 -149
  65. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  66. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  67. package/dist/cjs/workflow-project-plugin.js +14 -4
  68. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  69. package/dist/cjs/workflow-scope.d.ts +19 -0
  70. package/dist/cjs/workflow-scope.d.ts.map +1 -0
  71. package/dist/cjs/workflow-scope.js +147 -0
  72. package/dist/cjs/workflow-scope.js.map +1 -0
  73. package/dist/esm/api-route-generator.d.ts.map +1 -1
  74. package/dist/esm/api-route-generator.js +23 -13
  75. package/dist/esm/api-route-generator.js.map +1 -1
  76. package/dist/esm/auth-generator.d.ts.map +1 -1
  77. package/dist/esm/auth-generator.js +75 -27
  78. package/dist/esm/auth-generator.js.map +1 -1
  79. package/dist/esm/booking-calendar-feed-route-generator.d.ts +28 -0
  80. package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -0
  81. package/dist/esm/booking-calendar-feed-route-generator.js +291 -0
  82. package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -0
  83. package/dist/esm/data-api-route-generator.d.ts +6 -8
  84. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  85. package/dist/esm/data-api-route-generator.js +13 -17
  86. package/dist/esm/data-api-route-generator.js.map +1 -1
  87. package/dist/esm/executor-generator.d.ts.map +1 -1
  88. package/dist/esm/executor-generator.js +30 -2
  89. package/dist/esm/executor-generator.js.map +1 -1
  90. package/dist/esm/index.d.ts +1 -1
  91. package/dist/esm/index.d.ts.map +1 -1
  92. package/dist/esm/index.js +1 -1
  93. package/dist/esm/index.js.map +1 -1
  94. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  95. package/dist/esm/invoice/invoice-assembly-code.js +46 -6
  96. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  97. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  98. package/dist/esm/invoice/invoice-html-code.js +10 -2
  99. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  100. package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
  101. package/dist/esm/nodes/data/data-raw-query.js +11 -1
  102. package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
  103. package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
  104. package/dist/esm/nodes/general/general-custom-js.js +11 -0
  105. package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
  106. package/dist/esm/nodes/general/general-emit-custom-event.d.ts.map +1 -1
  107. package/dist/esm/nodes/general/general-emit-custom-event.js +3 -1
  108. package/dist/esm/nodes/general/general-emit-custom-event.js.map +1 -1
  109. package/dist/esm/page-arrival.d.ts +10 -0
  110. package/dist/esm/page-arrival.d.ts.map +1 -0
  111. package/dist/esm/page-arrival.js +81 -0
  112. package/dist/esm/page-arrival.js.map +1 -0
  113. package/dist/esm/payments/drivers/stripe-driver-code.d.ts.map +1 -1
  114. package/dist/esm/payments/drivers/stripe-driver-code.js +11 -0
  115. package/dist/esm/payments/drivers/stripe-driver-code.js.map +1 -1
  116. package/dist/esm/payments/index.d.ts +10 -4
  117. package/dist/esm/payments/index.d.ts.map +1 -1
  118. package/dist/esm/payments/index.js +16 -6
  119. package/dist/esm/payments/index.js.map +1 -1
  120. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  121. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  122. package/dist/esm/workflow-component-plugin.js +32 -146
  123. package/dist/esm/workflow-component-plugin.js.map +1 -1
  124. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  125. package/dist/esm/workflow-project-plugin.js +14 -4
  126. package/dist/esm/workflow-project-plugin.js.map +1 -1
  127. package/dist/esm/workflow-scope.d.ts +19 -0
  128. package/dist/esm/workflow-scope.d.ts.map +1 -0
  129. package/dist/esm/workflow-scope.js +142 -0
  130. package/dist/esm/workflow-scope.js.map +1 -0
  131. package/package.json +4 -4
  132. package/src/api-route-generator.ts +23 -13
  133. package/src/auth-generator.ts +80 -25
  134. package/src/booking-calendar-feed-route-generator.ts +293 -0
  135. package/src/data-api-route-generator.ts +14 -19
  136. package/src/executor-generator.ts +30 -2
  137. package/src/index.ts +1 -0
  138. package/src/invoice/invoice-assembly-code.ts +46 -6
  139. package/src/invoice/invoice-html-code.ts +10 -2
  140. package/src/nodes/data/data-raw-query.ts +11 -1
  141. package/src/nodes/general/general-custom-js.ts +10 -0
  142. package/src/nodes/general/general-emit-custom-event.ts +3 -1
  143. package/src/page-arrival.ts +115 -0
  144. package/src/payments/drivers/stripe-driver-code.ts +11 -0
  145. package/src/payments/index.ts +18 -5
  146. package/src/workflow-component-plugin.ts +43 -188
  147. package/src/workflow-project-plugin.ts +15 -4
  148. package/src/workflow-scope.ts +184 -0
@@ -0,0 +1,278 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateInvoiceGenerateRouteCode } from '../src/invoice/api-routes-code'
3
+ import { generatePdfGeneratorCode } from '../src/invoice/pdf-generator-code'
4
+ import type { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
5
+ import {
6
+ INVOICE_TEST_APP_SECRET,
7
+ createInvoiceRouteRequire,
8
+ evaluateEmittedModule,
9
+ serverCallHeaders,
10
+ } from './_helpers/load-invoice-route'
11
+
12
+ /**
13
+ * The buyer's own invoice details on the invoice. An order whose buyer gave
14
+ * them (a booking's "Add invoice details") carries the address in its parts
15
+ * and the tax or VAT number in its metadata: the invoice is made out to them
16
+ * — never mixed with a shipping address — and names the VAT number. A booking
17
+ * order also records the tax its final price includes, so its invoice extracts
18
+ * it instead of adding the store's rate on top. And the template's sample
19
+ * customer never fills a part the buyer left out. Every piece below is the
20
+ * EMITTED code, run.
21
+ */
22
+
23
+ const settingsWith = (overrides: Record<string, unknown>) =>
24
+ ({
25
+ invoicePrefix: 'INV-',
26
+ defaultTaxRate: 19,
27
+ showDiscount: false,
28
+ taxIncludedInPrice: false,
29
+ companyDetails: {},
30
+ template: { document: null as unknown },
31
+ tables: { invoicesTable: 'teleport_invoices', invoiceItemsTable: 'teleport_invoice_items' },
32
+ emailDelivery: { enabled: false },
33
+ ...overrides,
34
+ } as unknown as UIDLInvoiceSettings)
35
+
36
+ type Row = Record<string, unknown>
37
+
38
+ const BOOKING_ORDER: Row = {
39
+ id: 'order-bk',
40
+ order_number: 'ORD-1',
41
+ currency: 'EUR',
42
+ total_amount: 238,
43
+ shipping_amount: 0,
44
+ billing_name: 'Acme SRL',
45
+ billing_email: 'ana@example.com',
46
+ billing_address: 'Str. Lunga 1, 400000 Cluj, Cluj, Romania',
47
+ shipping_city: 'Shipped-to city',
48
+ tax_breakdown: JSON.stringify({
49
+ taxRate: 19,
50
+ taxIncluded: true,
51
+ taxDecimals: 2,
52
+ shippingTax: 0,
53
+ lines: [{ productId: 'room', variantId: '', taxRate: 19, taxIncluded: true }],
54
+ }),
55
+ metadata: JSON.stringify({
56
+ bookingId: 'b1',
57
+ bookingReference: 'BK-1',
58
+ billingTaxId: 'RO12345678',
59
+ billingStreet: 'Str. Lunga 1',
60
+ billingCity: 'Cluj',
61
+ billingPostalCode: '400000',
62
+ billingRegion: 'Cluj',
63
+ billingCountry: 'Romania',
64
+ }),
65
+ }
66
+ const BOOKING_ITEMS: Row[] = [
67
+ {
68
+ product_id: 'room',
69
+ product_name: 'Double room',
70
+ quantity: 1,
71
+ unit_price: 238,
72
+ total_price: 238,
73
+ },
74
+ ]
75
+
76
+ async function runHandler(
77
+ settings: UIDLInvoiceSettings,
78
+ order: Row,
79
+ items: Row[],
80
+ body: Row
81
+ ): Promise<{ status: number; invoice: Row }> {
82
+ const code = generateInvoiceGenerateRouteCode(settings)
83
+ let invoice: Row = {}
84
+ const handler = evaluateEmittedModule<(req: unknown, res: unknown) => Promise<void>>(
85
+ code,
86
+ createInvoiceRouteRequire(settings, {
87
+ dataAccess: {
88
+ getOrderWithItems: async (orderId: string) =>
89
+ orderId === order.id ? { order, items } : null,
90
+ reserveInvoice: async (invoiceData: Row) => {
91
+ invoice = invoiceData
92
+ return { invoice: invoiceData, created: true }
93
+ },
94
+ storeInvoicePdf: async () => ({}),
95
+ updateInvoice: async () => ({}),
96
+ },
97
+ pdfGenerator: {
98
+ generateInvoicePdf: async () => Buffer.from('%PDF-1.4 fake'),
99
+ COMPANY_DETAILS: {},
100
+ },
101
+ })
102
+ )
103
+ let status = 0
104
+ const res = {
105
+ status(statusCode: number) {
106
+ status = statusCode
107
+ return this
108
+ },
109
+ json() {
110
+ return this
111
+ },
112
+ setHeader() {
113
+ /* unused */
114
+ },
115
+ end() {
116
+ /* unused */
117
+ },
118
+ }
119
+ await handler({ method: 'POST', headers: serverCallHeaders(), body }, res)
120
+ return { status, invoice }
121
+ }
122
+
123
+ interface PdfGeneratorModule {
124
+ buildInvoiceDataScope: (invoiceData: Row) => { invoiceData: Record<string, Row> }
125
+ }
126
+
127
+ function loadPdfGenerator(
128
+ settings: UIDLInvoiceSettings,
129
+ templateUidl: unknown
130
+ ): PdfGeneratorModule {
131
+ const code = generatePdfGeneratorCode(settings, templateUidl, {})
132
+ const moduleObject = { exports: {} as Record<string, unknown> }
133
+ new Function('require', 'module', 'exports', `${code}; return module.exports;`)(
134
+ require,
135
+ moduleObject,
136
+ moduleObject.exports
137
+ )
138
+ return moduleObject.exports as unknown as PdfGeneratorModule
139
+ }
140
+
141
+ describe('/api/invoices/generate — the buyer’s own invoice details', () => {
142
+ const originalFetch = globalThis.fetch
143
+ const originalConnString = process.env.TELEPORT_DB_CONNECTION_STRING
144
+ const originalDatabaseUrl = process.env.DATABASE_URL
145
+ const originalAppSecret = process.env.NEXTAUTH_SECRET
146
+
147
+ beforeAll(() => {
148
+ globalThis.fetch = (async () => {
149
+ throw new Error('runtime storage not configured in tests')
150
+ }) as unknown as typeof fetch
151
+ delete process.env.TELEPORT_DB_CONNECTION_STRING
152
+ delete process.env.DATABASE_URL
153
+ process.env.NEXTAUTH_SECRET = INVOICE_TEST_APP_SECRET
154
+ })
155
+
156
+ afterAll(() => {
157
+ globalThis.fetch = originalFetch
158
+ if (originalAppSecret === undefined) {
159
+ delete process.env.NEXTAUTH_SECRET
160
+ } else {
161
+ process.env.NEXTAUTH_SECRET = originalAppSecret
162
+ }
163
+ if (originalConnString !== undefined) {
164
+ process.env.TELEPORT_DB_CONNECTION_STRING = originalConnString
165
+ }
166
+ if (originalDatabaseUrl !== undefined) {
167
+ process.env.DATABASE_URL = originalDatabaseUrl
168
+ }
169
+ })
170
+
171
+ it('makes the invoice out to the address parts and VAT number the order carries', async () => {
172
+ const run = await runHandler(settingsWith({}), BOOKING_ORDER, BOOKING_ITEMS, {
173
+ orderId: 'order-bk',
174
+ })
175
+ expect(run.status).toBe(200)
176
+ expect(run.invoice).toMatchObject({
177
+ customerName: 'Acme SRL',
178
+ customerAddress: 'Str. Lunga 1',
179
+ customerCity: 'Cluj',
180
+ customerZip: '400000',
181
+ customerState: 'Cluj',
182
+ customerCountry: 'Romania',
183
+ customerVat: 'RO12345678',
184
+ })
185
+ })
186
+
187
+ it('extracts the tax a booking price includes, so the invoice totals what was charged', async () => {
188
+ // The store adds 19% on top of its products; the booking's price already holds it.
189
+ const run = await runHandler(settingsWith({}), BOOKING_ORDER, BOOKING_ITEMS, {
190
+ orderId: 'order-bk',
191
+ })
192
+ expect(run.invoice.total).toBe(238)
193
+ expect(run.invoice.taxAmount).toBeCloseTo(38, 2)
194
+ expect(run.invoice.subtotal).toBeCloseTo(200, 2)
195
+ })
196
+
197
+ it('keeps the order’s own billing and shipping address when it carries no parts', async () => {
198
+ const order = {
199
+ ...BOOKING_ORDER,
200
+ tax_breakdown: null,
201
+ billing_address: 'Main St 5',
202
+ shipping_city: 'Springfield',
203
+ shipping_country: 'United States',
204
+ metadata: JSON.stringify({ billingTaxId: 'US-99' }),
205
+ }
206
+ const run = await runHandler(settingsWith({ defaultTaxRate: 0 }), order, BOOKING_ITEMS, {
207
+ orderId: 'order-bk',
208
+ })
209
+ expect(run.invoice).toMatchObject({
210
+ customerAddress: 'Main St 5',
211
+ customerCity: 'Springfield',
212
+ customerCountry: 'United States',
213
+ customerVat: 'US-99',
214
+ })
215
+ })
216
+
217
+ it('reads no metadata that is not a JSON object, and lets the caller’s VAT win', async () => {
218
+ for (const metadata of ['not json', '[1,2]', null]) {
219
+ const run = await runHandler(
220
+ settingsWith({ defaultTaxRate: 0 }),
221
+ { ...BOOKING_ORDER, metadata, tax_breakdown: null },
222
+ BOOKING_ITEMS,
223
+ { orderId: 'order-bk' }
224
+ )
225
+ expect([metadata, run.status]).toEqual([metadata, 200])
226
+ expect([metadata, run.invoice.customerVat, run.invoice.customerCity]).toEqual([
227
+ metadata,
228
+ '',
229
+ 'Shipped-to city',
230
+ ])
231
+ }
232
+ const stated = await runHandler(settingsWith({}), BOOKING_ORDER, BOOKING_ITEMS, {
233
+ orderId: 'order-bk',
234
+ customerVat: 'EDITED-1',
235
+ })
236
+ expect(stated.invoice.customerVat).toBe('EDITED-1')
237
+ })
238
+ })
239
+
240
+ describe('invoice PDF renderer — the buyer, never the template’s sample', () => {
241
+ const settings = settingsWith({})
242
+ const templated = loadPdfGenerator(settings, {
243
+ name: 'invoice-template',
244
+ node: {
245
+ type: 'element',
246
+ content: {
247
+ elementType: 'context',
248
+ attrs: {
249
+ Customer: {
250
+ type: 'static',
251
+ content: { name: 'Jane Smith', city: 'San Francisco', state: 'CA', vat: 'US1' },
252
+ },
253
+ Payment: { type: 'static', content: { method: 'Card', intentId: 'pi_sample' } },
254
+ },
255
+ children: [],
256
+ },
257
+ },
258
+ })
259
+
260
+ it('leaves out what the buyer did not give, and flags the lines that show', () => {
261
+ const { invoiceData } = templated.buildInvoiceDataScope({
262
+ customerName: 'Acme SRL',
263
+ customerVat: 'RO12345678',
264
+ currency: 'EUR',
265
+ items: [],
266
+ })
267
+ expect(invoiceData.Customer).toMatchObject({
268
+ name: 'Acme SRL',
269
+ city: '',
270
+ state: '',
271
+ vat: 'RO12345678',
272
+ hasVat: 'true',
273
+ hasZip: 'false',
274
+ hasCountry: 'false',
275
+ })
276
+ expect(invoiceData.Payment.intentId).toBe('')
277
+ })
278
+ })
@@ -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
+ })
@@ -0,0 +1,247 @@
1
+ // A page keeps its instance when a navigation lands on the page already on
2
+ // screen (a link to the current URL, another record of the same dynamic route,
3
+ // another query string), so a trigger that fires when the page appears —
4
+ // page-loaded, element-visible, component-loaded — ran only on the first
5
+ // arrival. Such pages are wrapped in `remountOnPageArrival`, which starts them
6
+ // fresh on every arrival; pages whose workflows only answer interactions keep
7
+ // their plain export.
8
+
9
+ import generator from '@babel/generator'
10
+ import * as types from '@babel/types'
11
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
12
+
13
+ const PAGE_ID = 'page-dashboard'
14
+ const ROOT_ID = 'thq_container_root'
15
+
16
+ const workflow = (type: string, scope: string, config: Record<string, unknown>) => ({
17
+ id: `wf-${type}`,
18
+ name: type,
19
+ trigger: { type, nodeId: 'trigger-1', scope, config },
20
+ nodes: [
21
+ {
22
+ id: 'update-1',
23
+ type: 'state-update-local-state',
24
+ config: { property: 'isLoading', value: false },
25
+ stepNumber: 1,
26
+ label: 'Update',
27
+ },
28
+ ],
29
+ edges: [{ id: 'e1', source: 'trigger-1', target: 'update-1' }],
30
+ })
31
+
32
+ const jsxComponentChunk = (rootId: string) => ({
33
+ type: 'AST',
34
+ name: 'jsx-component',
35
+ content: types.variableDeclaration('const', [
36
+ types.variableDeclarator(
37
+ types.identifier('Dashboard'),
38
+ types.arrowFunctionExpression(
39
+ [types.identifier('props')],
40
+ types.blockStatement([
41
+ types.returnStatement(
42
+ types.jsxElement(
43
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [
44
+ types.jsxAttribute(types.jsxIdentifier('id'), types.stringLiteral(rootId)),
45
+ ]),
46
+ types.jsxClosingElement(types.jsxIdentifier('div')),
47
+ []
48
+ )
49
+ ),
50
+ ])
51
+ )
52
+ ),
53
+ ]),
54
+ })
55
+
56
+ interface RunOptions {
57
+ isPage?: boolean
58
+ rootId?: string
59
+ pageNode?: unknown
60
+ projectComponents?: Record<string, unknown>
61
+ }
62
+
63
+ const run = async (
64
+ workflows: Array<ReturnType<typeof workflow>>,
65
+ {
66
+ isPage = true,
67
+ rootId = ROOT_ID,
68
+ pageNode = { type: 'element', content: { elementType: 'container' } },
69
+ projectComponents,
70
+ }: RunOptions = {}
71
+ ) => {
72
+ const structure: any = {
73
+ uidl: {
74
+ name: 'Dashboard',
75
+ node: pageNode,
76
+ stateDefinitions: { isLoading: { type: 'boolean', defaultValue: true } },
77
+ outputOptions: { pageId: PAGE_ID, folderPath: [] },
78
+ },
79
+ chunks: [
80
+ jsxComponentChunk(rootId),
81
+ {
82
+ type: 'AST',
83
+ name: 'export',
84
+ content: types.exportDefaultDeclaration(types.identifier('Dashboard')),
85
+ },
86
+ ],
87
+ options: {
88
+ workflows: {
89
+ workflows: workflows.reduce(
90
+ (byId: Record<string, unknown>, wf) => ({ ...byId, [wf.id]: wf }),
91
+ {}
92
+ ),
93
+ customNodes: {},
94
+ },
95
+ pagesPath: ['pages'],
96
+ extractedResources: {},
97
+ projectComponents,
98
+ },
99
+ dependencies: {},
100
+ }
101
+ await createNextWorkflowPlugin({ isPage })(structure)
102
+ const exportChunk = structure.chunks.find((chunk: any) => chunk.name === 'export')
103
+ const moduleChunk = structure.chunks.find((chunk: any) => chunk.name === 'workflow-module')
104
+ return {
105
+ exportCode: generator(exportChunk.content).code,
106
+ moduleCode: moduleChunk ? String(moduleChunk.content) : '',
107
+ dependencies: structure.dependencies,
108
+ extractedResources: structure.options.extractedResources,
109
+ }
110
+ }
111
+
112
+ const BANNER_ID = 'thq_container_banner'
113
+
114
+ /** An instance of a project component, as local dependencies are resolved for pages. */
115
+ const componentInstance = (name: string) => ({
116
+ type: 'element',
117
+ content: { elementType: 'component', semanticType: name, dependency: { type: 'local' } },
118
+ })
119
+
120
+ const pageRendering = (componentName: string) => ({
121
+ type: 'element',
122
+ content: { elementType: 'container', children: [componentInstance(componentName)] },
123
+ })
124
+
125
+ const bannerComponent = () => ({
126
+ name: 'Banner',
127
+ node: {
128
+ type: 'element',
129
+ content: { elementType: 'container', attrs: { id: { type: 'static', content: BANNER_ID } } },
130
+ },
131
+ })
132
+
133
+ const WRAPPED_EXPORT = 'export default remountOnPageArrival(Dashboard);'
134
+ const PLAIN_EXPORT = 'export default Dashboard;'
135
+
136
+ describe('workflow plugin — remount on page arrival', () => {
137
+ it('wraps a page whose workflow runs when the page loads', async () => {
138
+ const result = await run([workflow('event-page-loaded', 'page', { pageId: PAGE_ID })])
139
+
140
+ expect(result.exportCode).toBe(WRAPPED_EXPORT)
141
+ expect(result.dependencies.remountOnPageArrival).toEqual({
142
+ type: 'local',
143
+ path: '../utils/page-arrival',
144
+ })
145
+ expect(result.extractedResources['utils/page-arrival']).toBeDefined()
146
+ })
147
+
148
+ it('wraps a page whose workflow runs when its root element becomes visible', async () => {
149
+ const result = await run([
150
+ workflow('event-element-visible', 'element', { elementHtmlId: ROOT_ID, once: true }),
151
+ ])
152
+
153
+ expect(result.exportCode).toBe(WRAPPED_EXPORT)
154
+ })
155
+
156
+ it('leaves the page alone when the visible element is rendered elsewhere', async () => {
157
+ const result = await run(
158
+ [workflow('event-element-visible', 'element', { elementHtmlId: ROOT_ID, once: true })],
159
+ { rootId: 'thq_container_other' }
160
+ )
161
+
162
+ expect(result.exportCode).toBe(PLAIN_EXPORT)
163
+ expect(result.dependencies.remountOnPageArrival).toBeUndefined()
164
+ expect(result.extractedResources).toEqual({})
165
+ })
166
+
167
+ it('leaves the page alone when its workflows only answer interactions', async () => {
168
+ const result = await run([
169
+ workflow('event-element-clicked', 'element', { elementHtmlId: ROOT_ID }),
170
+ workflow('event-interval', 'page', { pageId: PAGE_ID, intervalMs: 1000 }),
171
+ ])
172
+
173
+ expect(result.moduleCode).not.toBe('')
174
+ expect(result.exportCode).toBe(PLAIN_EXPORT)
175
+ })
176
+
177
+ it('never wraps a component', async () => {
178
+ const result = await run(
179
+ [workflow('event-component-loaded', 'component', { componentName: 'Dashboard' })],
180
+ { isPage: false }
181
+ )
182
+
183
+ expect(result.moduleCode).toContain('Component loaded')
184
+ expect(result.exportCode).toBe(PLAIN_EXPORT)
185
+ })
186
+
187
+ it('wraps a page that renders a component running a workflow when its element is visible', async () => {
188
+ const result = await run(
189
+ [workflow('event-element-visible', 'element', { elementHtmlId: BANNER_ID, once: true })],
190
+ { pageNode: pageRendering('Banner'), projectComponents: { Banner: bannerComponent() } }
191
+ )
192
+
193
+ expect(result.moduleCode).toBe('')
194
+ expect(result.exportCode).toBe(WRAPPED_EXPORT)
195
+ })
196
+
197
+ it('finds the component through the components it is nested in', async () => {
198
+ const result = await run(
199
+ [workflow('event-element-visible', 'element', { elementHtmlId: BANNER_ID, once: true })],
200
+ {
201
+ pageNode: pageRendering('Layout'),
202
+ projectComponents: {
203
+ Layout: { name: 'Layout', node: componentInstance('Banner') },
204
+ Banner: bannerComponent(),
205
+ },
206
+ }
207
+ )
208
+
209
+ expect(result.exportCode).toBe(WRAPPED_EXPORT)
210
+ })
211
+
212
+ it('wraps a page that renders a component running a workflow when it loads', async () => {
213
+ const result = await run(
214
+ [workflow('event-component-loaded', 'component', { componentName: 'Banner' })],
215
+ { pageNode: pageRendering('Banner'), projectComponents: { Banner: bannerComponent() } }
216
+ )
217
+
218
+ expect(result.exportCode).toBe(WRAPPED_EXPORT)
219
+ })
220
+
221
+ it('leaves the page alone when no rendered component observes the element', async () => {
222
+ const result = await run(
223
+ [
224
+ workflow('event-element-visible', 'element', {
225
+ elementHtmlId: 'thq_elsewhere',
226
+ once: true,
227
+ }),
228
+ ],
229
+ { pageNode: pageRendering('Banner'), projectComponents: { Banner: bannerComponent() } }
230
+ )
231
+
232
+ expect(result.exportCode).toBe(PLAIN_EXPORT)
233
+ })
234
+
235
+ it('cancels a delayed page-loaded run together with the other triggers', async () => {
236
+ const result = await run([
237
+ workflow('event-page-loaded', 'page', { pageId: PAGE_ID, delay: 500 }),
238
+ ])
239
+
240
+ expect(result.moduleCode).toContain(
241
+ 'const __delay_wf_event_page_loaded = setTimeout(function() { __execWf(__wfConfig_wf_event_page_loaded'
242
+ )
243
+ expect(result.moduleCode).toContain(
244
+ 'cleanups.push(function() { clearTimeout(__delay_wf_event_page_loaded); });'
245
+ )
246
+ })
247
+ })
@@ -265,3 +265,26 @@ describe('Stripe driver — settling a checkout from Stripe', () => {
265
265
  expect(calls).toEqual([])
266
266
  })
267
267
  })
268
+
269
+ describe('Stripe driver — checkout lifetime', () => {
270
+ it("opens every Checkout page for an hour, not Stripe's default day", async () => {
271
+ const { stripe, calls } = load()
272
+ const before = Math.floor(Date.now() / 1000)
273
+ await withEnv(KEY, async () => {
274
+ await stripe.createCheckout({ amount: 10, currency: 'usd', description: 'Order' })
275
+ await stripe.createSubscriptionCheckout({
276
+ amount: 10,
277
+ currency: 'usd',
278
+ recurringInterval: 'month',
279
+ description: 'Club',
280
+ })
281
+ })
282
+ const after = Math.floor(Date.now() / 1000)
283
+ const sessions = calls.filter((call: any) => call.method === 'checkout.sessions.create')
284
+ expect(sessions).toHaveLength(2)
285
+ for (const session of sessions as any[]) {
286
+ expect(session.params.expires_at).toBeGreaterThanOrEqual(before + 3600)
287
+ expect(session.params.expires_at).toBeLessThanOrEqual(after + 3600)
288
+ }
289
+ })
290
+ })