@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.
@@ -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&#39;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&#39;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 route reads it on the
448
- // server, so a link to it carries it in the query.
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":"AA0BA,6DAA6D;AAC7D,eAAO,MAAM,6BAA6B,KAAK,CAAA;AAoB/C,eAAO,MAAM,gCAAgC,QAAO,MAuCnD,CAAA;AAED,eAAO,MAAM,8BAA8B,QAAO,MAqBjD,CAAA;AAED,eAAO,MAAM,kCAAkC,QAAO,MA0ErD,CAAA"}
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"}