CronJobs.utils.test.ts353 lines · main
1import { describe, expect, it } from 'vitest'
2
3import { cronPattern, secondsPattern } from './CronJobs.constants'
4import { formatCronJobColumns, parseCronJobCommand } from './CronJobs.utils'
5
6describe('parseCronJobCommand', () => {
7 it('should return a default object when the command is null', () => {
8 expect(parseCronJobCommand('', 'random_project_ref')).toStrictEqual({
9 httpBody: '',
10 method: 'POST',
11 snippet: '',
12 timeoutMs: 1000,
13 type: 'sql_snippet',
14 })
15 })
16
17 it('should return a default object when the command is random', () => {
18 const command = 'some random text'
19 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
20 snippet: 'some random text',
21 type: 'sql_snippet',
22 })
23 })
24
25 it('should return a sql function command when the command is SELECT auth.jwt ()', () => {
26 const command = 'SELECT auth.jwt ()'
27 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
28 type: 'sql_function',
29 schema: 'auth',
30 functionName: 'jwt',
31 snippet: command,
32 })
33 })
34
35 it('should return a sql function command when the command is SELECT auth.jwt () and ends with ;', () => {
36 const command = 'SELECT auth.jwt ();'
37 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
38 type: 'sql_function',
39 schema: 'auth',
40 functionName: 'jwt',
41 snippet: command,
42 })
43 })
44
45 it('should return a sql function command when the function name contains an underscore', () => {
46 const command = 'SELECT random_schema.function_1()'
47 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
48 type: 'sql_function',
49 schema: 'random_schema',
50 functionName: 'function_1',
51 snippet: command,
52 })
53 })
54
55 it('should return a sql function command for lowercase select', () => {
56 const command = 'select lowercase.issue ()'
57 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
58 type: 'sql_function',
59 schema: 'lowercase',
60 functionName: 'issue',
61 snippet: command,
62 })
63 })
64
65 it('should return a sql snippet command when the command is SELECT public.test_fn(1, 2)', () => {
66 const command = 'SELECT public.test_fn(1, 2)'
67 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
68 type: 'sql_snippet',
69 snippet: command,
70 })
71 })
72
73 it('should return a sql snippet command when the command is using a SQL function from the search path', () => {
74 const command = 'SELECT test_cron_function()'
75 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
76 type: 'sql_snippet',
77 snippet: command,
78 })
79 })
80
81 it('should return a sql snippet command when the command is SELECT .()', () => {
82 const command = 'SELECT .()'
83 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
84 type: 'sql_snippet',
85 snippet: command,
86 })
87 })
88
89 it('should return a sql snippet command when the command is SELECT schema.()', () => {
90 const command = 'SELECT schema.()'
91 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
92 type: 'sql_snippet',
93 snippet: command,
94 })
95 })
96
97 it('should return a edge function config when the command posts to its own supabase.co project', () => {
98 const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), body:='', timeout_milliseconds:=5000 );`
99 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
100 edgeFunctionName: 'https://random_project_ref.supabase.co/functions/v1/_',
101 method: 'POST',
102 httpHeaders: [
103 {
104 name: 'Authorization',
105 value: 'Bearer something',
106 },
107 ],
108 httpBody: '',
109 timeoutMs: 5000,
110 type: 'edge_function',
111 snippet: command,
112 })
113 })
114
115 it('should return a edge function config when the body is missing', () => {
116 const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );`
117 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
118 edgeFunctionName: 'https://random_project_ref.supabase.co/functions/v1/_',
119 method: 'POST',
120 httpHeaders: [
121 {
122 name: 'Authorization',
123 value: 'Bearer something',
124 },
125 ],
126 httpBody: '',
127 timeoutMs: 5000,
128 type: 'edge_function',
129 snippet: command,
130 })
131 })
132
133 it("should return an HTTP request config when there's a query parameter or hash in the URL (also handles edge function)", () => {
134 const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 )`
135 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
136 endpoint: 'https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2',
137 method: 'POST',
138 httpHeaders: [
139 {
140 name: 'Authorization',
141 value: 'Bearer something',
142 },
143 ],
144 httpBody: '',
145 timeoutMs: 5000,
146 type: 'http_request',
147 snippet: command,
148 })
149 })
150
151 it('should return an HTTP request config when the command posts to another supabase.co project', () => {
152 const command = `select net.http_post( url:='https://another_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object(), body:='', timeout_milliseconds:=5000 );`
153 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
154 endpoint: 'https://another_project_ref.supabase.co/functions/v1/_',
155 method: 'POST',
156 httpHeaders: [],
157 httpBody: '',
158 timeoutMs: 5000,
159 type: 'http_request',
160 snippet: command,
161 })
162 })
163
164 it('should return an HTTP request config with POST method, empty headers and a body as string', () => {
165 const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), body:='hello', timeout_milliseconds:=5000 );`
166 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
167 endpoint: 'https://example.com/api/endpoint',
168 method: 'POST',
169 httpHeaders: [],
170 httpBody: 'hello',
171 timeoutMs: 5000,
172 type: 'http_request',
173 snippet: command,
174 })
175 })
176
177 it('should return an HTTP request config with POST method, some headers and empty body', () => {
178 const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('fst', '1', 'snd', 'O''Reilly'), body:='', timeout_milliseconds:=1000 );`
179 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
180 endpoint: 'https://example.com/api/endpoint',
181 method: 'POST',
182 httpHeaders: [
183 { name: 'fst', value: '1' },
184 { name: 'snd', value: "O'Reilly" },
185 ],
186 httpBody: '',
187 timeoutMs: 1000,
188 type: 'http_request',
189 snippet: command,
190 })
191 })
192
193 it('should return an HTTP request config with GET method and empty body', () => {
194 const command = `select net.http_get( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), timeout_milliseconds:=5000 );`
195 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
196 endpoint: 'https://example.com/api/endpoint',
197 method: 'GET',
198 httpHeaders: [],
199 httpBody: '',
200 timeoutMs: 5000,
201 type: 'http_request',
202 snippet: command,
203 })
204 })
205
206 it('should return an HTTP request config with POST method and a body as JSON object', () => {
207 const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), body:='{"key": "value"}', timeout_milliseconds:=5000 );`
208 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
209 endpoint: 'https://example.com/api/endpoint',
210 method: 'POST',
211 httpHeaders: [],
212 httpBody: '{"key": "value"}',
213 timeoutMs: 5000,
214 type: 'http_request',
215 snippet: command,
216 })
217 })
218
219 it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body', () => {
220 const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"fst": "1", "snd": "2"}',body:='{"key": "value"}',timeout_milliseconds:=5000);`
221 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
222 endpoint: 'https://example.com/api/endpoint',
223 method: 'POST',
224 httpHeaders: [
225 { name: 'fst', value: '1' },
226 { name: 'snd', value: '2' },
227 ],
228 httpBody: '{"key": "value"}',
229 timeoutMs: 5000,
230 type: 'http_request',
231 snippet: command,
232 })
233 })
234
235 it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body with escaped SQL strings and ::jsonb typecasting', () => {
236 const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"X-Name":"O''Reilly"}'::jsonb,body:='{"message":"hello there","name":"O''Reilly"}'::jsonb,timeout_milliseconds:=5000);`
237 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
238 endpoint: 'https://example.com/api/endpoint',
239 method: 'POST',
240 httpHeaders: [{ name: 'X-Name', value: "O'Reilly" }],
241 httpBody: `{"message":"hello there","name":"O'Reilly"}`,
242 timeoutMs: 5000,
243 type: 'http_request',
244 snippet: command,
245 })
246 })
247
248 it('should return an HTTP request config with POST method, escape-string headers and body with backslashes', () => {
249 const command = String.raw`select net.http_post( url:='https://example.com/api/endpoint', headers:=E'{"Content-Type":"application/json","X-Regex":"^\\\\d+$"}'::jsonb,body:=E'{"path":"C:\\\\tmp","regex":"^\\\\d+$"}',timeout_milliseconds:=1000);`
250 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
251 endpoint: 'https://example.com/api/endpoint',
252 method: 'POST',
253 httpHeaders: [
254 { name: 'Content-Type', value: 'application/json' },
255 { name: 'X-Regex', value: String.raw`^\d+$` },
256 ],
257 httpBody: String.raw`{"path":"C:\\tmp","regex":"^\\d+$"}`,
258 timeoutMs: 1000,
259 type: 'http_request',
260 snippet: command,
261 })
262 })
263
264 it('should parse a POST body without swallowing later quoted arguments', () => {
265 const command = `select net.http_post( url:='https://example.com/api/endpoint', body:='{"payload":"ok"}'::jsonb, headers:='{"Authorization":"Bearer demo"}'::jsonb, timeout_milliseconds:=5000 );`
266 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
267 endpoint: 'https://example.com/api/endpoint',
268 method: 'POST',
269 httpHeaders: [{ name: 'Authorization', value: 'Bearer demo' }],
270 httpBody: '{"payload":"ok"}',
271 timeoutMs: 5000,
272 type: 'http_request',
273 snippet: command,
274 })
275 })
276
277 it('should return SQL snippet type if the command is an HTTP request that cannot be parsed properly due to positional notation', () => {
278 const command = `SELECT net.http_post( 'https://webhook.site/dacc2028-a588-462c-9597-c8968e61d0fa', '{"message":"Hello from Briven"}'::jsonb, '{}'::jsonb, '{"Content-Type":"application/json"}'::jsonb );`
279 expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
280 type: 'sql_snippet',
281 snippet: command,
282 })
283 })
284
285 // Array of test cases for secondsPattern
286 const secondsPatternTests = [
287 { description: '10 seconds', command: '10 seconds' },
288 { description: '30 seconds', command: '30 seconds' },
289 ]
290
291 // Run tests for secondsPattern
292 secondsPatternTests.forEach(({ description, command }) => {
293 it(`should match the regex for a secondsPattern with "${description}"`, () => {
294 expect(command).toMatch(secondsPattern)
295 })
296 })
297
298 // Array of test cases for cronPattern
299 const cronPatternTests = [
300 { description: 'every hour', command: '0 * * * *' },
301 { description: 'every day at midnight', command: '0 0 * * *' },
302 { description: 'every Monday at 9 AM', command: '0 9 * * 1' },
303 { description: 'every 15th of the month at noon', command: '0 12 15 * *' },
304 { description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
305 { description: 'every 30 minutes', command: '30 * * * *' },
306 { description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
307 { description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
308 { description: 'every quarter hour', command: '*/15 * * * *' },
309 { description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
310 { description: 'last day of every month at midnight (pg_cron $ syntax)', command: '0 0 $ * *' },
311 { description: 'last day of every month at noon (pg_cron $ syntax)', command: '0 12 $ * *' },
312 ]
313
314 const cronPatternRejectTests = [
315 { description: '$ in minute field', command: '$ * * * *' },
316 { description: '$ in hour field', command: '* $ * * *' },
317 { description: '$ in month field', command: '* * * $ *' },
318 { description: '$ in day-of-week field', command: '* * * * $' },
319 ]
320
321 cronPatternRejectTests.forEach(({ description, command }) => {
322 it(`should not match the regex for a cronPattern with "${description}"`, () => {
323 expect(command).not.toMatch(cronPattern)
324 })
325 })
326
327 // Replace the single cronPattern test with forEach
328 cronPatternTests.forEach(({ description, command }) => {
329 it(`should match the regex for a cronPattern with "${description}"`, () => {
330 expect(command).toMatch(cronPattern)
331 })
332 })
333})
334
335describe('formatCronJobColumns', () => {
336 it('enables resizing for informational columns and keeps utility columns fixed', () => {
337 const columns = formatCronJobColumns({
338 onSelectEdit: () => undefined,
339 onSelectDelete: () => undefined,
340 })
341
342 const columnsByKey = Object.fromEntries(columns.map((column) => [String(column.key), column]))
343
344 expect(columnsByKey.jobname.resizable).toBe(true)
345 expect(columnsByKey.jobname.minWidth).toBeGreaterThan(0)
346 expect(columnsByKey.schedule.resizable).toBe(true)
347 expect(columnsByKey.latest_run.resizable).toBe(true)
348 expect(columnsByKey.next_run.resizable).toBe(true)
349 expect(columnsByKey.command.resizable).toBe(true)
350 expect(columnsByKey.active.resizable).toBe(false)
351 expect(columnsByKey.actions.resizable).toBe(false)
352 })
353})