@aaronshaf/ger 0.3.2 → 0.3.3

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,712 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { searchCommand } from '@/cli/commands/search'
7
+ import { ConfigService } from '@/services/config'
8
+ import { generateMockChange } from '@/test-utils/mock-generator'
9
+ import type { ChangeInfo } from '@/schemas/gerrit'
10
+ import { createMockConfigService } from './helpers/config-mock'
11
+
12
+ // Create MSW server
13
+ const server = setupServer(
14
+ // Default handler for auth check
15
+ http.get('*/a/accounts/self', ({ request }) => {
16
+ const auth = request.headers.get('Authorization')
17
+ if (!auth || !auth.startsWith('Basic ')) {
18
+ return HttpResponse.text('Unauthorized', { status: 401 })
19
+ }
20
+ return HttpResponse.json({
21
+ _account_id: 1000,
22
+ name: 'Test User',
23
+ email: 'test@example.com',
24
+ })
25
+ }),
26
+ )
27
+
28
+ describe('search command', () => {
29
+ let mockConsoleLog: ReturnType<typeof mock>
30
+ let originalConsoleLog: typeof console.log
31
+
32
+ beforeAll(() => {
33
+ server.listen({ onUnhandledRequest: 'bypass' })
34
+ originalConsoleLog = console.log
35
+ })
36
+
37
+ afterAll(() => {
38
+ server.close()
39
+ })
40
+
41
+ beforeEach(() => {
42
+ mockConsoleLog = mock(() => {})
43
+ console.log = mockConsoleLog
44
+ })
45
+
46
+ afterEach(() => {
47
+ server.resetHandlers()
48
+ console.log = originalConsoleLog
49
+ })
50
+
51
+ it('should use default query "is:open" when no query provided', async () => {
52
+ const mockChanges: ChangeInfo[] = [
53
+ generateMockChange({
54
+ _number: 12345,
55
+ subject: 'Default query change',
56
+ project: 'test-project',
57
+ status: 'NEW',
58
+ }),
59
+ ]
60
+
61
+ server.use(
62
+ http.get('*/a/changes/', ({ request }) => {
63
+ const url = new URL(request.url)
64
+ const query = url.searchParams.get('q')
65
+ // Default query with limit
66
+ expect(query).toBe('is:open limit:25')
67
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
68
+ }),
69
+ )
70
+
71
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
72
+ await Effect.runPromise(
73
+ searchCommand(undefined, {}).pipe(
74
+ Effect.provide(GerritApiServiceLive),
75
+ Effect.provide(mockConfigLayer),
76
+ ),
77
+ )
78
+
79
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
80
+ expect(output).toContain('Default query change')
81
+ })
82
+
83
+ it('should pass custom query to Gerrit API', async () => {
84
+ const mockChanges: ChangeInfo[] = [
85
+ generateMockChange({
86
+ _number: 12345,
87
+ subject: "John's change",
88
+ project: 'canvas-lms',
89
+ status: 'NEW',
90
+ owner: {
91
+ _account_id: 2000,
92
+ name: 'John Doe',
93
+ email: 'john@example.com',
94
+ },
95
+ }),
96
+ ]
97
+
98
+ server.use(
99
+ http.get('*/a/changes/', ({ request }) => {
100
+ const url = new URL(request.url)
101
+ const query = url.searchParams.get('q')
102
+ expect(query).toBe('owner:john@example.com status:open limit:25')
103
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
104
+ }),
105
+ )
106
+
107
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
108
+ await Effect.runPromise(
109
+ searchCommand('owner:john@example.com status:open', {}).pipe(
110
+ Effect.provide(GerritApiServiceLive),
111
+ Effect.provide(mockConfigLayer),
112
+ ),
113
+ )
114
+
115
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
116
+ expect(output).toContain("John's change")
117
+ expect(output).toContain('by John Doe')
118
+ })
119
+
120
+ it('should respect --limit option', async () => {
121
+ const mockChanges: ChangeInfo[] = [
122
+ generateMockChange({
123
+ _number: 12345,
124
+ subject: 'Limited change',
125
+ project: 'test-project',
126
+ status: 'NEW',
127
+ }),
128
+ ]
129
+
130
+ server.use(
131
+ http.get('*/a/changes/', ({ request }) => {
132
+ const url = new URL(request.url)
133
+ const query = url.searchParams.get('q')
134
+ expect(query).toBe('is:open limit:10')
135
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
136
+ }),
137
+ )
138
+
139
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
140
+ await Effect.runPromise(
141
+ searchCommand(undefined, { limit: '10' }).pipe(
142
+ Effect.provide(GerritApiServiceLive),
143
+ Effect.provide(mockConfigLayer),
144
+ ),
145
+ )
146
+
147
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
148
+ expect(output).toContain('Limited change')
149
+ })
150
+
151
+ it('should not add limit if query already contains limit', async () => {
152
+ const mockChanges: ChangeInfo[] = [
153
+ generateMockChange({
154
+ _number: 12345,
155
+ subject: 'Custom limit change',
156
+ project: 'test-project',
157
+ status: 'NEW',
158
+ }),
159
+ ]
160
+
161
+ server.use(
162
+ http.get('*/a/changes/', ({ request }) => {
163
+ const url = new URL(request.url)
164
+ const query = url.searchParams.get('q')
165
+ // Should not add another limit
166
+ expect(query).toBe('is:open limit:5')
167
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
168
+ }),
169
+ )
170
+
171
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
172
+ await Effect.runPromise(
173
+ searchCommand('is:open limit:5', {}).pipe(
174
+ Effect.provide(GerritApiServiceLive),
175
+ Effect.provide(mockConfigLayer),
176
+ ),
177
+ )
178
+ })
179
+
180
+ it('should use default limit when --limit is non-numeric', async () => {
181
+ const mockChanges: ChangeInfo[] = [
182
+ generateMockChange({
183
+ _number: 12345,
184
+ subject: 'Invalid limit change',
185
+ project: 'test-project',
186
+ status: 'NEW',
187
+ }),
188
+ ]
189
+
190
+ server.use(
191
+ http.get('*/a/changes/', ({ request }) => {
192
+ const url = new URL(request.url)
193
+ const query = url.searchParams.get('q')
194
+ // Should fall back to default limit of 25
195
+ expect(query).toBe('is:open limit:25')
196
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
197
+ }),
198
+ )
199
+
200
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
201
+ await Effect.runPromise(
202
+ searchCommand(undefined, { limit: 'abc' }).pipe(
203
+ Effect.provide(GerritApiServiceLive),
204
+ Effect.provide(mockConfigLayer),
205
+ ),
206
+ )
207
+ })
208
+
209
+ it('should use default limit when --limit is negative', async () => {
210
+ const mockChanges: ChangeInfo[] = [
211
+ generateMockChange({
212
+ _number: 12345,
213
+ subject: 'Negative limit change',
214
+ project: 'test-project',
215
+ status: 'NEW',
216
+ }),
217
+ ]
218
+
219
+ server.use(
220
+ http.get('*/a/changes/', ({ request }) => {
221
+ const url = new URL(request.url)
222
+ const query = url.searchParams.get('q')
223
+ // Should fall back to default limit of 25
224
+ expect(query).toBe('is:open limit:25')
225
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
226
+ }),
227
+ )
228
+
229
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
230
+ await Effect.runPromise(
231
+ searchCommand(undefined, { limit: '-5' }).pipe(
232
+ Effect.provide(GerritApiServiceLive),
233
+ Effect.provide(mockConfigLayer),
234
+ ),
235
+ )
236
+ })
237
+
238
+ it('should display changes grouped by project', async () => {
239
+ const mockChanges: ChangeInfo[] = [
240
+ generateMockChange({
241
+ _number: 12345,
242
+ subject: 'Change in project A',
243
+ project: 'project-a',
244
+ status: 'NEW',
245
+ owner: { _account_id: 1, name: 'Alice' },
246
+ }),
247
+ generateMockChange({
248
+ _number: 12346,
249
+ subject: 'Change in project B',
250
+ project: 'project-b',
251
+ status: 'NEW',
252
+ owner: { _account_id: 2, name: 'Bob' },
253
+ }),
254
+ generateMockChange({
255
+ _number: 12347,
256
+ subject: 'Another change in project A',
257
+ project: 'project-a',
258
+ status: 'MERGED',
259
+ owner: { _account_id: 3, name: 'Charlie' },
260
+ }),
261
+ ]
262
+
263
+ server.use(
264
+ http.get('*/a/changes/', () => {
265
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
266
+ }),
267
+ )
268
+
269
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
270
+ await Effect.runPromise(
271
+ searchCommand('is:open', {}).pipe(
272
+ Effect.provide(GerritApiServiceLive),
273
+ Effect.provide(mockConfigLayer),
274
+ ),
275
+ )
276
+
277
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
278
+
279
+ // Verify project headers appear
280
+ expect(output).toContain('project-a')
281
+ expect(output).toContain('project-b')
282
+
283
+ // Verify changes are shown
284
+ expect(output).toContain('Change in project A')
285
+ expect(output).toContain('Change in project B')
286
+ expect(output).toContain('Another change in project A')
287
+
288
+ // Verify owners are shown
289
+ expect(output).toContain('by Alice')
290
+ expect(output).toContain('by Bob')
291
+ expect(output).toContain('by Charlie')
292
+
293
+ // Verify alphabetical ordering of projects
294
+ const projectAPos = output.indexOf('project-a')
295
+ const projectBPos = output.indexOf('project-b')
296
+ expect(projectAPos).toBeLessThan(projectBPos)
297
+ })
298
+
299
+ it('should output XML format when --xml flag is used', async () => {
300
+ const mockChanges: ChangeInfo[] = [
301
+ generateMockChange({
302
+ _number: 12345,
303
+ subject: 'XML test change',
304
+ project: 'test-project',
305
+ branch: 'main',
306
+ status: 'NEW',
307
+ owner: { _account_id: 1, name: 'Test User', email: 'test@example.com' },
308
+ updated: '2025-01-15 10:30:00.000000000',
309
+ }),
310
+ ]
311
+
312
+ server.use(
313
+ http.get('*/a/changes/', () => {
314
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
315
+ }),
316
+ )
317
+
318
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
319
+ await Effect.runPromise(
320
+ searchCommand('owner:self', { xml: true }).pipe(
321
+ Effect.provide(GerritApiServiceLive),
322
+ Effect.provide(mockConfigLayer),
323
+ ),
324
+ )
325
+
326
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
327
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
328
+ expect(output).toContain('<search_results>')
329
+ expect(output).toContain('<query><![CDATA[owner:self limit:25]]></query>')
330
+ expect(output).toContain('<count>1</count>')
331
+ expect(output).toContain('<changes>')
332
+ expect(output).toContain('<project name="test-project">')
333
+ expect(output).toContain('<change>')
334
+ expect(output).toContain('<number>12345</number>')
335
+ expect(output).toContain('<subject><![CDATA[XML test change]]></subject>')
336
+ expect(output).toContain('<status>NEW</status>')
337
+ expect(output).toContain('<owner>Test User</owner>')
338
+ expect(output).toContain('<branch>main</branch>')
339
+ expect(output).toContain('<owner_email>test@example.com</owner_email>')
340
+ expect(output).toContain('</change>')
341
+ expect(output).toContain('</project>')
342
+ expect(output).toContain('</changes>')
343
+ expect(output).toContain('</search_results>')
344
+ })
345
+
346
+ it('should respect --limit option with --xml flag', async () => {
347
+ const mockChanges: ChangeInfo[] = [
348
+ generateMockChange({
349
+ _number: 12345,
350
+ subject: 'Limited XML change',
351
+ project: 'test-project',
352
+ status: 'NEW',
353
+ owner: { _account_id: 1, name: 'Test User', email: 'test@example.com' },
354
+ }),
355
+ ]
356
+
357
+ server.use(
358
+ http.get('*/a/changes/', ({ request }) => {
359
+ const url = new URL(request.url)
360
+ const query = url.searchParams.get('q')
361
+ expect(query).toBe('owner:self limit:5')
362
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
363
+ }),
364
+ )
365
+
366
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
367
+ await Effect.runPromise(
368
+ searchCommand('owner:self', { xml: true, limit: '5' }).pipe(
369
+ Effect.provide(GerritApiServiceLive),
370
+ Effect.provide(mockConfigLayer),
371
+ ),
372
+ )
373
+
374
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
375
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
376
+ expect(output).toContain('<query><![CDATA[owner:self limit:5]]></query>')
377
+ expect(output).toContain('<number>12345</number>')
378
+ expect(output).toContain('<subject><![CDATA[Limited XML change]]></subject>')
379
+ })
380
+
381
+ it('should handle no results gracefully', async () => {
382
+ server.use(
383
+ http.get('*/a/changes/', () => {
384
+ return HttpResponse.text(")]}'\n[]")
385
+ }),
386
+ )
387
+
388
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
389
+ await Effect.runPromise(
390
+ searchCommand('owner:nonexistent@example.com', {}).pipe(
391
+ Effect.provide(GerritApiServiceLive),
392
+ Effect.provide(mockConfigLayer),
393
+ ),
394
+ )
395
+
396
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
397
+ expect(output).toContain('No changes found')
398
+ })
399
+
400
+ it('should handle no results in XML format', async () => {
401
+ server.use(
402
+ http.get('*/a/changes/', () => {
403
+ return HttpResponse.text(")]}'\n[]")
404
+ }),
405
+ )
406
+
407
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
408
+ await Effect.runPromise(
409
+ searchCommand('owner:nonexistent@example.com', { xml: true }).pipe(
410
+ Effect.provide(GerritApiServiceLive),
411
+ Effect.provide(mockConfigLayer),
412
+ ),
413
+ )
414
+
415
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
416
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
417
+ expect(output).toContain('<search_results>')
418
+ expect(output).toContain('<count>0</count>')
419
+ expect(output).toContain('</search_results>')
420
+ })
421
+
422
+ it('should handle network failures gracefully', async () => {
423
+ server.use(
424
+ http.get('*/a/changes/', () => {
425
+ return HttpResponse.text('Network error', { status: 500 })
426
+ }),
427
+ )
428
+
429
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
430
+ const result = await Effect.runPromise(
431
+ Effect.either(
432
+ searchCommand('is:open', {}).pipe(
433
+ Effect.provide(GerritApiServiceLive),
434
+ Effect.provide(mockConfigLayer),
435
+ ),
436
+ ),
437
+ )
438
+
439
+ expect(result._tag).toBe('Left')
440
+ })
441
+
442
+ it('should handle authentication failures', async () => {
443
+ server.use(
444
+ http.get('*/a/changes/', () => {
445
+ return HttpResponse.text('Unauthorized', { status: 401 })
446
+ }),
447
+ )
448
+
449
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
450
+ const result = await Effect.runPromise(
451
+ Effect.either(
452
+ searchCommand('is:open', {}).pipe(
453
+ Effect.provide(GerritApiServiceLive),
454
+ Effect.provide(mockConfigLayer),
455
+ ),
456
+ ),
457
+ )
458
+
459
+ expect(result._tag).toBe('Left')
460
+ })
461
+
462
+ it('should properly escape XML special characters', async () => {
463
+ const mockChanges: ChangeInfo[] = [
464
+ generateMockChange({
465
+ _number: 12345,
466
+ subject: 'Fix <script>alert("XSS")</script> & entities',
467
+ project: 'test<project>',
468
+ status: 'NEW',
469
+ owner: { _account_id: 1, name: 'User <>&"\'' },
470
+ }),
471
+ ]
472
+
473
+ server.use(
474
+ http.get('*/a/changes/', () => {
475
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
476
+ }),
477
+ )
478
+
479
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
480
+ await Effect.runPromise(
481
+ searchCommand('is:open', { xml: true }).pipe(
482
+ Effect.provide(GerritApiServiceLive),
483
+ Effect.provide(mockConfigLayer),
484
+ ),
485
+ )
486
+
487
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
488
+ // Subject should be in CDATA (special chars preserved)
489
+ expect(output).toContain(
490
+ '<subject><![CDATA[Fix <script>alert("XSS")</script> & entities]]></subject>',
491
+ )
492
+ // Project name attribute should be escaped
493
+ expect(output).toContain('<project name="test&lt;project&gt;">')
494
+ // Owner should be escaped (not CDATA)
495
+ expect(output).toContain('<owner>User &lt;&gt;&amp;&quot;&apos;</owner>')
496
+ })
497
+
498
+ it('should sanitize CDATA content with ]]> sequences', async () => {
499
+ const mockChanges: ChangeInfo[] = [
500
+ generateMockChange({
501
+ _number: 12345,
502
+ subject: 'Subject with ]]> CDATA breaker',
503
+ project: 'test-project',
504
+ status: 'NEW',
505
+ owner: { _account_id: 1, name: 'Test User' },
506
+ }),
507
+ ]
508
+
509
+ server.use(
510
+ http.get('*/a/changes/', () => {
511
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
512
+ }),
513
+ )
514
+
515
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
516
+ await Effect.runPromise(
517
+ searchCommand('is:open', { xml: true }).pipe(
518
+ Effect.provide(GerritApiServiceLive),
519
+ Effect.provide(mockConfigLayer),
520
+ ),
521
+ )
522
+
523
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
524
+ // ]]> should be escaped to ]]&gt; to prevent CDATA injection
525
+ expect(output).toContain('<subject><![CDATA[Subject with ]]&gt; CDATA breaker]]></subject>')
526
+ })
527
+
528
+ it('should display status indicators for changes with labels', async () => {
529
+ const mockChanges: ChangeInfo[] = [
530
+ generateMockChange({
531
+ _number: 12345,
532
+ subject: 'Approved change',
533
+ project: 'test-project',
534
+ status: 'NEW',
535
+ owner: { _account_id: 1, name: 'Test User' },
536
+ labels: {
537
+ 'Code-Review': {
538
+ approved: { _account_id: 2 },
539
+ value: 2,
540
+ },
541
+ },
542
+ }),
543
+ ]
544
+
545
+ server.use(
546
+ http.get('*/a/changes/', () => {
547
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
548
+ }),
549
+ )
550
+
551
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
552
+ await Effect.runPromise(
553
+ searchCommand('is:open', {}).pipe(
554
+ Effect.provide(GerritApiServiceLive),
555
+ Effect.provide(mockConfigLayer),
556
+ ),
557
+ )
558
+
559
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
560
+ // Should contain the checkmark indicator for approved
561
+ expect(output).toContain('✓')
562
+ expect(output).toContain('Approved change')
563
+ })
564
+
565
+ it('should not include owner_email when email is not present', async () => {
566
+ const mockChanges: ChangeInfo[] = [
567
+ generateMockChange({
568
+ _number: 12345,
569
+ subject: 'No email change',
570
+ project: 'test-project',
571
+ status: 'NEW',
572
+ owner: { _account_id: 1, name: 'Test User' }, // No email
573
+ }),
574
+ ]
575
+
576
+ server.use(
577
+ http.get('*/a/changes/', () => {
578
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
579
+ }),
580
+ )
581
+
582
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
583
+ await Effect.runPromise(
584
+ searchCommand('is:open', { xml: true }).pipe(
585
+ Effect.provide(GerritApiServiceLive),
586
+ Effect.provide(mockConfigLayer),
587
+ ),
588
+ )
589
+
590
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
591
+ expect(output).toContain('<owner>Test User</owner>')
592
+ expect(output).not.toContain('<owner_email>')
593
+ })
594
+
595
+ it('should not include updated when it is empty string', async () => {
596
+ const mockChanges: ChangeInfo[] = [
597
+ generateMockChange({
598
+ _number: 12345,
599
+ subject: 'Empty updated change',
600
+ project: 'test-project',
601
+ status: 'NEW',
602
+ owner: { _account_id: 1, name: 'Test User' },
603
+ updated: ' ', // Empty/whitespace
604
+ }),
605
+ ]
606
+
607
+ server.use(
608
+ http.get('*/a/changes/', () => {
609
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
610
+ }),
611
+ )
612
+
613
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
614
+ await Effect.runPromise(
615
+ searchCommand('is:open', { xml: true }).pipe(
616
+ Effect.provide(GerritApiServiceLive),
617
+ Effect.provide(mockConfigLayer),
618
+ ),
619
+ )
620
+
621
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
622
+ expect(output).toContain('<number>12345</number>')
623
+ expect(output).not.toContain('<updated>')
624
+ })
625
+
626
+ it('should display search results header with count', async () => {
627
+ const mockChanges: ChangeInfo[] = [
628
+ generateMockChange({ _number: 1, subject: 'Change 1', project: 'p1' }),
629
+ generateMockChange({ _number: 2, subject: 'Change 2', project: 'p2' }),
630
+ generateMockChange({ _number: 3, subject: 'Change 3', project: 'p3' }),
631
+ ]
632
+
633
+ server.use(
634
+ http.get('*/a/changes/', () => {
635
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
636
+ }),
637
+ )
638
+
639
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
640
+ await Effect.runPromise(
641
+ searchCommand('is:open', {}).pipe(
642
+ Effect.provide(GerritApiServiceLive),
643
+ Effect.provide(mockConfigLayer),
644
+ ),
645
+ )
646
+
647
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
648
+ expect(output).toContain('Search results (3)')
649
+ })
650
+ })
651
+
652
+ describe('search command CLI integration', () => {
653
+ it('should output XML error format when --xml flag is used and request fails', async () => {
654
+ // Use environment variables to configure an invalid host that will fail to connect
655
+ const proc = Bun.spawn(['bun', 'run', 'src/cli/index.ts', 'search', '--xml'], {
656
+ env: {
657
+ // Preserve PATH for bun to be found
658
+ PATH: process.env.PATH,
659
+ // Override with invalid host - connection will fail
660
+ GERRIT_HOST: 'http://localhost:59999',
661
+ GERRIT_USERNAME: 'test',
662
+ GERRIT_PASSWORD: 'test',
663
+ // Set HOME to temp dir to prevent reading real config
664
+ HOME: '/tmp',
665
+ },
666
+ stdout: 'pipe',
667
+ stderr: 'pipe',
668
+ })
669
+
670
+ const stdout = await new Response(proc.stdout).text()
671
+ const exitCode = await proc.exited
672
+
673
+ // Should exit with error code
674
+ expect(exitCode).toBe(1)
675
+
676
+ // Should output XML error format
677
+ expect(stdout).toContain('<?xml version="1.0" encoding="UTF-8"?>')
678
+ expect(stdout).toContain('<search_result>')
679
+ expect(stdout).toContain('<status>error</status>')
680
+ expect(stdout).toContain('<error><![CDATA[')
681
+ expect(stdout).toContain(']]></error>')
682
+ expect(stdout).toContain('</search_result>')
683
+ })
684
+
685
+ it('should output plain error format when request fails without --xml', async () => {
686
+ // Use environment variables to configure an invalid host that will fail to connect
687
+ const proc = Bun.spawn(['bun', 'run', 'src/cli/index.ts', 'search'], {
688
+ env: {
689
+ // Preserve PATH for bun to be found
690
+ PATH: process.env.PATH,
691
+ // Override with invalid host - connection will fail
692
+ GERRIT_HOST: 'http://localhost:59999',
693
+ GERRIT_USERNAME: 'test',
694
+ GERRIT_PASSWORD: 'test',
695
+ // Set HOME to temp dir to prevent reading real config
696
+ HOME: '/tmp',
697
+ },
698
+ stdout: 'pipe',
699
+ stderr: 'pipe',
700
+ })
701
+
702
+ const stderr = await new Response(proc.stderr).text()
703
+ const exitCode = await proc.exited
704
+
705
+ // Should exit with error code
706
+ expect(exitCode).toBe(1)
707
+
708
+ // Should output plain error (not XML)
709
+ expect(stderr).toContain('✗ Error:')
710
+ expect(stderr).not.toContain('<?xml')
711
+ })
712
+ })