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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/__tests__/booking-calendar-feed-route.test.ts +494 -0
  2. package/__tests__/custom-node-result-taken-branch.test.ts +107 -0
  3. package/__tests__/data-api-date-parser.test.ts +45 -0
  4. package/__tests__/invoice-billing-details.test.ts +278 -0
  5. package/__tests__/middleware-admin-panel-roles.test.ts +127 -0
  6. package/__tests__/page-arrival-remount.test.ts +247 -0
  7. package/__tests__/payment-drivers-module.test.ts +19 -0
  8. package/__tests__/raw-query-error-code.test.ts +47 -0
  9. package/__tests__/runtime-template-token-resolution.test.ts +16 -0
  10. package/__tests__/segment-reply-changed-keys.test.ts +31 -0
  11. package/__tests__/segment-route-skipped-branch.test.ts +101 -0
  12. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  13. package/dist/cjs/api-route-generator.js +23 -13
  14. package/dist/cjs/api-route-generator.js.map +1 -1
  15. package/dist/cjs/auth-generator.d.ts.map +1 -1
  16. package/dist/cjs/auth-generator.js +75 -27
  17. package/dist/cjs/auth-generator.js.map +1 -1
  18. package/dist/cjs/booking-calendar-feed-route-generator.d.ts +28 -0
  19. package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -0
  20. package/dist/cjs/booking-calendar-feed-route-generator.js +296 -0
  21. package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -0
  22. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  23. package/dist/cjs/data-api-route-generator.js +6 -1
  24. package/dist/cjs/data-api-route-generator.js.map +1 -1
  25. package/dist/cjs/executor-generator.d.ts.map +1 -1
  26. package/dist/cjs/executor-generator.js +30 -2
  27. package/dist/cjs/executor-generator.js.map +1 -1
  28. package/dist/cjs/index.d.ts +1 -1
  29. package/dist/cjs/index.d.ts.map +1 -1
  30. package/dist/cjs/index.js +2 -1
  31. package/dist/cjs/index.js.map +1 -1
  32. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  33. package/dist/cjs/invoice/invoice-assembly-code.js +46 -6
  34. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  35. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  36. package/dist/cjs/invoice/invoice-html-code.js +10 -2
  37. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  38. package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
  39. package/dist/cjs/nodes/data/data-raw-query.js +11 -1
  40. package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
  41. package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
  42. package/dist/cjs/nodes/general/general-custom-js.js +11 -0
  43. package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
  44. package/dist/cjs/page-arrival.d.ts +10 -0
  45. package/dist/cjs/page-arrival.d.ts.map +1 -0
  46. package/dist/cjs/page-arrival.js +86 -0
  47. package/dist/cjs/page-arrival.js.map +1 -0
  48. package/dist/cjs/payments/index.d.ts +10 -4
  49. package/dist/cjs/payments/index.d.ts.map +1 -1
  50. package/dist/cjs/payments/index.js +18 -7
  51. package/dist/cjs/payments/index.js.map +1 -1
  52. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  53. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  54. package/dist/cjs/workflow-component-plugin.js +35 -149
  55. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  56. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  57. package/dist/cjs/workflow-project-plugin.js +14 -4
  58. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  59. package/dist/cjs/workflow-scope.d.ts +19 -0
  60. package/dist/cjs/workflow-scope.d.ts.map +1 -0
  61. package/dist/cjs/workflow-scope.js +147 -0
  62. package/dist/cjs/workflow-scope.js.map +1 -0
  63. package/dist/esm/api-route-generator.d.ts.map +1 -1
  64. package/dist/esm/api-route-generator.js +23 -13
  65. package/dist/esm/api-route-generator.js.map +1 -1
  66. package/dist/esm/auth-generator.d.ts.map +1 -1
  67. package/dist/esm/auth-generator.js +75 -27
  68. package/dist/esm/auth-generator.js.map +1 -1
  69. package/dist/esm/booking-calendar-feed-route-generator.d.ts +28 -0
  70. package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -0
  71. package/dist/esm/booking-calendar-feed-route-generator.js +291 -0
  72. package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -0
  73. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  74. package/dist/esm/data-api-route-generator.js +6 -1
  75. package/dist/esm/data-api-route-generator.js.map +1 -1
  76. package/dist/esm/executor-generator.d.ts.map +1 -1
  77. package/dist/esm/executor-generator.js +30 -2
  78. package/dist/esm/executor-generator.js.map +1 -1
  79. package/dist/esm/index.d.ts +1 -1
  80. package/dist/esm/index.d.ts.map +1 -1
  81. package/dist/esm/index.js +1 -1
  82. package/dist/esm/index.js.map +1 -1
  83. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  84. package/dist/esm/invoice/invoice-assembly-code.js +46 -6
  85. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  86. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  87. package/dist/esm/invoice/invoice-html-code.js +10 -2
  88. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  89. package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
  90. package/dist/esm/nodes/data/data-raw-query.js +11 -1
  91. package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
  92. package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
  93. package/dist/esm/nodes/general/general-custom-js.js +11 -0
  94. package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
  95. package/dist/esm/page-arrival.d.ts +10 -0
  96. package/dist/esm/page-arrival.d.ts.map +1 -0
  97. package/dist/esm/page-arrival.js +81 -0
  98. package/dist/esm/page-arrival.js.map +1 -0
  99. package/dist/esm/payments/index.d.ts +10 -4
  100. package/dist/esm/payments/index.d.ts.map +1 -1
  101. package/dist/esm/payments/index.js +16 -6
  102. package/dist/esm/payments/index.js.map +1 -1
  103. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  104. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  105. package/dist/esm/workflow-component-plugin.js +32 -146
  106. package/dist/esm/workflow-component-plugin.js.map +1 -1
  107. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  108. package/dist/esm/workflow-project-plugin.js +14 -4
  109. package/dist/esm/workflow-project-plugin.js.map +1 -1
  110. package/dist/esm/workflow-scope.d.ts +19 -0
  111. package/dist/esm/workflow-scope.d.ts.map +1 -0
  112. package/dist/esm/workflow-scope.js +142 -0
  113. package/dist/esm/workflow-scope.js.map +1 -0
  114. package/package.json +4 -4
  115. package/src/api-route-generator.ts +23 -13
  116. package/src/auth-generator.ts +80 -25
  117. package/src/booking-calendar-feed-route-generator.ts +293 -0
  118. package/src/data-api-route-generator.ts +6 -1
  119. package/src/executor-generator.ts +30 -2
  120. package/src/index.ts +1 -0
  121. package/src/invoice/invoice-assembly-code.ts +46 -6
  122. package/src/invoice/invoice-html-code.ts +10 -2
  123. package/src/nodes/data/data-raw-query.ts +11 -1
  124. package/src/nodes/general/general-custom-js.ts +10 -0
  125. package/src/page-arrival.ts +115 -0
  126. package/src/payments/index.ts +18 -5
  127. package/src/workflow-component-plugin.ts +43 -188
  128. package/src/workflow-project-plugin.ts +15 -4
  129. package/src/workflow-scope.ts +184 -0
@@ -0,0 +1,494 @@
1
+ import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
2
+ import { generateBookingCalendarFeedRoute } from '../src/booking-calendar-feed-route-generator'
3
+
4
+ // The bookings' calendar feed route is EXECUTED here with a stubbed `pg`
5
+ // client and a stubbed response, so the contract is proven on behaviour: the
6
+ // methods and keys it refuses, the one parameterised query, the calendar it
7
+ // writes (RFC 5545 escaping, 75-octet folding, all-day and timed events), a
8
+ // HEAD without a body and a 500 that never reveals the database error.
9
+
10
+ const CR_LF = String.fromCharCode(13, 10)
11
+ const LF = String.fromCharCode(10)
12
+ const CR = String.fromCharCode(13)
13
+ const BS = String.fromCharCode(92)
14
+ const FEED_STATEMENT = 'SELECT * FROM booking_calendar_feed($1)'
15
+ const KEY = 'AbCdEfGhIjKlMnOpQrSt_-12'
16
+ const FILE = `${KEY}.ics`
17
+ // The request's "now": the DTSTAMP of every event.
18
+ const NOW = Date.UTC(2026, 9, 9, 8, 15, 0)
19
+
20
+ interface PgStub {
21
+ calls: Array<{ sql: string; params: unknown[] }>
22
+ rows: unknown[]
23
+ queryError: Error | null
24
+ connectError: Error | null
25
+ connections: number
26
+ ended: number
27
+ }
28
+
29
+ const makePgStub = (rows: unknown[] = []): PgStub => ({
30
+ calls: [],
31
+ rows,
32
+ queryError: null,
33
+ connectError: null,
34
+ connections: 0,
35
+ ended: 0,
36
+ })
37
+
38
+ interface FeedResponse {
39
+ statusCode: number
40
+ headers: Record<string, string>
41
+ body: string | undefined
42
+ ended: number
43
+ }
44
+
45
+ // A Date whose "now" is NOW; every other use behaves like Date.
46
+ class FixedDate extends Date {
47
+ constructor(...args: any[]) {
48
+ if (args.length === 0) {
49
+ super(NOW)
50
+ } else {
51
+ super(...(args as []))
52
+ }
53
+ }
54
+ }
55
+
56
+ const bootRoute = (pg: PgStub, errors: unknown[][] = []) => {
57
+ class Client {
58
+ public options: unknown
59
+ constructor(options: unknown) {
60
+ this.options = options
61
+ }
62
+ public async connect() {
63
+ pg.connections += 1
64
+ if (pg.connectError) {
65
+ throw pg.connectError
66
+ }
67
+ }
68
+ public async query(sql: string, params: unknown[]) {
69
+ pg.calls.push({ sql, params })
70
+ if (pg.queryError) {
71
+ throw pg.queryError
72
+ }
73
+ return { rows: pg.rows }
74
+ }
75
+ public async end() {
76
+ pg.ended += 1
77
+ }
78
+ }
79
+ const fakeRequire = (name: string): any => {
80
+ if (name === 'pg') {
81
+ return { Client }
82
+ }
83
+ throw new Error(`unexpected require: ${name}`)
84
+ }
85
+ const moduleObj: { exports: any } = { exports: {} }
86
+ // tslint:disable-next-line:function-constructor
87
+ new Function(
88
+ 'require',
89
+ 'module',
90
+ 'exports',
91
+ 'process',
92
+ 'console',
93
+ 'Date',
94
+ 'Buffer',
95
+ generateBookingCalendarFeedRoute(FEED_STATEMENT)
96
+ )(
97
+ fakeRequire,
98
+ moduleObj,
99
+ moduleObj.exports,
100
+ { env: { TELEPORT_DB_CONNECTION_STRING: 'postgresql://user:pw@db.test/site' } },
101
+ { error: (...args: unknown[]) => errors.push(args) },
102
+ FixedDate,
103
+ Buffer
104
+ )
105
+ const handler = moduleObj.exports as (req: unknown, res: unknown) => Promise<unknown>
106
+ return async (req: Record<string, unknown>): Promise<FeedResponse> => {
107
+ const response: FeedResponse = { statusCode: 200, headers: {}, body: undefined, ended: 0 }
108
+ const res = {
109
+ set statusCode(value: number) {
110
+ response.statusCode = value
111
+ },
112
+ get statusCode() {
113
+ return response.statusCode
114
+ },
115
+ setHeader: (name: string, value: string) => {
116
+ response.headers[name] = value
117
+ },
118
+ end: (body?: string) => {
119
+ response.body = body
120
+ response.ended += 1
121
+ },
122
+ }
123
+ await handler({ method: 'GET', query: { file: FILE }, ...req }, res)
124
+ return response
125
+ }
126
+ }
127
+
128
+ const calendarRow = (name = 'Sea view room') => ({ kind: 'calendar', calendar_name: name })
129
+
130
+ const event = (overrides: Record<string, unknown> = {}) => ({
131
+ kind: 'event',
132
+ uid: 'booking-1@site',
133
+ summary: 'Reserved',
134
+ all_day: true,
135
+ start_value: '20261012',
136
+ end_value: '20261015',
137
+ ...overrides,
138
+ })
139
+
140
+ // The content lines of a feed, folded continuations joined back on.
141
+ const unfold = (body: string): string[] =>
142
+ body.split(`${CR_LF} `).join('').split(CR_LF).slice(0, -1)
143
+
144
+ const eventBlocks = (body: string): string[][] => {
145
+ const blocks: string[][] = []
146
+ let current: string[] | null = null
147
+ for (const line of unfold(body)) {
148
+ if (line === 'BEGIN:VEVENT') {
149
+ current = []
150
+ } else if (line === 'END:VEVENT' && current) {
151
+ blocks.push(current)
152
+ current = null
153
+ } else if (current) {
154
+ current.push(line)
155
+ }
156
+ }
157
+ return blocks
158
+ }
159
+
160
+ describe('the bookings calendar feed route — emission', () => {
161
+ const structure = (extra: Record<string, unknown>): any => ({
162
+ uidl: {
163
+ name: 'Stays',
164
+ globals: { settings: { title: 'Stays', language: 'en' }, env: {} },
165
+ root: { name: 'Root', stateDefinitions: {}, node: { type: 'element', content: {} } },
166
+ ...extra,
167
+ },
168
+ strategy: { pages: { options: {} } },
169
+ files: new Map(),
170
+ dependencies: {},
171
+ devDependencies: {},
172
+ })
173
+
174
+ it('is not emitted without a feed statement', async () => {
175
+ for (const extra of [
176
+ {},
177
+ { bookingCalendars: { feedStatement: '' } },
178
+ { bookingCalendars: { feedStatement: ' ' } },
179
+ ]) {
180
+ const project = structure(extra)
181
+ await new NextWorkflowProjectPlugin().runAfter(project)
182
+ expect(project.files.has('bookings-ical-feed-route')).toBe(false)
183
+ expect(project.dependencies.pg).toBeUndefined()
184
+ }
185
+ })
186
+
187
+ it('is emitted at pages/api/bookings/ical/[file].js with the statement and pg', async () => {
188
+ const project = structure({ bookingCalendars: { feedStatement: FEED_STATEMENT } })
189
+ await new NextWorkflowProjectPlugin().runAfter(project)
190
+ const entry = project.files.get('bookings-ical-feed-route')
191
+ expect(entry.path).toEqual(['pages', 'api', 'bookings', 'ical'])
192
+ expect(entry.files).toHaveLength(1)
193
+ expect(entry.files[0].name).toBe('[file]')
194
+ expect(entry.files[0].fileType).toBe('js')
195
+ expect(entry.files[0].content).toContain(
196
+ `var FEED_STATEMENT = ${JSON.stringify(FEED_STATEMENT)};`
197
+ )
198
+ expect(project.dependencies.pg).toBe('^8.12.0')
199
+ })
200
+
201
+ it('keeps the pg version the project already has', async () => {
202
+ const project = structure({ bookingCalendars: { feedStatement: FEED_STATEMENT } })
203
+ project.dependencies.pg = '^8.11.0'
204
+ await new NextWorkflowProjectPlugin().runAfter(project)
205
+ expect(project.files.has('bookings-ical-feed-route')).toBe(true)
206
+ expect(project.dependencies.pg).toBe('^8.11.0')
207
+ })
208
+ })
209
+
210
+ describe('the bookings calendar feed route — behaviour', () => {
211
+ it('answers 405 with Allow to any method but GET and HEAD, without a query', async () => {
212
+ const pg = makePgStub([calendarRow()])
213
+ const route = bootRoute(pg)
214
+ for (const method of ['POST', 'PUT', 'DELETE', 'OPTIONS']) {
215
+ const response = await route({ method })
216
+ expect(response.statusCode).toBe(405)
217
+ expect(response.headers.Allow).toBe('GET, HEAD')
218
+ expect(response.body).toBe('Method not allowed')
219
+ }
220
+ expect(pg.calls).toHaveLength(0)
221
+ })
222
+
223
+ it('answers 404 to a key of the wrong shape, without a query', async () => {
224
+ const pg = makePgStub([calendarRow()])
225
+ const route = bootRoute(pg)
226
+ for (const query of [
227
+ {},
228
+ { file: KEY },
229
+ { file: `${KEY}.ICS` },
230
+ { file: `${KEY}.ics.ics` },
231
+ { file: 'short-key.ics' },
232
+ { file: `${'a'.repeat(81)}.ics` },
233
+ { file: `${KEY.slice(0, -1)}!.ics` },
234
+ { file: `${KEY.slice(0, -1)}..ics` },
235
+ { file: [FILE, FILE] },
236
+ ]) {
237
+ const response = await route({ query })
238
+ expect(response.statusCode).toBe(404)
239
+ expect(response.headers['Content-Type']).toBe('text/plain; charset=utf-8')
240
+ expect(response.body).toBe('Not found')
241
+ }
242
+ expect(pg.calls).toHaveLength(0)
243
+ // The longest and shortest keys the contract allows are accepted.
244
+ expect((await route({ query: { file: `${'a'.repeat(80)}.ics` } })).statusCode).toBe(200)
245
+ expect((await route({ query: { file: `${'a'.repeat(20)}.ics` } })).statusCode).toBe(200)
246
+ })
247
+
248
+ it('answers 404 when the key names no calendar, and closes the connection', async () => {
249
+ const pg = makePgStub([event()])
250
+ const response = await bootRoute(pg)({})
251
+ expect(response.statusCode).toBe(404)
252
+ expect(response.body).toBe('Not found')
253
+ expect(pg.calls).toEqual([{ sql: FEED_STATEMENT, params: [KEY] }])
254
+ expect(pg.connections).toBe(1)
255
+ expect(pg.ended).toBe(1)
256
+ })
257
+
258
+ it('writes the calendar with all-day and timed events', async () => {
259
+ const pg = makePgStub([
260
+ calendarRow(),
261
+ event(),
262
+ event({
263
+ uid: 'booking-2@site',
264
+ summary: 'Massage',
265
+ all_day: false,
266
+ start_value: '20261020T090000Z',
267
+ end_value: '20261020T103000Z',
268
+ }),
269
+ ])
270
+ const response = await bootRoute(pg)({})
271
+ expect(response.statusCode).toBe(200)
272
+ expect(response.headers['Content-Type']).toBe('text/calendar; charset=utf-8')
273
+ expect(response.headers['Cache-Control']).toBe('no-store')
274
+ expect(response.headers['Content-Disposition']).toBe(`inline; filename="${FILE}"`)
275
+ expect(response.headers['Content-Length']).toBe(
276
+ String(Buffer.byteLength(response.body as string, 'utf8'))
277
+ )
278
+ expect(pg.calls).toEqual([{ sql: FEED_STATEMENT, params: [KEY] }])
279
+ expect(pg.ended).toBe(1)
280
+ expect(response.body).toBe(
281
+ [
282
+ 'BEGIN:VCALENDAR',
283
+ 'VERSION:2.0',
284
+ 'PRODID:-//TeleportHQ//Bookings//EN',
285
+ 'CALSCALE:GREGORIAN',
286
+ 'METHOD:PUBLISH',
287
+ 'X-WR-CALNAME:Sea view room',
288
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT30M',
289
+ 'X-PUBLISHED-TTL:PT30M',
290
+ 'BEGIN:VEVENT',
291
+ 'UID:booking-1@site',
292
+ 'DTSTAMP:20261009T081500Z',
293
+ 'DTSTART;VALUE=DATE:20261012',
294
+ 'DTEND;VALUE=DATE:20261015',
295
+ 'SUMMARY:Reserved',
296
+ 'TRANSP:OPAQUE',
297
+ 'END:VEVENT',
298
+ 'BEGIN:VEVENT',
299
+ 'UID:booking-2@site',
300
+ 'DTSTAMP:20261009T081500Z',
301
+ 'DTSTART:20261020T090000Z',
302
+ 'DTEND:20261020T103000Z',
303
+ 'SUMMARY:Massage',
304
+ 'TRANSP:OPAQUE',
305
+ 'END:VEVENT',
306
+ 'END:VCALENDAR',
307
+ '',
308
+ ].join(CR_LF)
309
+ )
310
+ })
311
+
312
+ it('writes a calendar with no events when nothing is booked', async () => {
313
+ const response = await bootRoute(makePgStub([calendarRow('Cabin')]))({})
314
+ expect(response.statusCode).toBe(200)
315
+ expect(unfold(response.body as string)).toEqual([
316
+ 'BEGIN:VCALENDAR',
317
+ 'VERSION:2.0',
318
+ 'PRODID:-//TeleportHQ//Bookings//EN',
319
+ 'CALSCALE:GREGORIAN',
320
+ 'METHOD:PUBLISH',
321
+ 'X-WR-CALNAME:Cabin',
322
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT30M',
323
+ 'X-PUBLISHED-TTL:PT30M',
324
+ 'END:VCALENDAR',
325
+ ])
326
+ })
327
+
328
+ it('reads all_day as a boolean or as Postgres writes it in text', async () => {
329
+ const rows: unknown[] = [calendarRow()]
330
+ for (const allDay of [true, 't', 'true', 'TRUE']) {
331
+ rows.push(event({ uid: `day-${String(allDay)}`, all_day: allDay }))
332
+ }
333
+ for (const allDay of [false, 'f', 'false']) {
334
+ rows.push(
335
+ event({
336
+ uid: `timed-${String(allDay)}`,
337
+ all_day: allDay,
338
+ start_value: '20261020T090000Z',
339
+ end_value: '20261020T100000Z',
340
+ })
341
+ )
342
+ }
343
+ const blocks = eventBlocks((await bootRoute(makePgStub(rows))({})).body as string)
344
+ expect(blocks).toHaveLength(7)
345
+ for (const block of blocks.slice(0, 4)) {
346
+ expect(block).toContain('DTSTART;VALUE=DATE:20261012')
347
+ expect(block).toContain('DTEND;VALUE=DATE:20261015')
348
+ }
349
+ for (const block of blocks.slice(4)) {
350
+ expect(block).toContain('DTSTART:20261020T090000Z')
351
+ expect(block).toContain('DTEND:20261020T100000Z')
352
+ }
353
+ })
354
+
355
+ it('leaves out an event row with a missing or invalid value', async () => {
356
+ const response = await bootRoute(
357
+ makePgStub([
358
+ calendarRow(),
359
+ event({ uid: 'kept' }),
360
+ event({ uid: '' }),
361
+ event({ uid: null }),
362
+ event({ uid: 'bad-all-day', all_day: 'yes' }),
363
+ event({ uid: 'no-all-day', all_day: null }),
364
+ event({ uid: 'no-such-day', start_value: '20260231', end_value: '20260302' }),
365
+ event({ uid: 'month-13', start_value: '20261301', end_value: '20261302' }),
366
+ event({ uid: 'end-before-start', start_value: '20261015', end_value: '20261012' }),
367
+ event({ uid: 'empty-stay', start_value: '20261012', end_value: '20261012' }),
368
+ event({ uid: 'timed-on-all-day', start_value: '20261012T100000Z' }),
369
+ event({ uid: 'date-object', start_value: new Date(NOW) }),
370
+ event({ uid: 'no-end', end_value: null }),
371
+ event({ uid: 'dashed', start_value: '2026-10-12', end_value: '2026-10-15' }),
372
+ event({
373
+ uid: 'hour-24',
374
+ all_day: false,
375
+ start_value: '20261020T240000Z',
376
+ end_value: '20261021T010000Z',
377
+ }),
378
+ event({
379
+ uid: 'not-utc',
380
+ all_day: false,
381
+ start_value: '20261020T090000',
382
+ end_value: '20261020T100000',
383
+ }),
384
+ event({
385
+ uid: 'date-on-timed',
386
+ all_day: false,
387
+ start_value: '20261020',
388
+ end_value: '20261021',
389
+ }),
390
+ {
391
+ kind: 'other',
392
+ uid: 'other-kind',
393
+ all_day: true,
394
+ start_value: '20261012',
395
+ end_value: '20261015',
396
+ },
397
+ null,
398
+ ])
399
+ )({})
400
+ expect(response.statusCode).toBe(200)
401
+ const blocks = eventBlocks(response.body as string)
402
+ expect(blocks).toHaveLength(1)
403
+ expect(blocks[0]).toContain('UID:kept')
404
+ })
405
+
406
+ it('escapes TEXT values as RFC 5545 requires', async () => {
407
+ const summary = `Room 1, sea view; ${BS}deluxe${BS}${LF}two${CR_LF}three${CR}four${String.fromCharCode(
408
+ 7
409
+ )}${String.fromCharCode(9)}end`
410
+ const response = await bootRoute(
411
+ makePgStub([calendarRow('Rooms; A, B'), event({ summary, uid: 'a,b;c' })])
412
+ )({})
413
+ const lines = unfold(response.body as string)
414
+ expect(lines).toContain(`X-WR-CALNAME:Rooms${BS}; A${BS}, B`)
415
+ expect(lines).toContain(`UID:a${BS},b${BS};c`)
416
+ expect(lines).toContain(
417
+ `SUMMARY:Room 1${BS}, sea view${BS}; ${BS}${BS}deluxe${BS}${BS}${BS}ntwo${BS}nthree${BS}nfour${String.fromCharCode(
418
+ 9
419
+ )}end`
420
+ )
421
+ // No raw line break survives inside a value: every CR starts a CRLF.
422
+ const body = response.body as string
423
+ expect(body.split(CR_LF).join('').indexOf(CR)).toBe(-1)
424
+ expect(body.split(CR_LF).join('').indexOf(LF)).toBe(-1)
425
+ })
426
+
427
+ it('folds lines at 75 octets without splitting a UTF-8 character', async () => {
428
+ const ascii = 'Long stay '.repeat(20)
429
+ const multiByte = 'é€😀 Château à la mer '.repeat(12)
430
+ const response = await bootRoute(
431
+ makePgStub([
432
+ calendarRow(multiByte),
433
+ event({ uid: 'ascii', summary: ascii }),
434
+ event({ uid: 'multi', summary: multiByte }),
435
+ ])
436
+ )({})
437
+ const body = response.body as string
438
+ expect(body.endsWith(CR_LF)).toBe(true)
439
+ const physical = body.split(CR_LF).slice(0, -1)
440
+ expect(physical.length).toBeGreaterThan(unfold(body).length)
441
+ for (const line of physical) {
442
+ expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75)
443
+ // A split surrogate pair would not survive a UTF-8 round trip.
444
+ expect(Buffer.from(line, 'utf8').toString('utf8')).toBe(line)
445
+ }
446
+ // A fold uses the full 75 octets when the next character fits.
447
+ const asciiStart = physical.findIndex((line) => line.startsWith('SUMMARY:Long stay'))
448
+ expect(Buffer.byteLength(physical[asciiStart], 'utf8')).toBe(75)
449
+ expect(physical[asciiStart + 1].charAt(0)).toBe(' ')
450
+ expect(Buffer.byteLength(physical[asciiStart + 1], 'utf8')).toBe(75)
451
+ const lines = unfold(body)
452
+ expect(lines).toContain(`SUMMARY:${ascii}`)
453
+ expect(lines).toContain(`SUMMARY:${multiByte}`)
454
+ expect(lines).toContain(`X-WR-CALNAME:${multiByte}`)
455
+ })
456
+
457
+ it('answers HEAD with the headers GET would send and no body', async () => {
458
+ const rows = [calendarRow(), event()]
459
+ const get = await bootRoute(makePgStub(rows))({})
460
+ const pg = makePgStub(rows)
461
+ const head = await bootRoute(pg)({ method: 'HEAD' })
462
+ expect(head.statusCode).toBe(200)
463
+ expect(head.headers).toEqual(get.headers)
464
+ expect(head.body).toBeUndefined()
465
+ expect(head.ended).toBe(1)
466
+ expect(pg.calls).toHaveLength(1)
467
+
468
+ const missing = await bootRoute(makePgStub([]))({ method: 'HEAD' })
469
+ expect(missing.statusCode).toBe(404)
470
+ expect(missing.body).toBeUndefined()
471
+ })
472
+
473
+ it('answers 500 when the database fails, logging the cause only', async () => {
474
+ for (const failure of ['query', 'connect']) {
475
+ const pg = makePgStub([calendarRow(), event()])
476
+ const secret = new Error('password authentication failed for user "owner"')
477
+ if (failure === 'query') {
478
+ pg.queryError = secret
479
+ } else {
480
+ pg.connectError = secret
481
+ }
482
+ const errors: unknown[][] = []
483
+ const response = await bootRoute(pg, errors)({})
484
+ expect(response.statusCode).toBe(500)
485
+ expect(response.headers['Content-Type']).toBe('text/plain; charset=utf-8')
486
+ expect(response.headers['Cache-Control']).toBe('no-store')
487
+ expect(response.headers['Content-Disposition']).toBeUndefined()
488
+ expect(response.body).toBe('Calendar unavailable')
489
+ expect(errors).toHaveLength(1)
490
+ expect(errors[0].join(' ')).toContain(secret.message)
491
+ expect(pg.ended).toBe(1)
492
+ }
493
+ })
494
+ })
@@ -0,0 +1,107 @@
1
+ import { generateSharedRuntimeUtilsCode } from '../src'
2
+ import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
3
+
4
+ // A custom node answers its caller the output of the last node it ran. The
5
+ // email senders of teleport-gui are one branch per purpose — "is it this
6
+ // email?" → send → "was it accepted?" → a report — in one server segment, and
7
+ // their callers read the report: `{ sent, skipped }`. Whatever a segment
8
+ // answers for a branch it did not take (a route built before it stopped
9
+ // evaluating the if-statements there) must never stand in for that report.
10
+
11
+ const ifNode = (id: string, stepNumber: number) => ({
12
+ id,
13
+ type: 'general-if-statement',
14
+ label: id,
15
+ config: { conditionType: 'simple-comparison', leftValue: 1, operator: '===', rightValue: 1 },
16
+ executionEnv: 'server',
17
+ stepNumber,
18
+ })
19
+
20
+ const reportNode = (id: string, stepNumber: number) => ({
21
+ id,
22
+ type: 'general-custom-js',
23
+ label: id,
24
+ config: { code: 'function customHandler(p, q) { return {} }', context: 'server' },
25
+ executionEnv: 'server',
26
+ stepNumber,
27
+ })
28
+
29
+ const customNodes = {
30
+ 'cn-sender': {
31
+ id: 'cn-sender',
32
+ name: 'Sender',
33
+ parameters: [],
34
+ nodes: [
35
+ ifNode('isA', 1),
36
+ reportNode('reportA', 2),
37
+ ifNode('isB', 2),
38
+ ifNode('acceptedB', 3),
39
+ reportNode('reportB', 4),
40
+ ],
41
+ edges: [
42
+ { id: 'e1', source: 'isA', target: 'reportA', sourceHandle: 'true' },
43
+ { id: 'e2', source: 'isA', target: 'isB', sourceHandle: 'false' },
44
+ { id: 'e3', source: 'isB', target: 'acceptedB', sourceHandle: 'true' },
45
+ { id: 'e4', source: 'acceptedB', target: 'reportB', sourceHandle: 'true' },
46
+ ],
47
+ },
48
+ }
49
+
50
+ function bootSender(segmentAnswer: Record<string, unknown>) {
51
+ const plugin = new NextWorkflowProjectPlugin() as unknown as {
52
+ generateCustomNodesFile: (
53
+ nodes: Record<string, unknown>,
54
+ urls: Record<string, Record<string, string>>
55
+ ) => string
56
+ }
57
+ const source = plugin.generateCustomNodesFile(customNodes, {
58
+ 'cn-sender': { 'server-1': '/api/workflows/sender-seg-1' },
59
+ })
60
+ const utilsModule: { exports: Record<string, unknown> } = { exports: {} }
61
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
62
+ new Function('module', 'exports', generateSharedRuntimeUtilsCode())(
63
+ utilsModule,
64
+ utilsModule.exports
65
+ )
66
+ const runtimeStub = {
67
+ findStreamingAINodes: () => ({}),
68
+ callStreamingServerSegment: async () => ({}),
69
+ callServerSegment: async () => segmentAnswer,
70
+ mergeServerResults: (context: Record<string, unknown>, results: Record<string, unknown>) => {
71
+ Object.assign(context, results)
72
+ },
73
+ }
74
+ const requireStub = (id: string) =>
75
+ id === './runtime-utils' ? utilsModule.exports : id === './runtime' ? runtimeStub : {}
76
+ const moduleStub: { exports: Record<string, unknown> } = { exports: {} }
77
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
78
+ new Function('module', 'exports', 'require', source)(moduleStub, moduleStub.exports, requireStub)
79
+ return (moduleStub.exports as Record<string, (...args: unknown[]) => Promise<unknown>>)[
80
+ 'cn-sender'
81
+ ]
82
+ }
83
+
84
+ describe('a custom node answers the terminal of the branch it took', () => {
85
+ it('never the result of a node on a branch the segment skipped', async () => {
86
+ const run = bootSender({
87
+ isA: { result: true },
88
+ reportA: { sent: 'true', skipped: '' },
89
+ // What an older route answered for the branch it did not take.
90
+ isB: { result: false },
91
+ acceptedB: { result: false },
92
+ __skippedNodes: { isB: true, acceptedB: true, reportB: true },
93
+ })
94
+ expect(await run({}, {}, {})).toEqual({ sent: 'true', skipped: '' })
95
+ })
96
+
97
+ it('still answers the last node when nothing was skipped', async () => {
98
+ const run = bootSender({
99
+ isA: { result: false },
100
+ isB: { result: true },
101
+ acceptedB: { result: true },
102
+ reportB: { sent: 'false', skipped: 'send-failed' },
103
+ __skippedNodes: { reportA: true },
104
+ })
105
+ expect(await run({}, {}, {})).toEqual({ sent: 'false', skipped: 'send-failed' })
106
+ })
107
+ })
@@ -0,0 +1,45 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataAPIRoute } from '../src/data-api-route-generator'
3
+
4
+ /**
5
+ * The data API (workflow reads and writes) keeps a Postgres DATE as the day
6
+ * it names: node-pg's default reads it as midnight in the server's time zone,
7
+ * which a server east of UTC serves as the day before — and a form that loads
8
+ * the row then saves that day back.
9
+ */
10
+
11
+ const boot = (withTypes: boolean) => {
12
+ const registered: Array<{ oid: number; parse: (value: string) => unknown }> = []
13
+ const types = {
14
+ setTypeParser: (oid: number, parse: (value: string) => unknown) =>
15
+ registered.push({ oid, parse }),
16
+ }
17
+ new Function('require', 'module', 'exports', 'process', 'console', generateDataAPIRoute({}))(
18
+ (name: string) => {
19
+ if (name === 'pg') {
20
+ return withTypes ? { Client: class {}, types } : { Client: class {} }
21
+ }
22
+ if (name === 'next-auth/jwt') {
23
+ return { getToken: async () => null }
24
+ }
25
+ throw new Error('unexpected require: ' + name)
26
+ },
27
+ { exports: {} },
28
+ {},
29
+ { env: {} },
30
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
31
+ )
32
+ return registered
33
+ }
34
+
35
+ describe('data API DATE columns', () => {
36
+ it('keeps a DATE as the day it names', () => {
37
+ const registered = boot(true)
38
+ expect(registered.map(({ oid }) => oid)).toEqual([1082])
39
+ expect(registered[0].parse('2026-10-09')).toBe('2026-10-09')
40
+ })
41
+
42
+ it('loads with a driver that has no type registry', () => {
43
+ expect(boot(false)).toEqual([])
44
+ })
45
+ })