@aaronshaf/ger 2.0.0 → 2.0.2

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,443 @@
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 { topicCommand } from '@/cli/commands/topic'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ // Create MSW server
11
+ const server = setupServer(
12
+ // Default handler for auth check
13
+ http.get('*/a/accounts/self', ({ request }) => {
14
+ const auth = request.headers.get('Authorization')
15
+ if (!auth || !auth.startsWith('Basic ')) {
16
+ return HttpResponse.text('Unauthorized', { status: 401 })
17
+ }
18
+ return HttpResponse.json({
19
+ _account_id: 1000,
20
+ name: 'Test User',
21
+ email: 'test@example.com',
22
+ })
23
+ }),
24
+ )
25
+
26
+ describe('topic command', () => {
27
+ let mockConsoleLog: ReturnType<typeof mock>
28
+ let mockConsoleError: ReturnType<typeof mock>
29
+
30
+ beforeAll(() => {
31
+ server.listen({ onUnhandledRequest: 'bypass' })
32
+ })
33
+
34
+ afterAll(() => {
35
+ server.close()
36
+ })
37
+
38
+ beforeEach(() => {
39
+ mockConsoleLog = mock(() => {})
40
+ mockConsoleError = mock(() => {})
41
+ console.log = mockConsoleLog
42
+ console.error = mockConsoleError
43
+ })
44
+
45
+ afterEach(() => {
46
+ server.resetHandlers()
47
+ })
48
+
49
+ describe('get topic', () => {
50
+ it('should get topic when one is set', async () => {
51
+ server.use(
52
+ http.get('*/a/changes/12345/topic', () => {
53
+ return HttpResponse.text(`)]}'\n"my-feature"`)
54
+ }),
55
+ )
56
+
57
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
58
+ const program = topicCommand('12345', undefined, {}).pipe(
59
+ Effect.provide(GerritApiServiceLive),
60
+ Effect.provide(mockConfigLayer),
61
+ )
62
+
63
+ await Effect.runPromise(program)
64
+
65
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
66
+ expect(output).toBe('my-feature')
67
+ })
68
+
69
+ it('should show message when no topic is set', async () => {
70
+ server.use(
71
+ http.get('*/a/changes/12345/topic', () => {
72
+ return HttpResponse.text(`)]}'\n""`)
73
+ }),
74
+ )
75
+
76
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
77
+ const program = topicCommand('12345', undefined, {}).pipe(
78
+ Effect.provide(GerritApiServiceLive),
79
+ Effect.provide(mockConfigLayer),
80
+ )
81
+
82
+ await Effect.runPromise(program)
83
+
84
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
85
+ expect(output).toContain('No topic set')
86
+ })
87
+
88
+ it('should output XML format for get topic', async () => {
89
+ server.use(
90
+ http.get('*/a/changes/12345/topic', () => {
91
+ return HttpResponse.text(`)]}'\n"my-feature"`)
92
+ }),
93
+ )
94
+
95
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
96
+ const program = topicCommand('12345', undefined, { xml: true }).pipe(
97
+ Effect.provide(GerritApiServiceLive),
98
+ Effect.provide(mockConfigLayer),
99
+ )
100
+
101
+ await Effect.runPromise(program)
102
+
103
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
104
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
105
+ expect(output).toContain('<topic_result>')
106
+ expect(output).toContain('<status>success</status>')
107
+ expect(output).toContain('<action>get</action>')
108
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
109
+ expect(output).toContain('<topic><![CDATA[my-feature]]></topic>')
110
+ expect(output).toContain('</topic_result>')
111
+ })
112
+
113
+ it('should output XML format when no topic is set', async () => {
114
+ server.use(
115
+ http.get('*/a/changes/12345/topic', () => {
116
+ return HttpResponse.text(`)]}'\n""`)
117
+ }),
118
+ )
119
+
120
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
121
+ const program = topicCommand('12345', undefined, { xml: true }).pipe(
122
+ Effect.provide(GerritApiServiceLive),
123
+ Effect.provide(mockConfigLayer),
124
+ )
125
+
126
+ await Effect.runPromise(program)
127
+
128
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
129
+ expect(output).toContain('<topic />')
130
+ })
131
+ })
132
+
133
+ describe('set topic', () => {
134
+ it('should set topic on a change', async () => {
135
+ server.use(
136
+ http.put('*/a/changes/12345/topic', async ({ request }) => {
137
+ const body = await request.json()
138
+ expect(body).toEqual({ topic: 'my-feature' })
139
+ return HttpResponse.text(`)]}'\n"my-feature"`)
140
+ }),
141
+ )
142
+
143
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
144
+ const program = topicCommand('12345', 'my-feature', {}).pipe(
145
+ Effect.provide(GerritApiServiceLive),
146
+ Effect.provide(mockConfigLayer),
147
+ )
148
+
149
+ await Effect.runPromise(program)
150
+
151
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
152
+ expect(output).toContain('Set topic on change 12345: my-feature')
153
+ })
154
+
155
+ it('should output XML format for set topic', async () => {
156
+ server.use(
157
+ http.put('*/a/changes/12345/topic', () => {
158
+ return HttpResponse.text(`)]}'\n"release-v2"`)
159
+ }),
160
+ )
161
+
162
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
163
+ const program = topicCommand('12345', 'release-v2', { xml: true }).pipe(
164
+ Effect.provide(GerritApiServiceLive),
165
+ Effect.provide(mockConfigLayer),
166
+ )
167
+
168
+ await Effect.runPromise(program)
169
+
170
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
171
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
172
+ expect(output).toContain('<topic_result>')
173
+ expect(output).toContain('<status>success</status>')
174
+ expect(output).toContain('<action>set</action>')
175
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
176
+ expect(output).toContain('<topic><![CDATA[release-v2]]></topic>')
177
+ expect(output).toContain('</topic_result>')
178
+ })
179
+
180
+ it('should handle topic with special XML characters', async () => {
181
+ // Use a topic with XML special chars but no quotes to avoid JSON parsing issues
182
+ const specialTopic = '<script>alert(1)</script>'
183
+ server.use(
184
+ http.put('*/a/changes/12345/topic', () => {
185
+ // The server echoes the topic back as a quoted JSON string
186
+ return HttpResponse.text(`)]}'\n"<script>alert(1)</script>"`)
187
+ }),
188
+ )
189
+
190
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
191
+ const program = topicCommand('12345', specialTopic, { xml: true }).pipe(
192
+ Effect.provide(GerritApiServiceLive),
193
+ Effect.provide(mockConfigLayer),
194
+ )
195
+
196
+ await Effect.runPromise(program)
197
+
198
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
199
+ // Should be properly wrapped in CDATA
200
+ expect(output).toContain('<topic><![CDATA[')
201
+ expect(output).toContain('<script>alert(1)</script>')
202
+ expect(output).toContain('</topic_result>')
203
+ })
204
+
205
+ it('should handle topic containing CDATA end sequence', async () => {
206
+ const cdataEndTopic = 'my-feature]]>injection'
207
+ server.use(
208
+ http.put('*/a/changes/12345/topic', () => {
209
+ // Server returns the topic with the CDATA end sequence
210
+ return HttpResponse.text(`)]}'\n"my-feature]]>injection"`)
211
+ }),
212
+ )
213
+
214
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
215
+ const program = topicCommand('12345', cdataEndTopic, { xml: true }).pipe(
216
+ Effect.provide(GerritApiServiceLive),
217
+ Effect.provide(mockConfigLayer),
218
+ )
219
+
220
+ await Effect.runPromise(program)
221
+
222
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
223
+ // The CDATA end sequence should be sanitized by replacing ]]> with ]]&gt;
224
+ expect(output).toContain('<status>success</status>')
225
+ // sanitizeCDATA replaces ]]> with ]]&gt; to prevent CDATA injection
226
+ expect(output).toContain(']]&gt;')
227
+ // The raw ]]> in the topic content should NOT appear (only the escaped version)
228
+ expect(output).not.toContain('my-feature]]>injection')
229
+ })
230
+ })
231
+
232
+ describe('delete topic', () => {
233
+ it('should delete topic from a change', async () => {
234
+ server.use(
235
+ http.delete('*/a/changes/12345/topic', () => {
236
+ return new HttpResponse(null, { status: 204 })
237
+ }),
238
+ )
239
+
240
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
241
+ const program = topicCommand('12345', undefined, { delete: true }).pipe(
242
+ Effect.provide(GerritApiServiceLive),
243
+ Effect.provide(mockConfigLayer),
244
+ )
245
+
246
+ await Effect.runPromise(program)
247
+
248
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
249
+ expect(output).toContain('Removed topic from change 12345')
250
+ })
251
+
252
+ it('should output XML format for delete topic', async () => {
253
+ server.use(
254
+ http.delete('*/a/changes/12345/topic', () => {
255
+ return new HttpResponse(null, { status: 204 })
256
+ }),
257
+ )
258
+
259
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
260
+ const program = topicCommand('12345', undefined, { delete: true, xml: true }).pipe(
261
+ Effect.provide(GerritApiServiceLive),
262
+ Effect.provide(mockConfigLayer),
263
+ )
264
+
265
+ await Effect.runPromise(program)
266
+
267
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
268
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
269
+ expect(output).toContain('<topic_result>')
270
+ expect(output).toContain('<status>success</status>')
271
+ expect(output).toContain('<action>deleted</action>')
272
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
273
+ expect(output).toContain('</topic_result>')
274
+ })
275
+
276
+ it('should handle deleting non-existent topic', async () => {
277
+ // Gerrit returns 204 even if there was no topic
278
+ server.use(
279
+ http.delete('*/a/changes/12345/topic', () => {
280
+ return new HttpResponse(null, { status: 204 })
281
+ }),
282
+ )
283
+
284
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
285
+ const program = topicCommand('12345', undefined, { delete: true }).pipe(
286
+ Effect.provide(GerritApiServiceLive),
287
+ Effect.provide(mockConfigLayer),
288
+ )
289
+
290
+ await Effect.runPromise(program)
291
+
292
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
293
+ expect(output).toContain('Removed topic from change 12345')
294
+ })
295
+
296
+ it('should prioritize --delete over topic argument', async () => {
297
+ // When both --delete and topic are provided, delete should take precedence
298
+ server.use(
299
+ http.delete('*/a/changes/12345/topic', () => {
300
+ return new HttpResponse(null, { status: 204 })
301
+ }),
302
+ )
303
+
304
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
305
+ const program = topicCommand('12345', 'ignored-topic', { delete: true }).pipe(
306
+ Effect.provide(GerritApiServiceLive),
307
+ Effect.provide(mockConfigLayer),
308
+ )
309
+
310
+ await Effect.runPromise(program)
311
+
312
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
313
+ expect(output).toContain('Removed topic from change 12345')
314
+ })
315
+ })
316
+
317
+ describe('error handling', () => {
318
+ it('should fail when change ID is not provided and not in git repo', async () => {
319
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
320
+ const program = topicCommand(undefined, undefined, {}).pipe(
321
+ Effect.provide(GerritApiServiceLive),
322
+ Effect.provide(mockConfigLayer),
323
+ )
324
+
325
+ // Should throw because we can't auto-detect from HEAD outside a git repo
326
+ await expect(Effect.runPromise(program)).rejects.toThrow()
327
+ })
328
+
329
+ it('should fail when empty string change ID is provided and not in git repo', async () => {
330
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
331
+ const program = topicCommand('', undefined, {}).pipe(
332
+ Effect.provide(GerritApiServiceLive),
333
+ Effect.provide(mockConfigLayer),
334
+ )
335
+
336
+ // Should throw because empty string triggers auto-detect which fails outside git repo
337
+ await expect(Effect.runPromise(program)).rejects.toThrow()
338
+ })
339
+
340
+ it('should fail when whitespace-only change ID is provided and not in git repo', async () => {
341
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
342
+ const program = topicCommand(' ', undefined, {}).pipe(
343
+ Effect.provide(GerritApiServiceLive),
344
+ Effect.provide(mockConfigLayer),
345
+ )
346
+
347
+ // Should throw because whitespace-only triggers auto-detect which fails outside git repo
348
+ await expect(Effect.runPromise(program)).rejects.toThrow()
349
+ })
350
+
351
+ it('should handle 404 not found error', async () => {
352
+ server.use(
353
+ http.get('*/a/changes/99999/topic', () => {
354
+ return HttpResponse.text('Change not found', { status: 404 })
355
+ }),
356
+ )
357
+
358
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
359
+ const program = topicCommand('99999', undefined, {}).pipe(
360
+ Effect.provide(GerritApiServiceLive),
361
+ Effect.provide(mockConfigLayer),
362
+ )
363
+
364
+ await Effect.runPromise(program)
365
+
366
+ // 404 for topic endpoint means no topic set, not an error
367
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
368
+ expect(output).toContain('No topic set')
369
+ })
370
+
371
+ it('should handle 403 forbidden error for set', async () => {
372
+ server.use(
373
+ http.put('*/a/changes/12345/topic', () => {
374
+ return HttpResponse.text('Forbidden', { status: 403 })
375
+ }),
376
+ )
377
+
378
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
379
+ const program = topicCommand('12345', 'my-topic', {}).pipe(
380
+ Effect.provide(GerritApiServiceLive),
381
+ Effect.provide(mockConfigLayer),
382
+ )
383
+
384
+ // Should throw/fail
385
+ await expect(Effect.runPromise(program)).rejects.toThrow()
386
+ })
387
+
388
+ it('should handle 403 forbidden error for delete', async () => {
389
+ server.use(
390
+ http.delete('*/a/changes/12345/topic', () => {
391
+ return HttpResponse.text('Forbidden', { status: 403 })
392
+ }),
393
+ )
394
+
395
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
396
+ const program = topicCommand('12345', undefined, { delete: true }).pipe(
397
+ Effect.provide(GerritApiServiceLive),
398
+ Effect.provide(mockConfigLayer),
399
+ )
400
+
401
+ // Should throw/fail
402
+ await expect(Effect.runPromise(program)).rejects.toThrow()
403
+ })
404
+
405
+ it('should handle network errors', async () => {
406
+ server.use(
407
+ http.get('*/a/changes/12345/topic', () => {
408
+ return HttpResponse.error()
409
+ }),
410
+ )
411
+
412
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
413
+ const program = topicCommand('12345', undefined, {}).pipe(
414
+ Effect.provide(GerritApiServiceLive),
415
+ Effect.provide(mockConfigLayer),
416
+ )
417
+
418
+ // Should throw/fail
419
+ await expect(Effect.runPromise(program)).rejects.toThrow()
420
+ })
421
+ })
422
+
423
+ describe('Change-ID format support', () => {
424
+ it('should work with Change-ID format', async () => {
425
+ server.use(
426
+ http.get('*/a/changes/If5a3ae8cb5a107e187447802358417f311d0c4b1/topic', () => {
427
+ return HttpResponse.text(`)]}'\n"feature-branch"`)
428
+ }),
429
+ )
430
+
431
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
432
+ const program = topicCommand('If5a3ae8cb5a107e187447802358417f311d0c4b1', undefined, {}).pipe(
433
+ Effect.provide(GerritApiServiceLive),
434
+ Effect.provide(mockConfigLayer),
435
+ )
436
+
437
+ await Effect.runPromise(program)
438
+
439
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
440
+ expect(output).toBe('feature-branch')
441
+ })
442
+ })
443
+ })
@@ -0,0 +1,258 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { Effect, Layer, Exit } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { installHookCommand } from '@/cli/commands/install-hook'
6
+ import { ConfigService } from '@/services/config'
7
+ import { CommitHookService, CommitHookServiceLive } from '@/services/commit-hook'
8
+ import { createMockConfigService } from '../../helpers/config-mock'
9
+
10
+ // Create MSW server for hook download tests
11
+ const server = setupServer()
12
+
13
+ // Helper to create mock commit hook service with configurable hasHook
14
+ const createMockCommitHookService = (hookExists: boolean) => ({
15
+ hasHook: () => Effect.succeed(hookExists),
16
+ hasChangeId: () => Effect.succeed(true),
17
+ installHook: () => Effect.void,
18
+ ensureChangeId: () => Effect.void,
19
+ amendWithChangeId: () => Effect.void,
20
+ })
21
+
22
+ describe('install-hook Command', () => {
23
+ beforeAll(() => {
24
+ server.listen({ onUnhandledRequest: 'bypass' })
25
+ })
26
+
27
+ afterAll(() => {
28
+ server.close()
29
+ })
30
+
31
+ afterEach(() => {
32
+ server.resetHandlers()
33
+ })
34
+
35
+ describe('when hook does not exist', () => {
36
+ test('should call installHook when no hook exists', async () => {
37
+ let installHookCalled = false
38
+ const mockService = {
39
+ hasHook: () => Effect.succeed(false),
40
+ hasChangeId: () => Effect.succeed(true),
41
+ installHook: () => {
42
+ installHookCalled = true
43
+ return Effect.void
44
+ },
45
+ ensureChangeId: () => Effect.void,
46
+ amendWithChangeId: () => Effect.void,
47
+ }
48
+
49
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
50
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
51
+
52
+ const effect = installHookCommand({}).pipe(
53
+ Effect.provide(mockHookLayer),
54
+ Effect.provide(mockConfigLayer),
55
+ )
56
+
57
+ const exit = await Effect.runPromiseExit(effect)
58
+
59
+ expect(Exit.isSuccess(exit)).toBe(true)
60
+ expect(installHookCalled).toBe(true)
61
+ })
62
+
63
+ test('should succeed with XML output when no hook exists', async () => {
64
+ const mockService = createMockCommitHookService(false)
65
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
66
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
67
+
68
+ const effect = installHookCommand({ xml: true }).pipe(
69
+ Effect.provide(mockHookLayer),
70
+ Effect.provide(mockConfigLayer),
71
+ )
72
+
73
+ const exit = await Effect.runPromiseExit(effect)
74
+
75
+ expect(Exit.isSuccess(exit)).toBe(true)
76
+ })
77
+ })
78
+
79
+ describe('when hook already exists', () => {
80
+ test('should skip installation without --force and return success', async () => {
81
+ let installHookCalled = false
82
+ const mockService = {
83
+ hasHook: () => Effect.succeed(true),
84
+ hasChangeId: () => Effect.succeed(true),
85
+ installHook: () => {
86
+ installHookCalled = true
87
+ return Effect.void
88
+ },
89
+ ensureChangeId: () => Effect.void,
90
+ amendWithChangeId: () => Effect.void,
91
+ }
92
+
93
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
94
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
95
+
96
+ const effect = installHookCommand({}).pipe(
97
+ Effect.provide(mockHookLayer),
98
+ Effect.provide(mockConfigLayer),
99
+ )
100
+
101
+ const exit = await Effect.runPromiseExit(effect)
102
+
103
+ expect(Exit.isSuccess(exit)).toBe(true)
104
+ expect(installHookCalled).toBe(false) // Should NOT call installHook
105
+ })
106
+
107
+ test('should skip installation and succeed with XML output', async () => {
108
+ const mockService = createMockCommitHookService(true)
109
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
110
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
111
+
112
+ const effect = installHookCommand({ xml: true }).pipe(
113
+ Effect.provide(mockHookLayer),
114
+ Effect.provide(mockConfigLayer),
115
+ )
116
+
117
+ const exit = await Effect.runPromiseExit(effect)
118
+
119
+ expect(Exit.isSuccess(exit)).toBe(true)
120
+ })
121
+
122
+ test('should reinstall with --force flag', async () => {
123
+ let installHookCalled = false
124
+ const mockService = {
125
+ hasHook: () => Effect.succeed(true),
126
+ hasChangeId: () => Effect.succeed(true),
127
+ installHook: () => {
128
+ installHookCalled = true
129
+ return Effect.void
130
+ },
131
+ ensureChangeId: () => Effect.void,
132
+ amendWithChangeId: () => Effect.void,
133
+ }
134
+
135
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
136
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
137
+
138
+ const effect = installHookCommand({ force: true }).pipe(
139
+ Effect.provide(mockHookLayer),
140
+ Effect.provide(mockConfigLayer),
141
+ )
142
+
143
+ const exit = await Effect.runPromiseExit(effect)
144
+
145
+ expect(Exit.isSuccess(exit)).toBe(true)
146
+ expect(installHookCalled).toBe(true) // Should call installHook with --force
147
+ })
148
+ })
149
+
150
+ describe('error handling with real service', () => {
151
+ test('should handle HTTP 404 error', async () => {
152
+ server.use(
153
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
154
+ return HttpResponse.text('Not Found', { status: 404 })
155
+ }),
156
+ )
157
+
158
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
159
+
160
+ const effect = installHookCommand({ force: true }).pipe(
161
+ Effect.provide(CommitHookServiceLive),
162
+ Effect.provide(mockConfigLayer),
163
+ )
164
+
165
+ const result = await Effect.runPromise(effect).catch((e) => e)
166
+
167
+ expect(result).toBeInstanceOf(Error)
168
+ expect(String(result)).toContain('Failed to download')
169
+ })
170
+
171
+ test('should handle HTTP 500 error', async () => {
172
+ server.use(
173
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
174
+ return HttpResponse.text('Internal Server Error', { status: 500 })
175
+ }),
176
+ )
177
+
178
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
179
+
180
+ const effect = installHookCommand({ force: true }).pipe(
181
+ Effect.provide(CommitHookServiceLive),
182
+ Effect.provide(mockConfigLayer),
183
+ )
184
+
185
+ const result = await Effect.runPromise(effect).catch((e) => e)
186
+
187
+ expect(result).toBeInstanceOf(Error)
188
+ expect(String(result)).toContain('Failed to download')
189
+ })
190
+
191
+ test('should handle invalid script content', async () => {
192
+ server.use(
193
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
194
+ return HttpResponse.text('<html>Error</html>', { status: 200 })
195
+ }),
196
+ )
197
+
198
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
199
+
200
+ const effect = installHookCommand({ force: true }).pipe(
201
+ Effect.provide(CommitHookServiceLive),
202
+ Effect.provide(mockConfigLayer),
203
+ )
204
+
205
+ const result = await Effect.runPromise(effect).catch((e) => e)
206
+
207
+ expect(result).toBeInstanceOf(Error)
208
+ expect(String(result)).toContain('valid script')
209
+ })
210
+
211
+ test('should handle network error', async () => {
212
+ server.use(
213
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
214
+ return HttpResponse.error()
215
+ }),
216
+ )
217
+
218
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
219
+
220
+ const effect = installHookCommand({ force: true }).pipe(
221
+ Effect.provide(CommitHookServiceLive),
222
+ Effect.provide(mockConfigLayer),
223
+ )
224
+
225
+ const result = await Effect.runPromise(effect).catch((e) => e)
226
+
227
+ expect(result).toBeInstanceOf(Error)
228
+ })
229
+ })
230
+
231
+ describe('service integration', () => {
232
+ test('should use hasHook from service, not direct function call', async () => {
233
+ let hasHookCalled = false
234
+ const mockService = {
235
+ hasHook: () => {
236
+ hasHookCalled = true
237
+ return Effect.succeed(false)
238
+ },
239
+ hasChangeId: () => Effect.succeed(true),
240
+ installHook: () => Effect.void,
241
+ ensureChangeId: () => Effect.void,
242
+ amendWithChangeId: () => Effect.void,
243
+ }
244
+
245
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
246
+ const mockHookLayer = Layer.succeed(CommitHookService, mockService)
247
+
248
+ const effect = installHookCommand({}).pipe(
249
+ Effect.provide(mockHookLayer),
250
+ Effect.provide(mockConfigLayer),
251
+ )
252
+
253
+ await Effect.runPromiseExit(effect)
254
+
255
+ expect(hasHookCalled).toBe(true)
256
+ })
257
+ })
258
+ })