UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

580 lines (500 loc) 21 kB
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' import { Effect, Layer } from 'effect' import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { GerritApiServiceLive } from '@/api/gerrit' import { addReviewerCommand } from '@/cli/commands/add-reviewer' import { ConfigService } from '@/services/config' import { createMockConfigService } from './helpers/config-mock' // Create MSW server const server = setupServer( // Default handler for auth check http.get('*/a/accounts/self', ({ request }) => { const auth = request.headers.get('Authorization') if (!auth || !auth.startsWith('Basic ')) { return HttpResponse.text('Unauthorized', { status: 401 }) } return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com', }) }), ) describe('add-reviewer command', () => { let mockConsoleLog: ReturnType<typeof mock> let mockConsoleError: ReturnType<typeof mock> beforeAll(() => { server.listen({ onUnhandledRequest: 'bypass' }) }) afterAll(() => { server.close() }) beforeEach(() => { mockConsoleLog = mock(() => {}) mockConsoleError = mock(() => {}) console.log = mockConsoleLog console.error = mockConsoleError }) afterEach(() => { server.resetHandlers() }) it('should add a single reviewer successfully', async () => { server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; state?: string } expect(body.reviewer).toBe('reviewer@example.com') expect(body.state).toBe('REVIEWER') return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'reviewer@example.com', reviewers: [ { _account_id: 2000, name: 'Reviewer User', email: 'reviewer@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added Reviewer User as reviewer') }) it('should add multiple reviewers successfully', async () => { let callCount = 0 server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string } callCount++ const reviewerName = callCount === 1 ? 'User One' : 'User Two' return HttpResponse.text( `)]}'\n${JSON.stringify({ input: body.reviewer, reviewers: [ { _account_id: 2000 + callCount, name: reviewerName, email: body.reviewer, }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['user1@example.com', 'user2@example.com'], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added User One as reviewer') expect(output).toContain('Added User Two as reviewer') expect(callCount).toBe(2) }) it('should add as CC when --cc flag is used', async () => { server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; state?: string } expect(body.reviewer).toBe('cc@example.com') expect(body.state).toBe('CC') return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'cc@example.com', ccs: [ { _account_id: 2000, name: 'CC User', email: 'cc@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['cc@example.com'], { change: '12345', cc: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added CC User as cc') }) it('should pass notify option to API', async () => { server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; notify?: string } expect(body.notify).toBe('NONE') return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'reviewer@example.com', reviewers: [ { _account_id: 2000, name: 'Reviewer', email: 'reviewer@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', notify: 'none', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added Reviewer as reviewer') }) it('should handle API error in result', async () => { server.use( http.post('*/a/changes/12345/reviewers', async () => { return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'nonexistent@example.com', error: 'Account not found: nonexistent@example.com', })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['nonexistent@example.com'], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('Failed to add nonexistent@example.com') expect(errorOutput).toContain('Account not found') }) it('should show error when change ID is not provided', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], {}).pipe( Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer), ) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('Change ID is required') }) it('should show error when no reviewers are provided', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand([], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('At least one reviewer is required') }) it('should output XML format when --xml flag is used', async () => { server.use( http.post('*/a/changes/12345/reviewers', async () => { return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'reviewer@example.com', reviewers: [ { _account_id: 2000, name: 'Reviewer User', email: 'reviewer@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>') expect(output).toContain('<add_reviewer_result>') expect(output).toContain('<change_id>12345</change_id>') expect(output).toContain('<state>REVIEWER</state>') expect(output).toContain('<entity_type>individual</entity_type>') expect(output).toContain('<reviewer status="added">') expect(output).toContain('<input>reviewer@example.com</input>') expect(output).toContain('<name><![CDATA[Reviewer User]]></name>') expect(output).toContain('<status>success</status>') expect(output).toContain('</add_reviewer_result>') }) it('should output XML format for errors when --xml flag is used', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>') expect(output).toContain('<add_reviewer_result>') expect(output).toContain('<status>error</status>') expect(output).toContain('<error><![CDATA[Change ID is required') expect(output).toContain('</add_reviewer_result>') }) it('should handle network errors gracefully', async () => { server.use( http.post('*/a/changes/12345/reviewers', () => { return HttpResponse.text('Internal Server Error', { status: 500 }) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('Failed to add reviewer@example.com') }) it('should handle partial success with multiple reviewers', async () => { let callCount = 0 server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string } callCount++ if (callCount === 1) { return HttpResponse.text( `)]}'\n${JSON.stringify({ input: body.reviewer, reviewers: [ { _account_id: 2001, name: 'Valid User', email: body.reviewer, }, ], })}`, ) } return HttpResponse.text( `)]}'\n${JSON.stringify({ input: body.reviewer, error: 'Account not found', })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['valid@example.com', 'invalid@example.com'], { change: '12345', xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<status>partial_failure</status>') expect(output).toContain('<reviewer status="added">') expect(output).toContain('<reviewer status="failed">') }) it('should reject invalid notify option', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', notify: 'invalid', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('Invalid notify level: invalid') expect(errorOutput).toContain('Valid values: none, owner, owner_reviewers, all') }) it('should pass REVIEWER state by default (not CC)', async () => { let receivedState: string | undefined server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; state?: string } receivedState = body.state return HttpResponse.text( `)]}'\n${JSON.stringify({ input: body.reviewer, reviewers: [ { _account_id: 2000, name: 'Reviewer', email: body.reviewer, }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['reviewer@example.com'], { change: '12345', }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) expect(receivedState).toBe('REVIEWER') }) it('should add a group as reviewer with --group flag', async () => { server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; state?: string } expect(body.reviewer).toBe('project-reviewers') expect(body.state).toBe('REVIEWER') return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'project-reviewers', reviewers: [ { _account_id: 3001, name: 'Alice Developer', email: 'alice@example.com', }, { _account_id: 3002, name: 'Bob Developer', email: 'bob@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['project-reviewers'], { change: '12345', group: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added Alice Developer as group') }) it('should add a group as CC with --group and --cc flags', async () => { server.use( http.post('*/a/changes/12345/reviewers', async ({ request }) => { const body = (await request.json()) as { reviewer: string; state?: string } expect(body.reviewer).toBe('administrators') expect(body.state).toBe('CC') return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'administrators', ccs: [ { _account_id: 4001, name: 'Admin User', email: 'admin@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['administrators'], { change: '12345', group: true, cc: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('Added Admin User as cc') }) it('should show error when no groups provided with --group flag', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand([], { change: '12345', group: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('At least one group is required') }) it('should output XML format with --group flag', async () => { server.use( http.post('*/a/changes/12345/reviewers', async () => { return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'project-reviewers', reviewers: [ { _account_id: 3001, name: 'Alice Developer', email: 'alice@example.com', }, ], })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['project-reviewers'], { change: '12345', group: true, xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>') expect(output).toContain('<add_reviewer_result>') expect(output).toContain('<change_id>12345</change_id>') expect(output).toContain('<state>REVIEWER</state>') expect(output).toContain('<entity_type>group</entity_type>') expect(output).toContain('<reviewer status="added">') expect(output).toContain('<input>project-reviewers</input>') expect(output).toContain('<name><![CDATA[Alice Developer]]></name>') expect(output).toContain('<status>success</status>') }) it('should handle group not found error', async () => { server.use( http.post('*/a/changes/12345/reviewers', async () => { return HttpResponse.text( `)]}'\n${JSON.stringify({ input: 'nonexistent-group', error: 'Group nonexistent-group not found', })}`, ) }), ) const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['nonexistent-group'], { change: '12345', group: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) await Effect.runPromise(program) const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('Failed to add nonexistent-group') expect(errorOutput).toContain('Group nonexistent-group not found') }) it('should reject email-like input when --group flag is used', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['user@example.com'], { change: '12345', group: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n') expect(errorOutput).toContain('The --group flag expects group identifiers') expect(errorOutput).toContain('user@example.com') expect(errorOutput).toContain('Did you mean to omit --group?') }) it('should reject email-like input in XML mode when --group flag is used', async () => { const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService()) const program = addReviewerCommand(['admin@example.com', 'test@example.com'], { change: '12345', group: true, xml: true, }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer)) const result = await Effect.runPromiseExit(program) expect(result._tag).toBe('Failure') const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n') expect(output).toContain('<add_reviewer_result>') expect(output).toContain('<status>error</status>') expect(output).toContain('<error><![CDATA[') expect(output).toContain('admin@example.com') expect(output).toContain('test@example.com') }) })