@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.
- package/__tests__/_helpers/fake-store-database.ts +428 -0
- package/__tests__/_helpers/invoice-store.ts +382 -0
- package/__tests__/booking-calendar-feed-route.test.ts +494 -0
- package/__tests__/custom-node-result-taken-branch.test.ts +107 -0
- package/__tests__/data-api-date-parser.test.ts +45 -0
- package/__tests__/email-locale-senders.test.ts +3 -1
- package/__tests__/email-sender-name.test.ts +513 -0
- package/__tests__/graph-utils-topological-order.test.ts +155 -1
- package/__tests__/invoice-billing-details.test.ts +384 -0
- package/__tests__/invoice-data-access-replace.test.ts +236 -2
- package/__tests__/invoice-order-items-settle.test.ts +1 -1
- package/__tests__/invoice-payment-email.test.ts +191 -0
- package/__tests__/invoice-regenerate-email.test.ts +288 -0
- package/__tests__/invoice-regenerate-route.test.ts +9 -7
- package/__tests__/invoice-reservation.test.ts +1 -1
- package/__tests__/invoice-tender.test.ts +1 -1
- package/__tests__/middleware-admin-panel-roles.test.ts +127 -0
- package/__tests__/page-arrival-remount.test.ts +247 -0
- package/__tests__/payment-drivers-module.test.ts +19 -0
- package/__tests__/raw-query-error-code.test.ts +47 -0
- package/__tests__/runtime-template-token-resolution.test.ts +16 -0
- package/__tests__/segment-reply-changed-keys.test.ts +31 -0
- package/__tests__/segment-route-skipped-branch.test.ts +101 -0
- package/__tests__/segment-splitter-checkout-order.test.ts +306 -0
- package/__tests__/streaming-on-end-env-filter.test.ts +18 -18
- package/__tests__/streaming-on-end-order.test.ts +387 -0
- package/dist/cjs/account-delete-route-generator.d.ts +1 -0
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +2 -1
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +23 -13
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +1 -0
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +77 -28
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/booking-calendar-feed-route-generator.d.ts +28 -0
- package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -0
- package/dist/cjs/booking-calendar-feed-route-generator.js +296 -0
- package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +6 -1
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +39 -28
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/graph-utils.d.ts +31 -1
- package/dist/cjs/graph-utils.d.ts.map +1 -1
- package/dist/cjs/graph-utils.js +112 -10
- package/dist/cjs/graph-utils.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts +12 -3
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +152 -21
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/data-access-code.d.ts.map +1 -1
- package/dist/cjs/invoice/data-access-code.js +133 -21
- package/dist/cjs/invoice/data-access-code.js.map +1 -1
- package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
- package/dist/cjs/invoice/email-sender-code.js +15 -25
- package/dist/cjs/invoice/email-sender-code.js.map +1 -1
- package/dist/cjs/invoice/index.js +1 -1
- package/dist/cjs/invoice/index.js.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.js +50 -6
- package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +13 -2
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.js +11 -1
- package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +4 -2
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +5 -2
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +5 -2
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +8 -2
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-custom-js.js +11 -0
- package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-remove.d.ts.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-remove.js +2 -0
- package/dist/cjs/nodes/storage/storage-local-remove.js.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-set.d.ts.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-set.js +4 -0
- package/dist/cjs/nodes/storage/storage-local-set.js.map +1 -1
- package/dist/cjs/page-arrival.d.ts +10 -0
- package/dist/cjs/page-arrival.d.ts.map +1 -0
- package/dist/cjs/page-arrival.js +86 -0
- package/dist/cjs/page-arrival.js.map +1 -0
- package/dist/cjs/payments/index.d.ts +10 -4
- package/dist/cjs/payments/index.d.ts.map +1 -1
- package/dist/cjs/payments/index.js +18 -7
- package/dist/cjs/payments/index.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +65 -27
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +32 -18
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +35 -149
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +16 -4
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/cjs/workflow-scope.d.ts +19 -0
- package/dist/cjs/workflow-scope.d.ts.map +1 -0
- package/dist/cjs/workflow-scope.js +147 -0
- package/dist/cjs/workflow-scope.js.map +1 -0
- package/dist/esm/account-delete-route-generator.d.ts +1 -0
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +2 -1
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +23 -13
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +1 -0
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +77 -28
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/booking-calendar-feed-route-generator.d.ts +28 -0
- package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -0
- package/dist/esm/booking-calendar-feed-route-generator.js +291 -0
- package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -0
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +6 -1
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +39 -28
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/graph-utils.d.ts +31 -1
- package/dist/esm/graph-utils.d.ts.map +1 -1
- package/dist/esm/graph-utils.js +109 -9
- package/dist/esm/graph-utils.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts +12 -3
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +152 -21
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/data-access-code.d.ts.map +1 -1
- package/dist/esm/invoice/data-access-code.js +133 -21
- package/dist/esm/invoice/data-access-code.js.map +1 -1
- package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
- package/dist/esm/invoice/email-sender-code.js +15 -25
- package/dist/esm/invoice/email-sender-code.js.map +1 -1
- package/dist/esm/invoice/index.js +1 -1
- package/dist/esm/invoice/index.js.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.js +50 -6
- package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +13 -2
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.js +11 -1
- package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +4 -2
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +5 -2
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +5 -2
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +8 -2
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-custom-js.js +11 -0
- package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
- package/dist/esm/nodes/storage/storage-local-remove.d.ts.map +1 -1
- package/dist/esm/nodes/storage/storage-local-remove.js +2 -0
- package/dist/esm/nodes/storage/storage-local-remove.js.map +1 -1
- package/dist/esm/nodes/storage/storage-local-set.d.ts.map +1 -1
- package/dist/esm/nodes/storage/storage-local-set.js +4 -0
- package/dist/esm/nodes/storage/storage-local-set.js.map +1 -1
- package/dist/esm/page-arrival.d.ts +10 -0
- package/dist/esm/page-arrival.d.ts.map +1 -0
- package/dist/esm/page-arrival.js +81 -0
- package/dist/esm/page-arrival.js.map +1 -0
- package/dist/esm/payments/index.d.ts +10 -4
- package/dist/esm/payments/index.d.ts.map +1 -1
- package/dist/esm/payments/index.js +16 -6
- package/dist/esm/payments/index.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +66 -28
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +32 -18
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +32 -146
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +16 -4
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/dist/esm/workflow-scope.d.ts +19 -0
- package/dist/esm/workflow-scope.d.ts.map +1 -0
- package/dist/esm/workflow-scope.js +142 -0
- package/dist/esm/workflow-scope.js.map +1 -0
- package/package.json +4 -4
- package/src/account-delete-route-generator.ts +3 -1
- package/src/api-route-generator.ts +23 -13
- package/src/auth-generator.ts +83 -26
- package/src/booking-calendar-feed-route-generator.ts +293 -0
- package/src/data-api-route-generator.ts +6 -1
- package/src/executor-generator.ts +39 -28
- package/src/graph-utils.ts +143 -11
- package/src/index.ts +1 -0
- package/src/invoice/api-routes-code.ts +166 -21
- package/src/invoice/data-access-code.ts +139 -21
- package/src/invoice/email-sender-code.ts +15 -25
- package/src/invoice/index.ts +1 -1
- package/src/invoice/invoice-assembly-code.ts +50 -6
- package/src/invoice/invoice-html-code.ts +13 -2
- package/src/nodes/data/data-raw-query.ts +11 -1
- package/src/nodes/email/email-mailgun.ts +11 -2
- package/src/nodes/email/email-postmark.ts +12 -2
- package/src/nodes/email/email-resend.ts +12 -2
- package/src/nodes/email/email-sendgrid.ts +15 -2
- package/src/nodes/general/general-custom-js.ts +10 -0
- package/src/nodes/storage/storage-local-remove.ts +4 -0
- package/src/nodes/storage/storage-local-set.ts +6 -0
- package/src/page-arrival.ts +115 -0
- package/src/payments/index.ts +18 -5
- package/src/segment-splitter.ts +90 -36
- package/src/transactional-email-code.ts +32 -21
- package/src/workflow-component-plugin.ts +43 -188
- package/src/workflow-project-plugin.ts +17 -4
- 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
|
|
20
|
-
* the merchant's corrections over the order
|
|
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:
|
|
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
|
|
@@ -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
|
+
})
|