@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.
- 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__/invoice-billing-details.test.ts +278 -0
- 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/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.map +1 -1
- package/dist/cjs/auth-generator.js +75 -27
- 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 +30 -2
- package/dist/cjs/executor-generator.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/invoice-assembly-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.js +46 -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 +10 -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/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/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/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 +14 -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/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.map +1 -1
- package/dist/esm/auth-generator.js +75 -27
- 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 +30 -2
- package/dist/esm/executor-generator.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/invoice-assembly-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.js +46 -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 +10 -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/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/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/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 +14 -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/api-route-generator.ts +23 -13
- package/src/auth-generator.ts +80 -25
- package/src/booking-calendar-feed-route-generator.ts +293 -0
- package/src/data-api-route-generator.ts +6 -1
- package/src/executor-generator.ts +30 -2
- package/src/index.ts +1 -0
- package/src/invoice/invoice-assembly-code.ts +46 -6
- package/src/invoice/invoice-html-code.ts +10 -2
- package/src/nodes/data/data-raw-query.ts +11 -1
- package/src/nodes/general/general-custom-js.ts +10 -0
- package/src/page-arrival.ts +115 -0
- package/src/payments/index.ts +18 -5
- package/src/workflow-component-plugin.ts +43 -188
- package/src/workflow-project-plugin.ts +15 -4
- 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
|
+
})
|