@aaronshaf/ger 0.1.11 → 0.2.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.
@@ -0,0 +1,518 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'
2
+ import { setupServer } from 'msw/node'
3
+ import { http, HttpResponse } from 'msw'
4
+ import { Effect, Layer } from 'effect'
5
+ import { extractUrlCommand } from '@/cli/commands/extract-url'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import type { MessageInfo } from '@/schemas/gerrit'
9
+
10
+ import { createMockConfigService } from './helpers/config-mock'
11
+
12
+ const server = setupServer(
13
+ // Default handler for auth check
14
+ http.get('*/a/accounts/self', ({ request }) => {
15
+ const auth = request.headers.get('Authorization')
16
+ if (!auth || !auth.startsWith('Basic ')) {
17
+ return HttpResponse.text('Unauthorized', { status: 401 })
18
+ }
19
+ return HttpResponse.json({
20
+ _account_id: 1000,
21
+ name: 'Test User',
22
+ email: 'test@example.com',
23
+ })
24
+ }),
25
+ )
26
+
27
+ // Store captured output
28
+ let capturedLogs: string[] = []
29
+ let capturedErrors: string[] = []
30
+
31
+ // Mock console.log and console.error
32
+ const mockConsoleLog = mock((...args: any[]) => {
33
+ capturedLogs.push(args.join(' '))
34
+ })
35
+ const mockConsoleError = mock((...args: any[]) => {
36
+ capturedErrors.push(args.join(' '))
37
+ })
38
+
39
+ // Store original console methods
40
+ const originalConsoleLog = console.log
41
+ const originalConsoleError = console.error
42
+
43
+ beforeAll(() => {
44
+ server.listen({ onUnhandledRequest: 'bypass' })
45
+ // @ts-ignore
46
+ console.log = mockConsoleLog
47
+ // @ts-ignore
48
+ console.error = mockConsoleError
49
+ })
50
+
51
+ afterAll(() => {
52
+ server.close()
53
+ console.log = originalConsoleLog
54
+ console.error = originalConsoleError
55
+ })
56
+
57
+ afterEach(() => {
58
+ server.resetHandlers()
59
+ mockConsoleLog.mockClear()
60
+ mockConsoleError.mockClear()
61
+ capturedLogs = []
62
+ capturedErrors = []
63
+ })
64
+
65
+ describe('extract-url command', () => {
66
+ const mockComments = {
67
+ 'src/main.js': [
68
+ {
69
+ id: 'comment1',
70
+ path: 'src/main.js',
71
+ line: 10,
72
+ message: 'Check this out: https://github.com/example/repo/pull/123',
73
+ author: {
74
+ name: 'Alice',
75
+ email: 'alice@example.com',
76
+ },
77
+ updated: '2025-01-15 10:00:00.000000000',
78
+ unresolved: false,
79
+ },
80
+ ],
81
+ '/COMMIT_MSG': [
82
+ {
83
+ id: 'comment2',
84
+ path: '/COMMIT_MSG',
85
+ line: 1,
86
+ message: 'See https://docs.example.com/guide',
87
+ author: {
88
+ name: 'Bob',
89
+ email: 'bob@example.com',
90
+ },
91
+ updated: '2025-01-15 11:00:00.000000000',
92
+ unresolved: false,
93
+ },
94
+ ],
95
+ }
96
+
97
+ const mockMessages: MessageInfo[] = [
98
+ {
99
+ id: 'msg1',
100
+ message:
101
+ 'Patch Set 1:\n\nBuild Started https://jenkins.inst-ci.net/job/Canvas/job/main/154074/',
102
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
103
+ date: '2025-01-15 09:00:00.000000000',
104
+ _revision_number: 1,
105
+ },
106
+ {
107
+ id: 'msg2',
108
+ message:
109
+ 'Patch Set 1: Verified-1\n\nBuild Failed \n\nhttps://jenkins.inst-ci.net/job/Canvas/job/main/154074//build-summary-report/ : FAILURE',
110
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
111
+ date: '2025-01-15 09:15:00.000000000',
112
+ _revision_number: 1,
113
+ },
114
+ {
115
+ id: 'msg3',
116
+ message:
117
+ 'Patch Set 2:\n\nBuild Started https://jenkins.inst-ci.net/job/Canvas/job/main/156340/',
118
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
119
+ date: '2025-01-15 10:00:00.000000000',
120
+ _revision_number: 2,
121
+ },
122
+ {
123
+ id: 'msg4',
124
+ message:
125
+ 'Patch Set 2: Verified-1\n\nBuild Failed \n\nhttps://jenkins.inst-ci.net/job/Canvas/job/main/156340//build-summary-report/ : FAILURE',
126
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
127
+ date: '2025-01-15 10:15:00.000000000',
128
+ _revision_number: 2,
129
+ },
130
+ ]
131
+
132
+ const setupMockHandlers = (
133
+ comments: Record<string, any> = mockComments,
134
+ messages: MessageInfo[] = mockMessages,
135
+ ) => {
136
+ server.use(
137
+ // Get comments
138
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
139
+ return HttpResponse.text(`)]}'\n${JSON.stringify(comments)}`)
140
+ }),
141
+ // Get messages
142
+ http.get('*/a/changes/:changeId', ({ request }) => {
143
+ const url = new URL(request.url)
144
+ if (url.searchParams.get('o') === 'MESSAGES') {
145
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages })}`)
146
+ }
147
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: [] })}`)
148
+ }),
149
+ )
150
+ }
151
+
152
+ const createMockConfigLayer = () => Layer.succeed(ConfigService, createMockConfigService())
153
+
154
+ test('should extract URLs matching substring pattern from messages', async () => {
155
+ setupMockHandlers()
156
+
157
+ const mockConfigLayer = createMockConfigLayer()
158
+ const program = extractUrlCommand('jenkins', '12345', {}).pipe(
159
+ Effect.provide(GerritApiServiceLive),
160
+ Effect.provide(mockConfigLayer),
161
+ )
162
+
163
+ await Effect.runPromise(program)
164
+
165
+ const output = capturedLogs.join('\n')
166
+
167
+ // Should contain all Jenkins URLs in chronological order
168
+ expect(output).toContain('https://jenkins.inst-ci.net/job/Canvas/job/main/154074/')
169
+ expect(output).toContain(
170
+ 'https://jenkins.inst-ci.net/job/Canvas/job/main/154074//build-summary-report/',
171
+ )
172
+ expect(output).toContain('https://jenkins.inst-ci.net/job/Canvas/job/main/156340/')
173
+ expect(output).toContain(
174
+ 'https://jenkins.inst-ci.net/job/Canvas/job/main/156340//build-summary-report/',
175
+ )
176
+
177
+ // Check order - should be chronological (oldest first)
178
+ const lines = output.split('\n').filter((line) => line.includes('jenkins'))
179
+ expect(lines[0]).toContain('154074')
180
+ expect(lines[lines.length - 1]).toContain('156340')
181
+ })
182
+
183
+ test('should extract URLs matching build-summary-report pattern', async () => {
184
+ setupMockHandlers()
185
+
186
+ const mockConfigLayer = createMockConfigLayer()
187
+ const program = extractUrlCommand('build-summary-report', '12345', {}).pipe(
188
+ Effect.provide(GerritApiServiceLive),
189
+ Effect.provide(mockConfigLayer),
190
+ )
191
+
192
+ await Effect.runPromise(program)
193
+
194
+ const output = capturedLogs.join('\n')
195
+ const lines = output.split('\n').filter((line) => line.trim())
196
+
197
+ // Should only contain build-summary-report URLs
198
+ expect(lines.length).toBe(2)
199
+ expect(lines[0]).toContain('154074//build-summary-report/')
200
+ expect(lines[1]).toContain('156340//build-summary-report/')
201
+ })
202
+
203
+ test('should support regex pattern matching', async () => {
204
+ setupMockHandlers()
205
+
206
+ const mockConfigLayer = createMockConfigLayer()
207
+ const program = extractUrlCommand('job/Canvas/job/main/\\d+/$', '12345', { regex: true }).pipe(
208
+ Effect.provide(GerritApiServiceLive),
209
+ Effect.provide(mockConfigLayer),
210
+ )
211
+
212
+ await Effect.runPromise(program)
213
+
214
+ const output = capturedLogs.join('\n')
215
+ const lines = output.split('\n').filter((line) => line.trim())
216
+
217
+ // Should only match URLs ending with job number (not build-summary-report)
218
+ expect(lines.length).toBe(2)
219
+ expect(lines[0]).toBe('https://jenkins.inst-ci.net/job/Canvas/job/main/154074/')
220
+ expect(lines[1]).toBe('https://jenkins.inst-ci.net/job/Canvas/job/main/156340/')
221
+ })
222
+
223
+ test('should include URLs from comments when --include-comments is used', async () => {
224
+ setupMockHandlers()
225
+
226
+ const mockConfigLayer = createMockConfigLayer()
227
+ const program = extractUrlCommand('github', '12345', { includeComments: true }).pipe(
228
+ Effect.provide(GerritApiServiceLive),
229
+ Effect.provide(mockConfigLayer),
230
+ )
231
+
232
+ await Effect.runPromise(program)
233
+
234
+ const output = capturedLogs.join('\n')
235
+
236
+ // Should contain URL from comment
237
+ expect(output).toContain('https://github.com/example/repo/pull/123')
238
+ })
239
+
240
+ test('should not include comment URLs by default', async () => {
241
+ setupMockHandlers()
242
+
243
+ const mockConfigLayer = createMockConfigLayer()
244
+ const program = extractUrlCommand('github', '12345', {}).pipe(
245
+ Effect.provide(GerritApiServiceLive),
246
+ Effect.provide(mockConfigLayer),
247
+ )
248
+
249
+ await Effect.runPromise(program)
250
+
251
+ const output = capturedLogs.join('\n')
252
+
253
+ // Should not contain URL from comment
254
+ expect(output).not.toContain('https://github.com/example/repo/pull/123')
255
+ })
256
+
257
+ test('should output JSON format when --json flag is used', async () => {
258
+ setupMockHandlers()
259
+
260
+ const mockConfigLayer = createMockConfigLayer()
261
+ const program = extractUrlCommand('build-summary-report', '12345', { json: true }).pipe(
262
+ Effect.provide(GerritApiServiceLive),
263
+ Effect.provide(mockConfigLayer),
264
+ )
265
+
266
+ await Effect.runPromise(program)
267
+
268
+ const output = capturedLogs.join('\n')
269
+ const parsed = JSON.parse(output)
270
+
271
+ expect(parsed.status).toBe('success')
272
+ expect(Array.isArray(parsed.urls)).toBe(true)
273
+ expect(parsed.urls.length).toBe(2)
274
+ expect(parsed.urls[0]).toContain('154074//build-summary-report/')
275
+ expect(parsed.urls[1]).toContain('156340//build-summary-report/')
276
+ })
277
+
278
+ test('should output XML format when --xml flag is used', async () => {
279
+ setupMockHandlers()
280
+
281
+ const mockConfigLayer = createMockConfigLayer()
282
+ const program = extractUrlCommand('build-summary-report', '12345', { xml: true }).pipe(
283
+ Effect.provide(GerritApiServiceLive),
284
+ Effect.provide(mockConfigLayer),
285
+ )
286
+
287
+ await Effect.runPromise(program)
288
+
289
+ const output = capturedLogs.join('\n')
290
+
291
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
292
+ expect(output).toContain('<extract_url_result>')
293
+ expect(output).toContain('<status>success</status>')
294
+ expect(output).toContain('<urls>')
295
+ expect(output).toContain('<count>2</count>')
296
+ expect(output).toContain('154074//build-summary-report/')
297
+ expect(output).toContain('156340//build-summary-report/')
298
+ expect(output).toContain('</urls>')
299
+ expect(output).toContain('</extract_url_result>')
300
+ })
301
+
302
+ test('should handle no matching URLs gracefully', async () => {
303
+ setupMockHandlers()
304
+
305
+ const mockConfigLayer = createMockConfigLayer()
306
+ const program = extractUrlCommand('nonexistent-pattern', '12345', {}).pipe(
307
+ Effect.provide(GerritApiServiceLive),
308
+ Effect.provide(mockConfigLayer),
309
+ )
310
+
311
+ await Effect.runPromise(program)
312
+
313
+ const output = capturedLogs.join('\n')
314
+
315
+ // Should output nothing (empty list)
316
+ expect(output.trim()).toBe('')
317
+ })
318
+
319
+ test('should handle no matching URLs in JSON format', async () => {
320
+ setupMockHandlers()
321
+
322
+ const mockConfigLayer = createMockConfigLayer()
323
+ const program = extractUrlCommand('nonexistent-pattern', '12345', { json: true }).pipe(
324
+ Effect.provide(GerritApiServiceLive),
325
+ Effect.provide(mockConfigLayer),
326
+ )
327
+
328
+ await Effect.runPromise(program)
329
+
330
+ const output = capturedLogs.join('\n')
331
+ const parsed = JSON.parse(output)
332
+
333
+ expect(parsed.status).toBe('success')
334
+ expect(parsed.urls).toEqual([])
335
+ })
336
+
337
+ test('should handle API errors gracefully', async () => {
338
+ server.use(
339
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
340
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 })
341
+ }),
342
+ )
343
+
344
+ const mockConfigLayer = createMockConfigLayer()
345
+ const program = extractUrlCommand('jenkins', '12345', {}).pipe(
346
+ Effect.provide(GerritApiServiceLive),
347
+ Effect.provide(mockConfigLayer),
348
+ )
349
+
350
+ await Effect.runPromise(program)
351
+
352
+ const output = capturedErrors.join('\n')
353
+ expect(output).toContain('✗ Error:')
354
+ })
355
+
356
+ test('should handle API errors gracefully in JSON format', async () => {
357
+ server.use(
358
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
359
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 })
360
+ }),
361
+ )
362
+
363
+ const mockConfigLayer = createMockConfigLayer()
364
+ const program = extractUrlCommand('jenkins', '12345', { json: true }).pipe(
365
+ Effect.provide(GerritApiServiceLive),
366
+ Effect.provide(mockConfigLayer),
367
+ )
368
+
369
+ await Effect.runPromise(program)
370
+
371
+ const output = capturedLogs.join('\n')
372
+ const parsed = JSON.parse(output)
373
+
374
+ expect(parsed.status).toBe('error')
375
+ expect(parsed.error).toBeDefined()
376
+ })
377
+
378
+ test('should handle API errors gracefully in XML format', async () => {
379
+ server.use(
380
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
381
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 })
382
+ }),
383
+ )
384
+
385
+ const mockConfigLayer = createMockConfigLayer()
386
+ const program = extractUrlCommand('jenkins', '12345', { xml: true }).pipe(
387
+ Effect.provide(GerritApiServiceLive),
388
+ Effect.provide(mockConfigLayer),
389
+ )
390
+
391
+ await Effect.runPromise(program)
392
+
393
+ const output = capturedLogs.join('\n')
394
+
395
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
396
+ expect(output).toContain('<extract_url_result>')
397
+ expect(output).toContain('<status>error</status>')
398
+ expect(output).toContain('<error><![CDATA[')
399
+ expect(output).toContain('</extract_url_result>')
400
+ })
401
+
402
+ test('should handle case-insensitive substring matching', async () => {
403
+ setupMockHandlers()
404
+
405
+ const mockConfigLayer = createMockConfigLayer()
406
+ const program = extractUrlCommand('JENKINS', '12345', {}).pipe(
407
+ Effect.provide(GerritApiServiceLive),
408
+ Effect.provide(mockConfigLayer),
409
+ )
410
+
411
+ await Effect.runPromise(program)
412
+
413
+ const output = capturedLogs.join('\n')
414
+
415
+ // Should match jenkins URLs (case-insensitive)
416
+ expect(output).toContain('https://jenkins.inst-ci.net')
417
+ })
418
+
419
+ test('should extract multiple different URLs from same message', async () => {
420
+ const messagesWithMultipleUrls: MessageInfo[] = [
421
+ {
422
+ id: 'msg1',
423
+ message:
424
+ 'See https://docs.example.com/guide and also https://github.com/example/repo for more info',
425
+ author: { _account_id: 1001, name: 'User' },
426
+ date: '2025-01-15 09:00:00.000000000',
427
+ _revision_number: 1,
428
+ },
429
+ ]
430
+
431
+ // Setup with no comments, only messages
432
+ const emptyComments: Record<string, never> = {}
433
+ setupMockHandlers(emptyComments, messagesWithMultipleUrls)
434
+
435
+ const mockConfigLayer = createMockConfigLayer()
436
+ const program = extractUrlCommand('https', '12345', {}).pipe(
437
+ Effect.provide(GerritApiServiceLive),
438
+ Effect.provide(mockConfigLayer),
439
+ )
440
+
441
+ await Effect.runPromise(program)
442
+
443
+ const output = capturedLogs.join('\n')
444
+ const lines = output.split('\n').filter((line) => line.trim())
445
+
446
+ expect(lines.length).toBe(2)
447
+ expect(output).toContain('https://docs.example.com/guide')
448
+ expect(output).toContain('https://github.com/example/repo')
449
+ })
450
+
451
+ test('should reject dangerous regex patterns (ReDoS protection)', async () => {
452
+ setupMockHandlers()
453
+
454
+ const mockConfigLayer = createMockConfigLayer()
455
+ // Use a pattern with nested quantifiers that could cause ReDoS
456
+ const program = extractUrlCommand('(a+)+', '12345', { regex: true }).pipe(
457
+ Effect.provide(GerritApiServiceLive),
458
+ Effect.provide(mockConfigLayer),
459
+ )
460
+
461
+ await Effect.runPromise(program)
462
+
463
+ const output = capturedErrors.join('\n')
464
+ expect(output).toContain('✗ Error:')
465
+ expect(output).toContain('dangerous nested quantifiers')
466
+ })
467
+
468
+ test('should handle invalid regex syntax gracefully', async () => {
469
+ setupMockHandlers()
470
+
471
+ const mockConfigLayer = createMockConfigLayer()
472
+ // Use invalid regex syntax
473
+ const program = extractUrlCommand('[invalid', '12345', { regex: true }).pipe(
474
+ Effect.provide(GerritApiServiceLive),
475
+ Effect.provide(mockConfigLayer),
476
+ )
477
+
478
+ await Effect.runPromise(program)
479
+
480
+ const output = capturedErrors.join('\n')
481
+ expect(output).toContain('✗ Error:')
482
+ expect(output).toContain('Invalid regular expression')
483
+ })
484
+
485
+ test('should validate pattern is not empty', async () => {
486
+ setupMockHandlers()
487
+
488
+ const mockConfigLayer = createMockConfigLayer()
489
+ const program = extractUrlCommand('', '12345', {}).pipe(
490
+ Effect.provide(GerritApiServiceLive),
491
+ Effect.provide(mockConfigLayer),
492
+ )
493
+
494
+ await Effect.runPromise(program)
495
+
496
+ const output = capturedErrors.join('\n')
497
+ expect(output).toContain('✗ Error:')
498
+ expect(output).toContain('Pattern cannot be empty')
499
+ })
500
+
501
+ test('should validate pattern is not too long', async () => {
502
+ setupMockHandlers()
503
+
504
+ const mockConfigLayer = createMockConfigLayer()
505
+ // Create a pattern longer than 500 characters
506
+ const longPattern = 'a'.repeat(501)
507
+ const program = extractUrlCommand(longPattern, '12345', {}).pipe(
508
+ Effect.provide(GerritApiServiceLive),
509
+ Effect.provide(mockConfigLayer),
510
+ )
511
+
512
+ await Effect.runPromise(program)
513
+
514
+ const output = capturedErrors.join('\n')
515
+ expect(output).toContain('✗ Error:')
516
+ expect(output).toContain('Pattern is too long')
517
+ })
518
+ })
@@ -37,8 +37,11 @@ const mockAccount = generateMockAccount()
37
37
  // requirements: [],
38
38
  // }
39
39
 
40
- export const setupFetchMock = () => {
41
- return mock(async (url: string | URL, options?: RequestInit) => {
40
+ export const setupFetchMock = (): ((
41
+ url: string | URL,
42
+ options?: RequestInit,
43
+ ) => Promise<Response>) => {
44
+ return mock(async (url: string | URL, options?: RequestInit): Promise<Response> => {
42
45
  const urlStr = url.toString()
43
46
  const method = options?.method || 'GET'
44
47
 
@@ -1,7 +1,7 @@
1
- import { HttpResponse, http } from 'msw'
1
+ import { HttpResponse, http, type HttpHandler } from 'msw'
2
2
  import type { CommentInfo } from '@/schemas/gerrit'
3
3
 
4
- export const commentHandlers = [
4
+ export const commentHandlers: HttpHandler[] = [
5
5
  // Comments endpoint
6
6
  http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
7
7
  const mockComments: Record<string, CommentInfo[]> = {
@@ -73,7 +73,7 @@ export const commentHandlers = [
73
73
  }),
74
74
  ]
75
75
 
76
- export const emptyCommentsHandlers = [
76
+ export const emptyCommentsHandlers: HttpHandler[] = [
77
77
  http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
78
78
  return HttpResponse.text(`)]}'\n{}`)
79
79
  }),