@teleporthq/teleport-plugin-next-workflows 0.45.6 → 0.45.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/analytics-track-outcome.test.ts +56 -0
- package/__tests__/newsletter-plugin.test.ts +165 -0
- package/__tests__/server-job-self-calls-absolute.test.ts +430 -0
- package/dist/cjs/newsletter/helper-code.js +2 -2
- package/dist/cjs/newsletter/routes-code.d.ts.map +1 -1
- package/dist/cjs/newsletter/routes-code.js +64 -31
- package/dist/cjs/newsletter/routes-code.js.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js +12 -0
- package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/newsletter/helper-code.js +2 -2
- package/dist/esm/newsletter/routes-code.d.ts.map +1 -1
- package/dist/esm/newsletter/routes-code.js +64 -31
- package/dist/esm/newsletter/routes-code.js.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -1
- package/dist/esm/nodes/analytics/analytics-track-outcome.js +12 -0
- package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/newsletter/helper-code.ts +2 -2
- package/src/newsletter/routes-code.ts +67 -31
- package/src/nodes/analytics/analytics-track-outcome.ts +12 -0
|
@@ -176,6 +176,62 @@ describe('analytics-track-outcome node', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('server: an outcome the analytics service takes only from a browser', () => {
|
|
180
|
+
// The service's server endpoint counts `purchase` and `booking_made` alone;
|
|
181
|
+
// a lead, sign-up, newsletter, search or custom outcome comes from the page.
|
|
182
|
+
const configured = (calls: Array<{ body: string }>) =>
|
|
183
|
+
evalHandler(generator.generateServerHandler?.() ?? '', {
|
|
184
|
+
globalThis: {
|
|
185
|
+
process: {
|
|
186
|
+
env: {
|
|
187
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
|
|
188
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
|
|
189
|
+
TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
fetch: (_url: string, init: { body: string }) => {
|
|
194
|
+
calls.push(init)
|
|
195
|
+
return Promise.resolve({ status: 202 })
|
|
196
|
+
},
|
|
197
|
+
AbortController: undefined,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
let warn: jest.SpyInstance
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
203
|
+
})
|
|
204
|
+
afterEach(() => warn.mockRestore())
|
|
205
|
+
|
|
206
|
+
it('is refused as browser-only, with the reason in the logs, and never sent', async () => {
|
|
207
|
+
const calls: Array<{ body: string }> = []
|
|
208
|
+
const report = configured(calls)
|
|
209
|
+
for (const name of ['lead', 'signup', 'newsletter', 'search', 'custom:demo_booked']) {
|
|
210
|
+
expect(await report({ name }, {})).toEqual({ sent: false, reason: 'browser-only' })
|
|
211
|
+
expect(await report({ name, outcomeId: 'row-1' }, {})).toEqual({
|
|
212
|
+
sent: false,
|
|
213
|
+
reason: 'browser-only',
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
expect(calls).toEqual([])
|
|
217
|
+
expect(warn).toHaveBeenCalledWith(
|
|
218
|
+
'[analytics] the "lead" outcome was not reported: only a visitor\'s browser can report it, never a server step'
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('says so in the logs when a money outcome has no id to report', async () => {
|
|
223
|
+
const calls: Array<{ body: string }> = []
|
|
224
|
+
expect(await configured(calls)({ name: 'booking_made', value: '40' }, {})).toEqual({
|
|
225
|
+
sent: false,
|
|
226
|
+
reason: 'no-outcome-id',
|
|
227
|
+
})
|
|
228
|
+
expect(calls).toEqual([])
|
|
229
|
+
expect(warn).toHaveBeenCalledWith(
|
|
230
|
+
'[analytics] the "booking_made" outcome was not reported: it has no order or booking id'
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
179
235
|
it('server: answers not-configured without the key and never throws on a network failure', async () => {
|
|
180
236
|
const unconfigured = evalHandler(generator.generateServerHandler?.() ?? '', {
|
|
181
237
|
globalThis: { process: { env: {} } },
|
|
@@ -723,6 +723,171 @@ describe('the newsletter routes', () => {
|
|
|
723
723
|
expect(ok.body).toEqual({ ok: true, email: 'wait@site.test' })
|
|
724
724
|
const bad = await route(app, 'confirm', { method: 'POST', body: { token: 'x.y' } })
|
|
725
725
|
expect(bad.body).toEqual({ ok: false })
|
|
726
|
+
const queriesBefore = app.db.queries.length
|
|
727
|
+
const none = await route(app, 'confirm', { method: 'POST', body: {} })
|
|
728
|
+
expect(none.statusCode).toBe(200)
|
|
729
|
+
expect(none.body).toEqual({ ok: false })
|
|
730
|
+
expect(app.db.queries).toHaveLength(queriesBefore)
|
|
731
|
+
app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
|
|
732
|
+
const down = await route(app, 'confirm', { method: 'POST', body: { token } })
|
|
733
|
+
expect(down.statusCode).toBe(503)
|
|
734
|
+
expect(down.body).toEqual({
|
|
735
|
+
error: 'We could not confirm the subscription. Please try again in a moment.',
|
|
736
|
+
})
|
|
737
|
+
const put = await route(app, 'confirm', { method: 'PUT', body: { token } })
|
|
738
|
+
expect(put.statusCode).toBe(405)
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it("confirm: the emailed link to the site's confirm route shows a button; only its POST confirms", async () => {
|
|
742
|
+
const app = loadApp({
|
|
743
|
+
settings: {
|
|
744
|
+
pages: { confirm: '/api/newsletter/confirm', unsubscribe: '/api/newsletter/unsubscribe' },
|
|
745
|
+
},
|
|
746
|
+
})
|
|
747
|
+
await app.helper.subscribeNewsletterPerson({
|
|
748
|
+
db: app.db,
|
|
749
|
+
email: 'click@reader.test',
|
|
750
|
+
signUpPoint: 'block',
|
|
751
|
+
req: REQ,
|
|
752
|
+
})
|
|
753
|
+
expect(app.sent[0].html).toContain('https://studio.example/api/newsletter/confirm?t=')
|
|
754
|
+
const token = linkToken(app.sent[0].html)
|
|
755
|
+
// The person's click, and a mail client's link scanner opening the same
|
|
756
|
+
// link first, subscribe nobody.
|
|
757
|
+
for (let opened = 0; opened < 2; opened++) {
|
|
758
|
+
const page = await route(app, 'confirm', { method: 'GET', query: { t: token } })
|
|
759
|
+
expect(page.statusCode).toBe(200)
|
|
760
|
+
expect(page.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
761
|
+
expect(page.headers['Cache-Control']).toBe('no-store')
|
|
762
|
+
expect(page.body).toContain('<h1>Confirm your subscription</h1>')
|
|
763
|
+
expect(page.body).toContain(
|
|
764
|
+
'<form method="post" action="/api/newsletter/confirm"><input type="hidden" name="t" value="' +
|
|
765
|
+
token +
|
|
766
|
+
'"><button type="submit">Confirm subscription</button></form>'
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
expect(app.db.rows[0].newsletter_status).toBe('pending')
|
|
770
|
+
expect(app.db.writes().map((query) => query.sql)).toEqual([NEWSLETTER_SQL.insertPending])
|
|
771
|
+
expect(app.sent).toHaveLength(1)
|
|
772
|
+
|
|
773
|
+
const pressButton = () =>
|
|
774
|
+
route(app, 'confirm', {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
777
|
+
body: { t: token },
|
|
778
|
+
})
|
|
779
|
+
const done = await pressButton()
|
|
780
|
+
expect(done.statusCode).toBe(200)
|
|
781
|
+
expect(done.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
782
|
+
expect(done.body).toContain('<h1>You're subscribed</h1>')
|
|
783
|
+
expect(app.db.rows[0]).toMatchObject({
|
|
784
|
+
newsletter_status: 'subscribed',
|
|
785
|
+
marketing_consent: true,
|
|
786
|
+
})
|
|
787
|
+
expect(app.events.map((event) => event.type)).toEqual(['newsletter-subscribed'])
|
|
788
|
+
expect(app.sent).toHaveLength(2)
|
|
789
|
+
expect(app.sent[1].html).toContain('https://studio.example/api/newsletter/unsubscribe?t=')
|
|
790
|
+
// The same link used again: the same answer, nothing happens twice.
|
|
791
|
+
const again = await pressButton()
|
|
792
|
+
expect(again.body).toContain('<h1>You're subscribed</h1>')
|
|
793
|
+
expect(app.events).toHaveLength(1)
|
|
794
|
+
expect(app.sent).toHaveLength(2)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('confirm: a bad, expired or wrong link answers a plain page and confirms nothing', async () => {
|
|
798
|
+
const app = loadApp()
|
|
799
|
+
app.db.rows.push({ id: 'u-p', email: 'wait@site.test', newsletter_status: 'pending' })
|
|
800
|
+
const eightDaysAgo = Math.floor(Date.now() / 1000) - 8 * 24 * 3600
|
|
801
|
+
const notALink =
|
|
802
|
+
'This confirmation link does not work. A link works for 7 days: sign up again to get a new one.'
|
|
803
|
+
const badLinks = [
|
|
804
|
+
'',
|
|
805
|
+
'x.y',
|
|
806
|
+
'"><script>alert(1)</script>',
|
|
807
|
+
app.helper.signNewsletterToken('wait@site.test', 'confirm', eightDaysAgo),
|
|
808
|
+
app.helper.signNewsletterToken('wait@site.test', 'unsubscribe'),
|
|
809
|
+
loadApp({ env: { NEXTAUTH_SECRET: 'another-site' } }).helper.signNewsletterToken(
|
|
810
|
+
'wait@site.test',
|
|
811
|
+
'confirm'
|
|
812
|
+
),
|
|
813
|
+
]
|
|
814
|
+
const expectNotALinkPage = (res: Row) => {
|
|
815
|
+
expect(res.statusCode).toBe(200)
|
|
816
|
+
expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8')
|
|
817
|
+
expect(res.body).toContain('<h1>Link not valid</h1>')
|
|
818
|
+
expect(res.body).toContain(notALink)
|
|
819
|
+
expect(res.body).not.toContain('<form')
|
|
820
|
+
expect(res.body).not.toContain('<script>')
|
|
821
|
+
}
|
|
822
|
+
expectNotALinkPage(await route(app, 'confirm', { method: 'GET' }))
|
|
823
|
+
for (const t of badLinks) {
|
|
824
|
+
expectNotALinkPage(await route(app, 'confirm', { method: 'GET', query: { t } }))
|
|
825
|
+
expectNotALinkPage(
|
|
826
|
+
await route(app, 'confirm', {
|
|
827
|
+
method: 'POST',
|
|
828
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
829
|
+
body: { t },
|
|
830
|
+
})
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
expect(app.db.writes()).toEqual([])
|
|
834
|
+
expect(app.db.rows[0].newsletter_status).toBe('pending')
|
|
835
|
+
expect(app.sent).toEqual([])
|
|
836
|
+
|
|
837
|
+
// The database down while the button is pressed: a plain page, not the error.
|
|
838
|
+
app.db.failWith = { message: 'connection refused', code: 'ECONNREFUSED' }
|
|
839
|
+
const down = await route(app, 'confirm', {
|
|
840
|
+
method: 'POST',
|
|
841
|
+
headers: { host: 'studio.example', 'content-type': 'application/x-www-form-urlencoded' },
|
|
842
|
+
body: { t: app.helper.signNewsletterToken('wait@site.test', 'confirm') },
|
|
843
|
+
})
|
|
844
|
+
expect(down.statusCode).toBe(503)
|
|
845
|
+
expect(down.body).toContain('<h1>Please try again</h1>')
|
|
846
|
+
expect(down.body).not.toContain('connection refused')
|
|
847
|
+
expect(down.body).not.toContain('ECONNREFUSED')
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('confirm: a project that still has its own confirm page keeps linking to it, and its step confirms', async () => {
|
|
851
|
+
const app = loadApp()
|
|
852
|
+
await app.helper.subscribeNewsletterPerson({
|
|
853
|
+
db: app.db,
|
|
854
|
+
email: 'page@reader.test',
|
|
855
|
+
signUpPoint: 'footer',
|
|
856
|
+
req: REQ,
|
|
857
|
+
})
|
|
858
|
+
expect(app.sent[0].html).toContain('https://studio.example/newsletter/confirm#t=')
|
|
859
|
+
expect(app.sent[0].html).not.toContain('/api/newsletter/confirm')
|
|
860
|
+
const realFetch = (global as any).fetch
|
|
861
|
+
;(global as any).fetch = async (
|
|
862
|
+
url: string,
|
|
863
|
+
init: { method: string; headers: Row; body: string }
|
|
864
|
+
) => {
|
|
865
|
+
expect(url).toBe('https://studio.example/api/newsletter/confirm')
|
|
866
|
+
const res = await route(app, 'confirm', {
|
|
867
|
+
method: init.method,
|
|
868
|
+
headers: { host: 'studio.example', 'content-type': init.headers['Content-Type'] },
|
|
869
|
+
body: JSON.parse(init.body),
|
|
870
|
+
})
|
|
871
|
+
return {
|
|
872
|
+
ok: res.statusCode < 400,
|
|
873
|
+
json: async () => {
|
|
874
|
+
expect(typeof res.body).toBe('object')
|
|
875
|
+
return res.body
|
|
876
|
+
},
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const confirmStep = loadHandler('newsletter-confirm')
|
|
881
|
+
expect(
|
|
882
|
+
await confirmStep(
|
|
883
|
+
{ token: linkToken(app.sent[0].html) },
|
|
884
|
+
{ __baseUrl: 'https://studio.example' }
|
|
885
|
+
)
|
|
886
|
+
).toEqual({ ok: true, email: 'page@reader.test' })
|
|
887
|
+
} finally {
|
|
888
|
+
;(global as any).fetch = realFetch
|
|
889
|
+
}
|
|
890
|
+
expect(app.db.rows[0].newsletter_status).toBe('subscribed')
|
|
726
891
|
})
|
|
727
892
|
})
|
|
728
893
|
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/* tslint:disable:function-constructor no-any */
|
|
2
|
+
import * as http from 'http'
|
|
3
|
+
import { AddressInfo } from 'net'
|
|
4
|
+
import * as nodePath from 'path'
|
|
5
|
+
import * as vm from 'vm'
|
|
6
|
+
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
|
|
7
|
+
import { generateHandlerFunction } from '../src/api-route-generator'
|
|
8
|
+
import { withEnv } from './_helpers/fake-fetch'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A scheduled workflow on a published site failed every run with HTTP 500
|
|
12
|
+
* "Failed to parse URL from /api/data/<id>/raw-query": its data step called the
|
|
13
|
+
* site's own data API by a relative address, which a browser resolves against
|
|
14
|
+
* the page and Node's fetch refuses. A server job has no page, so each route
|
|
15
|
+
* that runs one hands its steps the absolute origin (`context.__baseUrl`).
|
|
16
|
+
*
|
|
17
|
+
* Every kind of server job is run here as the deployment runs it — the
|
|
18
|
+
* generated files, Node's own fetch, and a real HTTP server standing in for the
|
|
19
|
+
* site — so a relative self-call fails exactly as it did in production.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DS = 'ds-store'
|
|
23
|
+
const ref = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
|
|
24
|
+
|
|
25
|
+
const CART_ROWS = [{ cart_id: 'cart-1', email: 'a@shop.test' }]
|
|
26
|
+
|
|
27
|
+
/** The predefined "Abandoned Cart Reminder", reduced to its database steps. */
|
|
28
|
+
const CART_REMINDER = {
|
|
29
|
+
id: 'wf-cart-reminder',
|
|
30
|
+
name: 'Abandoned Cart Reminder',
|
|
31
|
+
trigger: {
|
|
32
|
+
type: 'event-cron-triggered',
|
|
33
|
+
nodeId: 't',
|
|
34
|
+
scope: 'global',
|
|
35
|
+
config: { schedule: '0 * * * *', urlPath: 'cron_cartreminder' },
|
|
36
|
+
},
|
|
37
|
+
nodes: [
|
|
38
|
+
{
|
|
39
|
+
id: 'scan',
|
|
40
|
+
type: 'data-raw-query',
|
|
41
|
+
label: 'Scan Abandoned Carts',
|
|
42
|
+
config: {
|
|
43
|
+
dataSourceId: DS,
|
|
44
|
+
query:
|
|
45
|
+
'SELECT c.id AS cart_id FROM teleport_cart c WHERE c.updated_at < NOW() - make_interval(hours => $1::int)',
|
|
46
|
+
params: ['2'],
|
|
47
|
+
},
|
|
48
|
+
stepNumber: 1,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'loop',
|
|
52
|
+
type: 'general-loop',
|
|
53
|
+
label: 'Loop Over Each Abandoned Cart',
|
|
54
|
+
config: { loopType: 'forEach', collection: ref('scan', ['result']), itemVariable: 'cart' },
|
|
55
|
+
stepNumber: 2,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'record',
|
|
59
|
+
type: 'data-create-item',
|
|
60
|
+
label: 'Record Abandoned Cart',
|
|
61
|
+
config: {
|
|
62
|
+
dataSourceId: DS,
|
|
63
|
+
tableName: 'teleport_abandoned_carts',
|
|
64
|
+
columnMappings: [{ column: 'cart_id', value: ref('loop', ['currentItem', 'cart_id']) }],
|
|
65
|
+
},
|
|
66
|
+
stepNumber: 3,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
edges: [
|
|
70
|
+
{ id: 'e1', source: 't', target: 'scan' },
|
|
71
|
+
{ id: 'e2', source: 'scan', target: 'loop' },
|
|
72
|
+
{
|
|
73
|
+
id: 'e3',
|
|
74
|
+
source: 'loop',
|
|
75
|
+
target: 'record',
|
|
76
|
+
sourceHandle: 'loop',
|
|
77
|
+
targetHandle: 'loop-body-in',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'e4',
|
|
81
|
+
source: 'record',
|
|
82
|
+
target: 'loop',
|
|
83
|
+
sourceHandle: 'loop-body-out',
|
|
84
|
+
targetHandle: 'loop-back',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A shared step the store's jobs call: its database read runs in its own server segment route. */
|
|
90
|
+
const RELEASE_NODE = {
|
|
91
|
+
id: 'cn-release',
|
|
92
|
+
name: 'Release Expired Holds',
|
|
93
|
+
parameters: [] as unknown[],
|
|
94
|
+
nodes: [
|
|
95
|
+
{
|
|
96
|
+
id: 'release-query',
|
|
97
|
+
type: 'data-raw-query',
|
|
98
|
+
label: 'Release Expired Holds',
|
|
99
|
+
config: { dataSourceId: DS, query: "UPDATE teleport_orders SET status = 'expired'" },
|
|
100
|
+
stepNumber: 0,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
edges: [] as unknown[],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const callRelease = (id: string, stepNumber: number) => ({
|
|
107
|
+
id,
|
|
108
|
+
type: 'general-custom-node',
|
|
109
|
+
label: 'Release Expired Holds',
|
|
110
|
+
config: { customNodeId: 'cn-release', parameters: [] as unknown[] },
|
|
111
|
+
stepNumber,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const RELEASE_CRON = {
|
|
115
|
+
id: 'wf-release',
|
|
116
|
+
name: 'Release Abandoned Checkouts',
|
|
117
|
+
trigger: {
|
|
118
|
+
type: 'event-cron-triggered',
|
|
119
|
+
nodeId: 't',
|
|
120
|
+
scope: 'global',
|
|
121
|
+
config: { schedule: '*/15 * * * *', urlPath: 'cron_release' },
|
|
122
|
+
},
|
|
123
|
+
nodes: [callRelease('release-call', 1)],
|
|
124
|
+
edges: [{ id: 'r1', source: 't', target: 'release-call' }],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SYNC_WEBHOOK = {
|
|
128
|
+
id: 'wf-sync',
|
|
129
|
+
name: 'Inventory Sync',
|
|
130
|
+
trigger: { type: 'event-webhook-received', nodeId: 't', scope: 'global', config: {} },
|
|
131
|
+
webhookConfig: { urlPath: 'webhooks/inventory-sync', httpMethod: 'POST' },
|
|
132
|
+
nodes: [
|
|
133
|
+
{
|
|
134
|
+
id: 'sync-query',
|
|
135
|
+
type: 'data-raw-query',
|
|
136
|
+
label: 'Read Stock',
|
|
137
|
+
config: { dataSourceId: DS, query: 'SELECT id, stock FROM teleport_products' },
|
|
138
|
+
stepNumber: 1,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
edges: [{ id: 's1', source: 't', target: 'sync-query' }],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ORDER_AUTOMATION = {
|
|
145
|
+
id: 'wf-order-placed',
|
|
146
|
+
name: 'When an order is placed',
|
|
147
|
+
trigger: { type: 'event-business-event', nodeId: 't', scope: 'global', config: {} },
|
|
148
|
+
nodes: [
|
|
149
|
+
{
|
|
150
|
+
id: 'auto-query',
|
|
151
|
+
type: 'data-raw-query',
|
|
152
|
+
label: 'Read The Order',
|
|
153
|
+
config: { dataSourceId: DS, query: 'SELECT id FROM teleport_orders LIMIT 1' },
|
|
154
|
+
stepNumber: 1,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
edges: [{ id: 'a1', source: 't', target: 'auto-query' }],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ENV: Record<string, string | undefined> = {
|
|
161
|
+
NEXTAUTH_SECRET: 'app-secret',
|
|
162
|
+
NEXTAUTH_URL: undefined,
|
|
163
|
+
TELEPORT_CRON_ALLOW_UNSIGNED: 'true',
|
|
164
|
+
TELEPORT_CRON_SECRET: undefined,
|
|
165
|
+
VERCEL: undefined,
|
|
166
|
+
VERCEL_AUTOMATION_BYPASS_SECRET: undefined,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const generateFiles = async (): Promise<Map<string, string>> => {
|
|
170
|
+
const structure: any = {
|
|
171
|
+
uidl: {
|
|
172
|
+
name: 'Store',
|
|
173
|
+
globals: { settings: { title: 'Store', language: 'en' }, env: { NEXTAUTH_SECRET: 'k' } },
|
|
174
|
+
root: {
|
|
175
|
+
name: 'Root',
|
|
176
|
+
stateDefinitions: {
|
|
177
|
+
route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
|
|
178
|
+
},
|
|
179
|
+
node: { type: 'element', content: { elementType: 'container', children: [] } },
|
|
180
|
+
},
|
|
181
|
+
components: {},
|
|
182
|
+
dataSources: { [DS]: { id: DS, type: 'teleport', name: 'Store', config: {} } },
|
|
183
|
+
workflows: {
|
|
184
|
+
workflows: JSON.parse(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
[CART_REMINDER.id]: CART_REMINDER,
|
|
187
|
+
[RELEASE_CRON.id]: RELEASE_CRON,
|
|
188
|
+
[SYNC_WEBHOOK.id]: SYNC_WEBHOOK,
|
|
189
|
+
})
|
|
190
|
+
),
|
|
191
|
+
customNodes: JSON.parse(JSON.stringify({ [RELEASE_NODE.id]: RELEASE_NODE })),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
strategy: { pages: { options: {} } },
|
|
195
|
+
files: new Map(),
|
|
196
|
+
dependencies: {},
|
|
197
|
+
devDependencies: {},
|
|
198
|
+
}
|
|
199
|
+
await new NextWorkflowProjectPlugin().runAfter(structure)
|
|
200
|
+
const files = new Map<string, string>()
|
|
201
|
+
for (const entry of structure.files.values()) {
|
|
202
|
+
for (const file of entry.files) {
|
|
203
|
+
files.set(
|
|
204
|
+
[...entry.path, `${file.name}.${file.fileType || 'js'}`].join('/'),
|
|
205
|
+
String(file.content)
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
files.set(
|
|
210
|
+
'lib/automations/handlers/wf-order-placed.js',
|
|
211
|
+
generateHandlerFunction(JSON.parse(JSON.stringify(ORDER_AUTOMATION)), {
|
|
212
|
+
[RELEASE_NODE.id]: RELEASE_NODE,
|
|
213
|
+
} as any)
|
|
214
|
+
)
|
|
215
|
+
return files
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface Reply {
|
|
219
|
+
status: number
|
|
220
|
+
body: any
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface Site {
|
|
224
|
+
origin: string
|
|
225
|
+
/** Every request the site received: its path and the Host it was sent to. */
|
|
226
|
+
received: Array<{ path: string; host: string }>
|
|
227
|
+
call: (routeFile: string, body?: unknown) => Promise<Reply>
|
|
228
|
+
load: (path: string) => any
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The generated files served by a real HTTP server: the data API answers with
|
|
233
|
+
* rows, and a call to one of the site's own workflow routes runs that route.
|
|
234
|
+
*/
|
|
235
|
+
const startSite = async (files: Map<string, string>): Promise<Site & { close: () => void }> => {
|
|
236
|
+
let origin = ''
|
|
237
|
+
const received: Site['received'] = []
|
|
238
|
+
const cache = new Map<string, { exports: any }>()
|
|
239
|
+
const overrides: Record<string, unknown> = {
|
|
240
|
+
'lib/automations/store.js': {
|
|
241
|
+
appBaseUrl: () => origin,
|
|
242
|
+
saveWait: async (): Promise<void> => undefined,
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const load = (path: string): any => {
|
|
247
|
+
if (Object.prototype.hasOwnProperty.call(overrides, path)) {
|
|
248
|
+
return overrides[path]
|
|
249
|
+
}
|
|
250
|
+
const cached = cache.get(path)
|
|
251
|
+
if (cached) {
|
|
252
|
+
return cached.exports
|
|
253
|
+
}
|
|
254
|
+
const source = files.get(path)
|
|
255
|
+
if (source === undefined) {
|
|
256
|
+
throw new Error(`Cannot find module '${path}'`)
|
|
257
|
+
}
|
|
258
|
+
const module = { exports: {} as any }
|
|
259
|
+
cache.set(path, module)
|
|
260
|
+
const dir = nodePath.posix.dirname(path)
|
|
261
|
+
const localRequire = (id: string): unknown => {
|
|
262
|
+
if (id.startsWith('.')) {
|
|
263
|
+
const joined = nodePath.posix.normalize(nodePath.posix.join(dir, id))
|
|
264
|
+
const found = [joined, `${joined}.js`, `${joined}/index.js`].find(
|
|
265
|
+
(candidate) =>
|
|
266
|
+
files.has(candidate) || Object.prototype.hasOwnProperty.call(overrides, candidate)
|
|
267
|
+
)
|
|
268
|
+
return load(found || `${joined}.js`)
|
|
269
|
+
}
|
|
270
|
+
if (id === 'next-auth/jwt') {
|
|
271
|
+
return { getToken: async (): Promise<null> => null }
|
|
272
|
+
}
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
274
|
+
return require(id)
|
|
275
|
+
}
|
|
276
|
+
new Function('module', 'exports', 'require', source)(module, module.exports, localRequire)
|
|
277
|
+
return module.exports
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const run = async (
|
|
281
|
+
routeFile: string,
|
|
282
|
+
headers: Record<string, string | string[] | undefined>,
|
|
283
|
+
body: unknown
|
|
284
|
+
): Promise<Reply> => {
|
|
285
|
+
const reply: Reply = { status: 200, body: null }
|
|
286
|
+
const res: any = {
|
|
287
|
+
setHeader: (): any => res,
|
|
288
|
+
status(code: number) {
|
|
289
|
+
reply.status = code
|
|
290
|
+
return res
|
|
291
|
+
},
|
|
292
|
+
json(payload: unknown) {
|
|
293
|
+
reply.body = payload
|
|
294
|
+
return res
|
|
295
|
+
},
|
|
296
|
+
end() {
|
|
297
|
+
return res
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
const handler = load(routeFile)
|
|
301
|
+
await (handler.default || handler)(
|
|
302
|
+
{
|
|
303
|
+
method: 'POST',
|
|
304
|
+
url: '/' + routeFile.replace(/^pages\//, '').replace(/\.js$/, ''),
|
|
305
|
+
query: {},
|
|
306
|
+
headers,
|
|
307
|
+
body,
|
|
308
|
+
},
|
|
309
|
+
res
|
|
310
|
+
)
|
|
311
|
+
return reply
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const server = http.createServer((req, res) => {
|
|
315
|
+
const chunks: Buffer[] = []
|
|
316
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
317
|
+
req.on('end', async () => {
|
|
318
|
+
const path = new URL(req.url || '/', 'http://site').pathname
|
|
319
|
+
received.push({ path, host: String(req.headers.host) })
|
|
320
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
321
|
+
const body = text ? JSON.parse(text) : undefined
|
|
322
|
+
let reply: Reply = { status: 404, body: { error: 'not found' } }
|
|
323
|
+
if (/^\/api\/data\/[^/]+\/[^/]+$/.test(path)) {
|
|
324
|
+
reply = { status: 200, body: { success: true, rows: CART_ROWS, data: CART_ROWS[0] } }
|
|
325
|
+
} else if (files.has(`pages${path}.js`)) {
|
|
326
|
+
reply = await run(`pages${path}.js`, req.headers, body)
|
|
327
|
+
}
|
|
328
|
+
res.statusCode = reply.status
|
|
329
|
+
res.setHeader('content-type', 'application/json')
|
|
330
|
+
res.end(JSON.stringify(reply.body))
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
334
|
+
const { port } = server.address() as AddressInfo
|
|
335
|
+
origin = `http://127.0.0.1:${port}`
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
origin,
|
|
339
|
+
received,
|
|
340
|
+
load,
|
|
341
|
+
call: (routeFile, body = {}) =>
|
|
342
|
+
run(routeFile, { host: `127.0.0.1:${port}`, 'content-type': 'application/json' }, body),
|
|
343
|
+
close: () => server.close(),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Node's own fetch (undici), as the deployment has it: the one that refuses a
|
|
348
|
+
// relative address. Jest's sandbox has no global fetch of its own.
|
|
349
|
+
const nodeFetch = vm.runInThisContext('fetch')
|
|
350
|
+
|
|
351
|
+
const silenced = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
352
|
+
const sink = console as unknown as Record<string, unknown>
|
|
353
|
+
const levels = ['warn', 'error', 'log', 'info']
|
|
354
|
+
const saved = levels.map((level) => sink[level])
|
|
355
|
+
levels.forEach((level) => {
|
|
356
|
+
sink[level] = (): undefined => undefined
|
|
357
|
+
})
|
|
358
|
+
try {
|
|
359
|
+
return await run()
|
|
360
|
+
} finally {
|
|
361
|
+
levels.forEach((level, index) => {
|
|
362
|
+
sink[level] = saved[index]
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const onSite = async <T>(run: (site: Site) => Promise<T>): Promise<T> => {
|
|
368
|
+
const site = await startSite(await generateFiles())
|
|
369
|
+
const scope = globalThis as any
|
|
370
|
+
const previous = scope.fetch
|
|
371
|
+
scope.fetch = nodeFetch
|
|
372
|
+
try {
|
|
373
|
+
return await withEnv(ENV, () => silenced(() => run(site)))
|
|
374
|
+
} finally {
|
|
375
|
+
scope.fetch = previous
|
|
376
|
+
site.close()
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The paths the site received, each asserted to have come to its own address. */
|
|
381
|
+
const selfCalls = (site: Site): string[] => {
|
|
382
|
+
const host = new URL(site.origin).host
|
|
383
|
+
site.received.forEach((call) => expect(call.host).toBe(host))
|
|
384
|
+
return site.received.map((call) => call.path)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const webhookDelivery = (payload: unknown) => Buffer.from(JSON.stringify(payload))
|
|
388
|
+
|
|
389
|
+
describe("a server job calls the site's own routes by their absolute address", () => {
|
|
390
|
+
it('a scheduled workflow reads with a raw query, then records each row', async () => {
|
|
391
|
+
await onSite(async (site) => {
|
|
392
|
+
const reply = await site.call('pages/api/workflows/cron_cartreminder.js')
|
|
393
|
+
|
|
394
|
+
expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
|
|
395
|
+
expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`, `/api/data/${DS}/create`])
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("a scheduled workflow runs a shared step whose query is in that step's own route", async () => {
|
|
400
|
+
await onSite(async (site) => {
|
|
401
|
+
const reply = await site.call('pages/api/workflows/cron_release.js')
|
|
402
|
+
|
|
403
|
+
expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
|
|
404
|
+
const [segmentRoute, ...rest] = selfCalls(site)
|
|
405
|
+
expect(segmentRoute).toMatch(/^\/api\/workflows\/release-expired-holds-/)
|
|
406
|
+
expect(rest).toEqual([`/api/data/${DS}/raw-query`])
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('a webhook reads with a raw query', async () => {
|
|
411
|
+
await onSite(async (site) => {
|
|
412
|
+
const reply = await site.call(
|
|
413
|
+
'pages/api/webhooks/inventory-sync.js',
|
|
414
|
+
webhookDelivery({ event: 'stock' })
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
expect(reply).toEqual({ status: 200, body: { received: true } })
|
|
418
|
+
expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('an automation reads with a raw query', async () => {
|
|
423
|
+
await onSite(async (site) => {
|
|
424
|
+
const handler = site.load('lib/automations/handlers/wf-order-placed.js')
|
|
425
|
+
await handler.run({ event: { id: 'e1', type: 'order-placed' }, order: { id: 'o1' } }, {})
|
|
426
|
+
|
|
427
|
+
expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
})
|
|
@@ -444,8 +444,8 @@ function canSendNewsletterEmail() {
|
|
|
444
444
|
|
|
445
445
|
// A link to one of the site's pages carries the token in its fragment: a
|
|
446
446
|
// fragment never reaches a server, a CDN log or a Referer (the page's first
|
|
447
|
-
// head script keeps it). The site's own unsubscribe
|
|
448
|
-
// server, so a link to
|
|
447
|
+
// head script keeps it). The site's own confirm and unsubscribe routes read it
|
|
448
|
+
// on the server, so a link to one of them carries it in the query.
|
|
449
449
|
function pageLink(path, token) {
|
|
450
450
|
return path + (path.indexOf('/api/') === 0 ? '?t=' : '#t=') + encodeURIComponent(token);
|
|
451
451
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"routes-code.d.ts","sourceRoot":"","sources":["../../../src/newsletter/routes-code.ts"],"names":[],"mappings":"AA4BA,6DAA6D;AAC7D,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAoB/C,eAAO,MAAM,gCAAgC,QAAO,MAuCnD,CAAA;AA8BD,eAAO,MAAM,8BAA8B,QAAO,MAmDjD,CAAA;AAED,eAAO,MAAM,kCAAkC,QAAO,MAkDrD,CAAA"}
|