diff --git a/tests/unit/gadgets/github/core/getPRDetails.test.ts b/tests/unit/gadgets/github/core/getPRDetails.test.ts new file mode 100644 index 00000000..1cedc53c --- /dev/null +++ b/tests/unit/gadgets/github/core/getPRDetails.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/github/client.js', () => ({ + githubClient: { + getPR: vi.fn(), + }, +})); + +import { getPRDetails } from '../../../../../src/gadgets/github/core/getPRDetails.js'; +import { githubClient } from '../../../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); + +describe('getPRDetails', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns formatted string with title, state, branch, URL, and body on success', async () => { + mockGithub.getPR.mockResolvedValue({ + number: 42, + title: 'My feature PR', + state: 'open', + headRef: 'feature/my-branch', + baseRef: 'main', + htmlUrl: 'https://github.com/owner/repo/pull/42', + body: 'This PR adds a new feature.', + } as Awaited>); + + const result = await getPRDetails('owner', 'repo', 42); + + expect(result).toBe( + [ + 'PR #42: My feature PR', + 'State: open', + 'Branch: feature/my-branch -> main', + 'URL: https://github.com/owner/repo/pull/42', + '', + 'Description:', + 'This PR adds a new feature.', + ].join('\n'), + ); + }); + + it('uses "(no description)" when PR body is empty', async () => { + mockGithub.getPR.mockResolvedValue({ + number: 10, + title: 'Empty body PR', + state: 'closed', + headRef: 'fix/bug', + baseRef: 'main', + htmlUrl: 'https://github.com/owner/repo/pull/10', + body: '', + } as Awaited>); + + const result = await getPRDetails('owner', 'repo', 10); + + expect(result).toContain('(no description)'); + }); + + it('returns error message string when githubClient throws', async () => { + mockGithub.getPR.mockRejectedValue(new Error('Not Found')); + + const result = await getPRDetails('owner', 'repo', 99); + + expect(result).toBe('Error fetching PR details: Not Found'); + }); +}); diff --git a/tests/unit/gadgets/github/core/getPRDiff.test.ts b/tests/unit/gadgets/github/core/getPRDiff.test.ts new file mode 100644 index 00000000..b7384b71 --- /dev/null +++ b/tests/unit/gadgets/github/core/getPRDiff.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/github/client.js', () => ({ + githubClient: { + getPRDiff: vi.fn(), + }, +})); + +import { getPRDiff } from '../../../../../src/gadgets/github/core/getPRDiff.js'; +import { githubClient } from '../../../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); + +describe('getPRDiff', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns formatted diff output with file count and patches on success', async () => { + mockGithub.getPRDiff.mockResolvedValue([ + { + filename: 'src/foo.ts', + status: 'modified', + additions: 5, + deletions: 2, + patch: '@@ -1,2 +1,5 @@\n-old line\n+new line\n+another line', + }, + ] as Awaited>); + + const result = await getPRDiff('owner', 'repo', 42); + + expect(result).toContain('1 file(s) changed:'); + expect(result).toContain('## src/foo.ts'); + expect(result).toContain('Status: modified | +5 -2'); + expect(result).toContain('```diff'); + expect(result).toContain('@@ -1,2 +1,5 @@'); + expect(result).toContain('```'); + }); + + it('uses "[Binary file or too large to display]" for files without patch', async () => { + mockGithub.getPRDiff.mockResolvedValue([ + { + filename: 'assets/image.png', + status: 'added', + additions: 0, + deletions: 0, + patch: undefined, + }, + ] as Awaited>); + + const result = await getPRDiff('owner', 'repo', 42); + + expect(result).toContain('1 file(s) changed:'); + expect(result).toContain('## assets/image.png'); + expect(result).toContain('[Binary file or too large to display]'); + expect(result).not.toContain('```diff'); + }); + + it('returns "No files changed" when file list is empty', async () => { + mockGithub.getPRDiff.mockResolvedValue([] as Awaited>); + + const result = await getPRDiff('owner', 'repo', 42); + + expect(result).toBe('No files changed in this PR.'); + }); + + it('returns error message string when githubClient throws', async () => { + mockGithub.getPRDiff.mockRejectedValue(new Error('API rate limit exceeded')); + + const result = await getPRDiff('owner', 'repo', 42); + + expect(result).toBe('Error fetching PR diff: API rate limit exceeded'); + }); +}); diff --git a/tests/unit/gadgets/github/core/postPRComment.test.ts b/tests/unit/gadgets/github/core/postPRComment.test.ts new file mode 100644 index 00000000..a5f38a24 --- /dev/null +++ b/tests/unit/gadgets/github/core/postPRComment.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/github/client.js', () => ({ + githubClient: { + createPRComment: vi.fn(), + }, +})); + +vi.mock('../../../../../src/utils/runLink.js', () => ({ + buildRunLinkFooterFromEnv: vi.fn(), +})); + +import { postPRComment } from '../../../../../src/gadgets/github/core/postPRComment.js'; +import { githubClient } from '../../../../../src/github/client.js'; +import { buildRunLinkFooterFromEnv } from '../../../../../src/utils/runLink.js'; + +const mockGithub = vi.mocked(githubClient); +const mockBuildRunLinkFooter = vi.mocked(buildRunLinkFooterFromEnv); + +describe('postPRComment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns "Comment posted" with id and URL on success (no run link footer)', async () => { + mockBuildRunLinkFooter.mockReturnValue(null); + mockGithub.createPRComment.mockResolvedValue({ + id: 123, + htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-123', + } as Awaited>); + + const result = await postPRComment('owner', 'repo', 42, 'Hello from test'); + + expect(result).toBe( + 'Comment posted (id: 123): https://github.com/owner/repo/pull/42#issuecomment-123', + ); + expect(mockGithub.createPRComment).toHaveBeenCalledWith('owner', 'repo', 42, 'Hello from test'); + }); + + it('appends run link footer to comment body when available', async () => { + mockBuildRunLinkFooter.mockReturnValue('\n\n[Run details](https://example.com/run/1)'); + mockGithub.createPRComment.mockResolvedValue({ + id: 456, + htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-456', + } as Awaited>); + + const result = await postPRComment('owner', 'repo', 42, 'My comment'); + + expect(mockGithub.createPRComment).toHaveBeenCalledWith( + 'owner', + 'repo', + 42, + 'My comment\n\n[Run details](https://example.com/run/1)', + ); + expect(result).toBe( + 'Comment posted (id: 456): https://github.com/owner/repo/pull/42#issuecomment-456', + ); + }); + + it('returns error message string when githubClient throws', async () => { + mockBuildRunLinkFooter.mockReturnValue(null); + mockGithub.createPRComment.mockRejectedValue(new Error('Forbidden')); + + const result = await postPRComment('owner', 'repo', 42, 'My comment'); + + expect(result).toBe('Error posting PR comment: Forbidden'); + }); +}); diff --git a/tests/unit/gadgets/github/core/replyToReviewComment.test.ts b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts new file mode 100644 index 00000000..d0962fc8 --- /dev/null +++ b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/github/client.js', () => ({ + githubClient: { + replyToReviewComment: vi.fn(), + }, +})); + +import { replyToReviewComment } from '../../../../../src/gadgets/github/core/replyToReviewComment.js'; +import { githubClient } from '../../../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); + +describe('replyToReviewComment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns "Reply posted successfully" with URL on success', async () => { + mockGithub.replyToReviewComment.mockResolvedValue({ + htmlUrl: 'https://github.com/owner/repo/pull/42#discussion_r999', + } as Awaited>); + + const result = await replyToReviewComment('owner', 'repo', 42, 101, 'Looks good!'); + + expect(result).toBe( + 'Reply posted successfully: https://github.com/owner/repo/pull/42#discussion_r999', + ); + expect(mockGithub.replyToReviewComment).toHaveBeenCalledWith( + 'owner', + 'repo', + 42, + 101, + 'Looks good!', + ); + }); + + it('returns error message string when githubClient throws', async () => { + mockGithub.replyToReviewComment.mockRejectedValue(new Error('Unprocessable Entity')); + + const result = await replyToReviewComment('owner', 'repo', 42, 101, 'My reply'); + + expect(result).toBe('Error replying to comment: Unprocessable Entity'); + }); +}); diff --git a/tests/unit/gadgets/github/core/updatePRComment.test.ts b/tests/unit/gadgets/github/core/updatePRComment.test.ts new file mode 100644 index 00000000..5daac47e --- /dev/null +++ b/tests/unit/gadgets/github/core/updatePRComment.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/github/client.js', () => ({ + githubClient: { + updatePRComment: vi.fn(), + }, +})); + +import { updatePRComment } from '../../../../../src/gadgets/github/core/updatePRComment.js'; +import { githubClient } from '../../../../../src/github/client.js'; + +const mockGithub = vi.mocked(githubClient); + +describe('updatePRComment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns "Comment updated" with id and URL on success', async () => { + mockGithub.updatePRComment.mockResolvedValue({ + id: 789, + htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-789', + } as Awaited>); + + const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); + + expect(result).toBe( + 'Comment updated (id: 789): https://github.com/owner/repo/pull/42#issuecomment-789', + ); + expect(mockGithub.updatePRComment).toHaveBeenCalledWith('owner', 'repo', 789, 'Updated body'); + }); + + it('returns error message string when githubClient throws', async () => { + mockGithub.updatePRComment.mockRejectedValue(new Error('Not Found')); + + const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); + + expect(result).toBe('Error updating PR comment: Not Found'); + }); +});