import { describe, expect, it, jest } from "bun:test"; import { fetchForgejoData } from "../src/forgejo/data/fetcher"; import type { Octokits } from "../src/github/api/client"; // Helper to build a mock Octokits with REST methods function createMockOctokits({ prData, filesData, commitsData, issueData, userData, }: { prData?: Record; filesData?: Record[]; commitsData?: Record[]; issueData?: Record; userData?: Record; } = {}) { return { rest: { pulls: { get: jest.fn().mockResolvedValue({ data: prData ?? {} }), listFiles: jest.fn().mockResolvedValue({ data: filesData ?? [] }), listCommits: jest.fn().mockResolvedValue({ data: commitsData ?? [] }), }, issues: { get: jest.fn().mockResolvedValue({ data: issueData ?? {} }), }, users: { getByUsername: jest .fn() .mockResolvedValue({ data: userData ?? { name: null } }), }, }, graphql: jest.fn(), } as unknown as Octokits; } // Minimal PR REST response matching Forgejo/GitHub REST shape const mockPrRestResponse = { title: "Fix memory leak in user service", body: "This PR fixes a critical memory leak.", user: { login: "test-author" }, base: { ref: "main" }, head: { ref: "fix/memory-leak", sha: "abc123def456" }, created_at: "2024-01-15T10:00:00Z", updated_at: "2024-01-15T12:00:00Z", additions: 42, deletions: 15, state: "open", labels: [{ name: "bug" }, { name: "priority:high" }], }; const mockFilesRestResponse = [ { filename: "src/user.ts", additions: 30, deletions: 10, status: "modified" }, { filename: "src/new-file.ts", additions: 12, deletions: 0, status: "added" }, { filename: "src/old-file.ts", additions: 0, deletions: 5, status: "removed", }, ]; const mockCommitsRestResponse = [ { sha: "commit1sha", commit: { message: "fix: patch memory leak", author: { name: "Test Author", email: "test@example.com" }, }, }, { sha: "commit2sha", commit: { message: "test: add regression test", author: { name: "Test Author", email: "test@example.com" }, }, }, ]; const mockIssueRestResponse = { title: "Bug: memory leak in user service", body: "Steps to reproduce...", user: { login: "reporter" }, created_at: "2024-01-10T08:00:00Z", updated_at: "2024-01-14T09:00:00Z", state: "open", labels: [{ name: "bug" }], }; describe("fetchForgejoData", () => { describe("PR data fetching", () => { it("should fetch PR details, files, and commits in parallel", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: mockFilesRestResponse, commitsData: mockCommitsRestResponse, }); await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); // Verify all three REST calls were made expect(mockOctokits.rest.pulls.get).toHaveBeenCalledWith({ owner: "test-owner", repo: "test-repo", pull_number: 42, }); expect(mockOctokits.rest.pulls.listFiles).toHaveBeenCalledWith({ owner: "test-owner", repo: "test-repo", pull_number: 42, per_page: 100, }); expect(mockOctokits.rest.pulls.listCommits).toHaveBeenCalledWith({ owner: "test-owner", repo: "test-repo", pull_number: 42, per_page: 100, }); }); it("should map PR fields to GitHubPullRequest type", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: mockFilesRestResponse, commitsData: mockCommitsRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.title).toBe("Fix memory leak in user service"); expect(pr.body).toBe("This PR fixes a critical memory leak."); expect(pr.author.login).toBe("test-author"); expect(pr.baseRefName).toBe("main"); expect(pr.headRefName).toBe("fix/memory-leak"); expect(pr.headRefOid).toBe("abc123def456"); expect(pr.createdAt).toBe("2024-01-15T10:00:00Z"); expect(pr.updatedAt).toBe("2024-01-15T12:00:00Z"); expect(pr.lastEditedAt).toBeUndefined(); expect(pr.additions).toBe(42); expect(pr.deletions).toBe(15); expect(pr.state).toBe("open"); }); it("should map PR labels correctly", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.labels.nodes).toEqual([ { name: "bug" }, { name: "priority:high" }, ]); }); it("should handle string labels", async () => { const mockOctokits = createMockOctokits({ prData: { ...mockPrRestResponse, labels: ["bug", "fix"] }, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.labels.nodes).toEqual([{ name: "bug" }, { name: "fix" }]); }); it("should map commits to GitHubCommit structure", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [], commitsData: mockCommitsRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.commits.totalCount).toBe(2); expect(pr.commits.nodes[0].commit.oid).toBe("commit1sha"); expect(pr.commits.nodes[0].commit.message).toBe( "fix: patch memory leak", ); expect(pr.commits.nodes[0].commit.author.name).toBe("Test Author"); expect(pr.commits.nodes[0].commit.author.email).toBe( "test@example.com", ); }); }); describe("file status mapping", () => { it("should map REST file statuses to GraphQL-style enums", async () => { const files = [ { filename: "a.ts", additions: 1, deletions: 0, status: "added" }, { filename: "b.ts", additions: 0, deletions: 1, status: "removed" }, { filename: "c.ts", additions: 1, deletions: 1, status: "modified" }, { filename: "d.ts", additions: 1, deletions: 0, status: "renamed" }, { filename: "e.ts", additions: 0, deletions: 0, status: "copied" }, { filename: "f.ts", additions: 1, deletions: 0, status: "changed" }, ]; const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: files, commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.changedFiles.map((f) => f.changeType)).toEqual([ "ADDED", "DELETED", "MODIFIED", "RENAMED", "COPIED", "MODIFIED", ]); }); it("should uppercase unknown file statuses", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [ { filename: "a.ts", additions: 0, deletions: 0, status: "unknown_status", }, ], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.changedFiles[0]!.changeType).toBe("UNKNOWN_STATUS"); }); }); describe("SHA computation", () => { it("should mark deleted files with sha 'deleted'", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [ { filename: "deleted.ts", additions: 0, deletions: 10, status: "removed", }, ], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.changedFilesWithSHA[0]!.sha).toBe("deleted"); }); it("should use 'unknown' for files that fail SHA computation", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [ { filename: "nonexistent-file.ts", additions: 1, deletions: 0, status: "added", }, ], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); // File doesn't exist on disk, so SHA computation fails gracefully expect(result.changedFilesWithSHA[0]!.sha).toBe("unknown"); }); it("should not compute SHAs for issue (no changed files)", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.changedFilesWithSHA).toEqual([]); }); }); describe("issue data fetching", () => { it("should fetch issue details", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(mockOctokits.rest.issues.get).toHaveBeenCalledWith({ owner: "test-owner", repo: "test-repo", issue_number: 10, }); // Should NOT call PR endpoints expect(mockOctokits.rest.pulls.get).not.toHaveBeenCalled(); expect(mockOctokits.rest.pulls.listFiles).not.toHaveBeenCalled(); expect(mockOctokits.rest.pulls.listCommits).not.toHaveBeenCalled(); }); it("should map issue fields to GitHubIssue type", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); const issue = result.contextData as any; expect(issue.title).toBe("Bug: memory leak in user service"); expect(issue.body).toBe("Steps to reproduce..."); expect(issue.author.login).toBe("reporter"); expect(issue.createdAt).toBe("2024-01-10T08:00:00Z"); expect(issue.updatedAt).toBe("2024-01-14T09:00:00Z"); expect(issue.lastEditedAt).toBeUndefined(); expect(issue.state).toBe("open"); expect(issue.labels.nodes).toEqual([{ name: "bug" }]); }); it("should return empty changedFiles for issues", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.changedFiles).toEqual([]); expect(result.changedFilesWithSHA).toEqual([]); }); it("should return null reviewData for issues", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.reviewData).toBeNull(); }); }); describe("TOCTOU protection", () => { it("should override body with originalBody when provided", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, originalBody: "Original body from webhook", }); expect(result.contextData.body).toBe("Original body from webhook"); }); it("should override body with empty string when originalBody is null", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, originalBody: null, }); expect(result.contextData.body).toBe(""); }); it("should override title with originalTitle when provided", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, originalTitle: "Original title from webhook", }); expect(result.contextData.title).toBe("Original title from webhook"); }); it("should keep fetched body when originalBody is not provided", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.contextData.body).toBe("Steps to reproduce..."); }); }); describe("user display name", () => { it("should fetch display name via REST when triggerUsername provided", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, userData: { name: "Test User Display Name" }, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, triggerUsername: "testuser", }); expect(mockOctokits.rest.users.getByUsername).toHaveBeenCalledWith({ username: "testuser", }); expect(result.triggerDisplayName).toBe("Test User Display Name"); }); it("should return null when user has no display name", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, userData: { name: null }, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, triggerUsername: "testuser", }); expect(result.triggerDisplayName).toBeNull(); }); it("should return null on user fetch failure", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); (mockOctokits.rest.users.getByUsername as any).mockRejectedValue( new Error("User not found"), ); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, triggerUsername: "nonexistent", }); expect(result.triggerDisplayName).toBeNull(); }); it("should not fetch display name when triggerUsername is not provided", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(mockOctokits.rest.users.getByUsername).not.toHaveBeenCalled(); expect(result.triggerDisplayName).toBeUndefined(); }); }); describe("M4a placeholders (comments/reviews/images)", () => { it("should return empty comments array", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.comments).toEqual([]); }); it("should return empty reviewData nodes for PRs", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.reviewData).toEqual({ nodes: [] }); }); it("should return empty imageUrlMap", async () => { const mockOctokits = createMockOctokits({ issueData: mockIssueRestResponse, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.imageUrlMap.size).toBe(0); }); }); describe("error handling", () => { it("should throw on invalid repository format", async () => { const mockOctokits = createMockOctokits(); expect( fetchForgejoData({ octokits: mockOctokits, repository: "invalid-format", prNumber: "1", isPR: false, }), ).rejects.toThrow("Invalid repository format"); }); it("should handle null PR body gracefully", async () => { const mockOctokits = createMockOctokits({ prData: { ...mockPrRestResponse, body: null }, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.contextData.body).toBe(""); }); it("should handle null issue body gracefully", async () => { const mockOctokits = createMockOctokits({ issueData: { ...mockIssueRestResponse, body: null }, }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "10", isPR: false, }); expect(result.contextData.body).toBe(""); }); it("should handle null PR user gracefully", async () => { const mockOctokits = createMockOctokits({ prData: { ...mockPrRestResponse, user: null }, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); expect(result.contextData.author.login).toBe("unknown"); }); it("should handle missing labels gracefully", async () => { const mockOctokits = createMockOctokits({ prData: { ...mockPrRestResponse, labels: undefined }, filesData: [], commitsData: [], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.labels.nodes).toEqual([]); }); it("should handle missing commit author gracefully", async () => { const mockOctokits = createMockOctokits({ prData: mockPrRestResponse, filesData: [], commitsData: [ { sha: "abc", commit: { message: "test", author: null, }, }, ], }); const result = await fetchForgejoData({ octokits: mockOctokits, repository: "test-owner/test-repo", prNumber: "42", isPR: true, }); const pr = result.contextData as any; expect(pr.commits.nodes[0].commit.author.name).toBe("unknown"); expect(pr.commits.nodes[0].commit.author.email).toBe(""); }); }); });