Files
claude-code-action/test/forgejo-data-fetcher.test.ts
T
Vlad MocanuandClaude Opus 4.6 6e104a33dc feat(prd-1): implement M4a - Forgejo REST data fetching (PR/issue basics)
- Add src/forgejo/data/fetcher.ts: REST-based data fetcher replacing
  GraphQL queries with parallel Octokit REST calls (PR details + files
  + commits via Promise.all, issue details, file status mapping, SHA
  computation, TOCTOU title/body protection, user display name)
- Add test/forgejo-data-fetcher.test.ts with 31 test cases covering
  field mapping, parallel fetch, null handling, and edge cases
- Output matches FetchDataResult type for downstream compatibility
- Comments/reviews left empty (M4b scope)

Progress: 33% complete (M1, M2, M4a done) - next: M4b or M5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:19:09 +02:00

727 lines
21 KiB
TypeScript

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<string, unknown>;
filesData?: Record<string, unknown>[];
commitsData?: Record<string, unknown>[];
issueData?: Record<string, unknown>;
userData?: Record<string, unknown>;
} = {}) {
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("");
});
});
});