diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/InlineGeminiSuggestionButton/InlineGeminiSuggestionButton.test.tsx b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/InlineGeminiSuggestionButton/InlineGeminiSuggestionButton.test.tsx new file mode 100644 index 000000000..f4c5891ce --- /dev/null +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/InlineGeminiSuggestionButton/InlineGeminiSuggestionButton.test.tsx @@ -0,0 +1,142 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import InlineGeminiSuggestionButton from "./InlineGeminiSuggestionButton"; +import { PublishedThreadItem } from "../../_hooks/usePublishedThreads"; + +const mockMutate = jest.fn(); +const mockUseGeminiSuggestionMutation = jest.fn(); + +// Mock next/navigation +jest.mock("next/navigation", () => ({ + useParams: () => ({ + username: "owner", + repo_name: "repo", + id: "1", + }), +})); + +jest.mock("@/lib/api/mutations/useGeminiSuggestionMutation", () => ({ + useGeminiSuggestionMutation: ( + username: string, + repoName: string, + id: string, + threadId: string + ) => mockUseGeminiSuggestionMutation(username, repoName, id, threadId), +})); + +describe("InlineGeminiSuggestionButton", () => { + const mockThread = { + id: "thread-123", + path: "src/utils/index.ts", + side: "RIGHT", + start_line: 15, + line: 20, + comments: [ + { + commit_id: "sha-abc-123", + body: "Sample comment", + }, + ], + } as unknown as PublishedThreadItem; + + beforeEach(() => { + jest.resetAllMocks(); + mockUseGeminiSuggestionMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, + }); + }); + + it("renders the suggest button and icon", () => { + render(); + + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("Suggest")).toBeInTheDocument(); + expect(screen.getByAltText("AI Star")).toBeInTheDocument(); + }); + + it("renders the pending state correctly", () => { + mockUseGeminiSuggestionMutation.mockReturnValue({ + mutate: mockMutate, + isPending: true, + }); + + render(); + expect(screen.getByText("Pending...")).toBeInTheDocument(); + }); + + it("calls the mutation hook with correct route and thread IDs", () => { + render(); + + expect(mockUseGeminiSuggestionMutation).toHaveBeenCalledWith( + "owner", + "repo", + "1", + "thread-123" + ); + }); + + it("calls mutate with request params using start_line if it exists", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button")); + + expect(mockMutate).toHaveBeenCalledWith({ + id: mockThread.id, + filePath: mockThread.path, + side: mockThread.side, + line: mockThread.start_line, + sha: mockThread.comments[0].commit_id, + comments: mockThread.comments, + }); + }); + + it("calls mutate with request params using line if start_line is null", async () => { + const user = userEvent.setup(); + const threadWithoutStartLine = { + ...mockThread, + start_line: null, + } as unknown as PublishedThreadItem; + + render(); + + await user.click(screen.getByRole("button")); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + line: mockThread.line, + }) + ); + }); + + it("does not call mutate if both start_line and line are null", async () => { + const user = userEvent.setup(); + const threadWithoutAnyLines = { + ...mockThread, + start_line: null, + line: null, + } as unknown as PublishedThreadItem; + + render(); + + await user.click(screen.getByRole("button")); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("does not call mutate if the mutation is currently pending", async () => { + const user = userEvent.setup(); + mockUseGeminiSuggestionMutation.mockReturnValue({ + mutate: mockMutate, + isPending: true, + }); + + render(); + + await user.click(screen.getByRole("button")); + + expect(mockMutate).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/SuggestionModulePopup.test.tsx b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/SuggestionModulePopup.test.tsx new file mode 100644 index 000000000..8aa3a741f --- /dev/null +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/SuggestionModulePopup.test.tsx @@ -0,0 +1,303 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SuggestionModuleContent, SuggestionPopupProp } from "./SuggestionModulePopup"; +import { SuggestionDiffEditorProps } from "./_components/SuggestionDiffEditor"; +import { useUpdateGeminiSuggestionMutation } from "@/lib/api/mutations/useUpdateGeminiSuggestionMutation"; +import { useCommitGeminiSuggestionMutation } from "@/lib/api/mutations/useCommitGeminiSuggestionMutation"; +import { usePullQuery } from "@/lib/api/queries/usePullQuery"; + +const mockUpdateMutate = jest.fn(); +const mockCommitMutate = jest.fn(); +const mockOnXClicked = jest.fn(); + +// Mock next/navigation +jest.mock("next/navigation", () => ({ + useParams: () => ({ + username: "owner", + repo_name: "repo", + id: "1", + }), +})); + +jest.mock("@/lib/api/queries/usePullQuery", () => ({ + usePullQuery: jest.fn(), +})); + +jest.mock("@../../../_hooks/usePermissionChecks", () => ({ + usePermissionChecks: jest.fn().mockReturnValue({ + accessType: "write", + hasCommentPermission: true, + hasWritePermission: true, + }), +})); + +jest.mock("@/lib/api/mutations/useUpdateGeminiSuggestionMutation", () => ({ + useUpdateGeminiSuggestionMutation: jest.fn(), +})); + +// Mock the Commit Mutation +jest.mock("@/lib/api/mutations/useCommitGeminiSuggestionMutation", () => ({ + useCommitGeminiSuggestionMutation: jest.fn(), +})); + +// Mock the Editor component to easily trigger onCodeChange +jest.mock("./_components/SuggestionDiffEditor", () => ({ + SuggestionDiffEditor: ({ onCodeChange }: SuggestionDiffEditorProps) => ( +
+ + +
+ ), +})); + +describe("SuggestionModuleContent", () => { + const defaultProps: SuggestionPopupProp = { + commentID: 101, + threadLine: 4, + fullFileCode: "line1\nline2\nline3\nline4\nline5\nline6", + filename: "src/utils/math.ts", + replaceStartLine: 2, + replaceEndLine: 4, + deletionContent: "line3\nline4", + additionContent: "newLine3\nnewLine4", + onXClicked: mockOnXClicked, + }; + + const carriageProps: SuggestionPopupProp = { + commentID: 101, + threadLine: 4, + fullFileCode: "line1\r\nline2\r\nline3\nline4\r\nline5\nline6", + filename: "src/utils/math.ts", + replaceStartLine: 2, + replaceEndLine: 4, + deletionContent: "line3\r\nline4", + additionContent: "newLine3\r\nnewLine4", + onXClicked: mockOnXClicked, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + (usePullQuery as jest.Mock).mockReturnValue({ + data: { + id: 1, + number: 1, + state: "open", + title: "Refactor math utilities", + body: "This PR introduces suggestions for math.ts", + head: { ref: "feature/math-update" }, + base: { ref: "main" }, + user: { login: "test-user" }, + }, + isLoading: false, + isError: false, + }); + + (useUpdateGeminiSuggestionMutation as jest.Mock).mockReturnValue({ + mutate: mockUpdateMutate, + isPending: false + }); + + (useCommitGeminiSuggestionMutation as jest.Mock).mockReturnValue({ + mutate: mockCommitMutate, + isPending: false + }); + }); + + it("renders the header and buttons correctly", () => { + render(); + + expect(screen.getByText("math.ts")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Update" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Commit" })).toBeInTheDocument(); + }); + + it("renders Loading... state when pull data is loading or missing", () => { + // Override the global mock specifically for this test + (usePullQuery as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + + render(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("shows pending states for Update and Commit buttons", () => { + (useUpdateGeminiSuggestionMutation as jest.Mock).mockReturnValue({ + mutate: mockUpdateMutate, + isPending: true + }); + + (useCommitGeminiSuggestionMutation as jest.Mock).mockReturnValue({ + mutate: mockCommitMutate, + isPending: true + }); + + render(); + + expect(screen.getByRole("button", { name: "Updating..." })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Committing..." })).toBeInTheDocument(); + }); + + it("prevents onUpdateClicked from firing if no changes are made", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Update" })); + expect(mockUpdateMutate).not.toHaveBeenCalled(); + }); + + it("calls update mutation with correct payload when code changes and Update is clicked", async () => { + const user = userEvent.setup(); + render(); + + // 1. Trigger a change in the mocked editor + await user.click(screen.getByTestId("trigger-change")); + + // 2. Click the Update button + await user.click(screen.getByRole("button", { name: "Update" })); + + // beforeCodeLength for "line1\nline2" is 2 + // relativeLineLocation = 2 + 1 - 4 = -1 + expect(mockUpdateMutate).toHaveBeenCalledWith({ + githubCommentId: 101, + deletionContent: "changedOriginal\n", + additionContent: "changedModified\n", + relativeLineLocation: -1, + }); + }); + + it("disables update functionality if changes are reverted back to original", async () => { + const user = userEvent.setup(); + render(); + + // 1. Change code + await user.click(screen.getByTestId("trigger-change")); + + // 2. Revert code to original + await user.click(screen.getByTestId("trigger-revert")); + + // 3. Click the Update button + await user.click(screen.getByRole("button", { name: "Update" })); + + // Should not fire because changes match initial state + expect(mockUpdateMutate).not.toHaveBeenCalled(); + }); + + it("calls commit mutation with correct payload and combined code when Commit is clicked", async () => { + const user = userEvent.setup(); + + // Modify the commit mock to automatically call onSuccess for this test + mockCommitMutate.mockImplementation((data, options) => { + if (options?.onSuccess) options.onSuccess(); + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Commit" })); + + // fileContent calculation based on initial props: + // beforeCode: "line1\nline2" + // additionContent: "newLine3\nnewLine4" + // afterCode: "line5\nline6" + const expectedFileContent = "line1\nline2newLine3\nnewLine4line5\nline6"; + + // relativeLineLocation = 2 + 1 - 4 = -1 + expect(mockCommitMutate).toHaveBeenCalledWith( + { + filename: "src/utils/math.ts", + content: expectedFileContent, + suggestionData: { + githubCommentId: 101, + deletionContent: "line3\nline4", + additionContent: "newLine3\nnewLine4", + relativeLineLocation: -1, + }, + }, + expect.any(Object) // The options object containing onSuccess + ); + + // Ensure the onSuccess callback properly triggered the X click + expect(mockOnXClicked).toHaveBeenCalledTimes(1); + }); + + it("calls update mutation with carriage returns", async () => { + const user = userEvent.setup(); + render(); + + // 1. Trigger a change in the mocked editor + await user.click(screen.getByTestId("trigger-change")); + + // 2. Click the Update button + await user.click(screen.getByRole("button", { name: "Update" })); + + // beforeCodeLength for "line1\nline2" is 2 + // relativeLineLocation = 2 + 1 - 4 = -1 + expect(mockUpdateMutate).toHaveBeenCalledWith({ + githubCommentId: 101, + deletionContent: "changedOriginal\r\n", + additionContent: "changedModified\r\n", + relativeLineLocation: -1, + }); + }); + + it("calls commit mutation with carriage returns", async () => { + const user = userEvent.setup(); + + mockCommitMutate.mockImplementation((data, options) => { + if (options?.onSuccess) options.onSuccess(); + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Commit" })); + + const expectedFileContent = "line1\r\nline2newLine3\r\nnewLine4line5\r\nline6"; + + // relativeLineLocation = 2 + 1 - 4 = -1 + expect(mockCommitMutate).toHaveBeenCalledWith( + { + filename: "src/utils/math.ts", + content: expectedFileContent, + suggestionData: { + githubCommentId: 101, + deletionContent: "line3\r\nline4", + additionContent: "newLine3\r\nnewLine4", + relativeLineLocation: -1, + }, + }, + expect.any(Object) // The options object containing onSuccess + ); + + // Ensure the onSuccess callback properly triggered the X click + expect(mockOnXClicked).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/SuggestionDiffEditor.test.tsx b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/SuggestionDiffEditor.test.tsx new file mode 100644 index 000000000..d306f8d3f --- /dev/null +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/SuggestionDiffEditor.test.tsx @@ -0,0 +1,151 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { SuggestionDiffEditor, SuggestionDiffEditorProps } from "./SuggestionDiffEditor"; +import { useDiffEditorSetup } from "./useDiffEditorSetup"; + +// 1. Mock the custom setup hook +jest.mock("./useDiffEditorSetup", () => ({ + useDiffEditorSetup: jest.fn(), +})); + +// 2. Mock the Monaco Editor +jest.mock("@monaco-editor/react", () => ({ + DiffEditor: jest.fn(({ original, modified, originalModelPath, modifiedModelPath, options }) => ( +
+
{original}
+
{modified}
+
{originalModelPath}
+
{modifiedModelPath}
+
{JSON.stringify(options)}
+
+ )), +})); + +describe("SuggestionDiffEditor", () => { + const mockHandleEditorMount = jest.fn(); + const mockOnCodeChange = jest.fn(); + + const defaultProps: SuggestionDiffEditorProps = { + beforeCode: "line1\nline2", + originalCode: "line3", + modifiedCode: "line3-changed", + afterCode: "line4\nline5", + hasCarriageReturn: false, + filename: "src/utils/math.ts", + onCodeChange: mockOnCodeChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useDiffEditorSetup as jest.Mock).mockReturnValue(mockHandleEditorMount); + }); + + it("renders correctly with Unix line endings (\\n)", () => { + render(); + + const expectedOriginal = "line1\nline2\nline3\nline4\nline5"; + const expectedModified = "line1\nline2\nline3-changed\nline4\nline5"; + + expect(screen.getByTestId("original-content").textContent).toBe(expectedOriginal); + expect(screen.getByTestId("modified-content").textContent).toBe(expectedModified); + }); + + it("renders correctly with Windows line endings (\\r\\n)", () => { + const propsWithCarriageReturn = { + ...defaultProps, + hasCarriageReturn: true, + beforeCode: "line1\r\nline2", + originalCode: "line3", + modifiedCode: "line3-changed", + afterCode: "line4\r\nline5", + }; + + render(); + + // buildFullCode combines using "\r\n" + const expectedOriginal = "line1\r\nline2\r\nline3\r\nline4\r\nline5"; + const expectedModified = "line1\r\nline2\r\nline3-changed\r\nline4\r\nline5"; + + expect(screen.getByTestId("original-content").textContent).toBe(expectedOriginal); + expect(screen.getByTestId("modified-content").textContent).toBe(expectedModified); + }); + + it("updates internal state when boundary props (beforeCode/afterCode) change", () => { + const { rerender } = render(); + + // Initial check + expect(screen.getByTestId("original-content").textContent).toBe("line1\nline2\nline3\nline4\nline5"); + + // Rerender with updated boundaries + const updatedProps = { + ...defaultProps, + beforeCode: "newLine1\nnewLine2", + afterCode: "newLine4\nnewLine5", + }; + + rerender(); + + // Check if the component caught the derived state change and updated + const expectedNewOriginal = "newLine1\nnewLine2\nline3\nnewLine4\nnewLine5"; + expect(screen.getByTestId("original-content").textContent).toBe(expectedNewOriginal); + }); + + it("passes correct model paths to Monaco", () => { + render(); + + expect(screen.getByTestId("original-path")).toHaveTextContent("original-src/utils/math.ts"); + expect(screen.getByTestId("modified-path")).toHaveTextContent("modified-src/utils/math.ts"); + }); + + it("passes correct configuration options to the DiffEditor", () => { + render(); + + const optionsString = screen.getByTestId("options").textContent; + const options = JSON.parse(optionsString || "{}"); + + expect(options).toMatchObject({ + automaticLayout: true, + lineHeight: 22, + renderSideBySide: true, + readOnly: false, + originalEditable: false, + wordWrap: "on", + minimap: { enabled: false }, + ignoreTrimWhitespace: false, + scrollBeyondLastLine: false, + renderOverviewRuler: false, + }); + }); + + it("initializes useDiffEditorSetup with the component props", () => { + render(); + + // Ensures our custom hook is called with all the props so it can set up Monaco correctly + expect(useDiffEditorSetup).toHaveBeenCalledWith(defaultProps); + }); + + it("handles undefined and null middle sections by converting them to empty strings", () => { + // Override the default props with undefined and null + const edgeCaseProps = { + ...defaultProps, + beforeCode: "line1\nline2", + afterCode: "line4\nline5", + hasCarriageReturn: false, + originalCode: undefined as unknown as string, + modifiedCode: null as unknown as string, + }; + + render(); + + // If the ternary operator works, `middle` becomes `""`. + // buildFullCode then joins: "line1\nline2" + "\n" + "" + "\n" + "line4\nline5" + // Which results in a double newline where the middle code should have been. + const expectedOutput = "line1\nline2\n\nline4\nline5"; + + // originalCode was undefined -> should resolve to expectedOutput + expect(screen.getByTestId("original-content").textContent).toBe(expectedOutput); + + // modifiedCode was null -> should resolve to expectedOutput + expect(screen.getByTestId("modified-content").textContent).toBe(expectedOutput); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.test.ts b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.test.ts new file mode 100644 index 000000000..c104a78b4 --- /dev/null +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.test.ts @@ -0,0 +1,175 @@ +import { + getLineCount, + getLines, + calculateExpandedRegions, + getLanguageIdFromFilename, + vsCodeLightPlus, +} from "./mountUtils"; // Replace with your actual file path +import { Monaco } from "@monaco-editor/react"; + +describe("SuggestionDiffEditor Utilities", () => { + describe("getLineCount", () => { + it("returns 0 for an empty string or falsy value", () => { + expect(getLineCount("")).toBe(0); + expect(getLineCount(undefined as unknown as string)).toBe(0); + }); + + it("returns 1 for a single line", () => { + expect(getLineCount("const x = 10;")).toBe(1); + }); + + it("returns the correct count for multiple lines", () => { + expect(getLineCount("line1\nline2\nline3")).toBe(3); + }); + }); + + describe("getLines", () => { + it("returns an empty array for an empty string or falsy value", () => { + expect(getLines("", false)).toEqual([]); + expect(getLines(undefined as unknown as string, false)).toEqual([]); + }); + + it("returns an array with a single element for a single line", () => { + expect(getLines("const x = 10;", false)).toEqual(["const x = 10;"]); + }); + + it("returns an array of lines for a multiline string", () => { + expect(getLines("line1\nline2\nline3", false)).toEqual(["line1", "line2", "line3"]); + }); + + it ("handles carriage returns", () => { + expect(getLines("line1\r\nline2\r\nline3", true)).toEqual(["line1", "line2", "line3"]); + }) + }); + + describe("calculateExpandedRegions", () => { + const defaultData = { + beforeCode: "line1\nline2", + originalCode: "line3", + modifiedCode: "line3-changed", + afterCode: "line4\nline5", + }; + + it("returns null if the clicked line is within the diff bounds", () => { + // beforeCode has 2 lines, so diff starts at line 3 and ends at line 3. + expect(calculateExpandedRegions(3, defaultData, false)).toBeNull(); + }); + + it("shifts 'beforeCode' into the diff region when clicking above the diff", () => { + // Clicking on line 1 should take 2 lines from 'beforeCode' (lines 1 and 2) + const result = calculateExpandedRegions(1, defaultData, false); + + expect(result).not.toBeNull(); + expect(result?.beforeCode).toBe(""); + expect(result?.originalCode).toBe("line1\nline2\nline3"); + expect(result?.modifiedCode).toBe("line1\nline2\nline3-changed"); + expect(result?.afterCode).toBe("line4\nline5"); + }); + + it("shifts 'afterCode' into the diff region when clicking below the diff", () => { + const result = calculateExpandedRegions(5, defaultData, false); + + expect(result).not.toBeNull(); + expect(result?.beforeCode).toBe("line1\nline2"); + expect(result?.originalCode).toBe("line3\nline4\nline5"); + expect(result?.modifiedCode).toBe("line3-changed\nline4\nline5"); + expect(result?.afterCode).toBe(""); + }); + + it("handles missing original or modified code gracefully when expanding before", () => { + const emptyMiddleData = { + beforeCode: "line1\nline2", + originalCode: "", + modifiedCode: "", + afterCode: "line4", + }; + + const result = calculateExpandedRegions(2, emptyMiddleData, false); + + expect(result?.beforeCode).toBe("line1"); + expect(result?.originalCode).toBe("line2"); + expect(result?.modifiedCode).toBe("line2"); + }); + + it("handles missing original or modified code gracefully when expanding after", () => { + const emptyMiddleData = { + beforeCode: "line1", + originalCode: "", + modifiedCode: "", + afterCode: "line3\nline4", + }; + + // Change from 3 to 2 to click on the first line of the afterCode + const result = calculateExpandedRegions(2, emptyMiddleData, false); + + expect(result?.afterCode).toBe("line4"); + expect(result?.originalCode).toBe("line3"); + expect(result?.modifiedCode).toBe("line3"); + }); + + it("calculates startLine as 1 when there is no beforeCode (diff is at the top of the file)", () => { + const topOfFileData = { + beforeCode: "", + originalCode: "line1", + modifiedCode: "line1-changed", + afterCode: "line2\nline3", + }; + + const result = calculateExpandedRegions(2, topOfFileData, false); + + expect(result).not.toBeNull(); + expect(result?.beforeCode).toBe(""); + + expect(result?.originalCode).toBe("line1\nline2"); + expect(result?.modifiedCode).toBe("line1-changed\nline2"); + + expect(result?.afterCode).toBe("line3"); + }); + }); + + describe("getLanguageIdFromFilename", () => { + let mockMonaco: Monaco; + + beforeEach(() => { + // Create a mock Monaco instance with a realistic getLanguages implementation + mockMonaco = { + languages: { + getLanguages: jest.fn().mockReturnValue([ + { id: "typescript", extensions: [".ts", ".tsx"] }, + { id: "javascript", extensions: [".js", ".jsx"] }, + { id: "csharp", extensions: [".cs"] }, + ]), + }, + } as unknown as Monaco; + }); + + it("returns 'plaintext' if no filename is provided", () => { + expect(getLanguageIdFromFilename("", mockMonaco)).toBe("plaintext"); + expect(getLanguageIdFromFilename(undefined as unknown as string, mockMonaco)).toBe("plaintext"); + }); + + it("returns the correct language id based on the file extension", () => { + expect(getLanguageIdFromFilename("index.ts", mockMonaco)).toBe("typescript"); + expect(getLanguageIdFromFilename("App.tsx", mockMonaco)).toBe("typescript"); + expect(getLanguageIdFromFilename("Program.cs", mockMonaco)).toBe("csharp"); + }); + + it("is case-insensitive when checking extensions", () => { + expect(getLanguageIdFromFilename("PROGRAM.CS", mockMonaco)).toBe("csharp"); + }); + + it("returns 'plaintext' if the extension is not found in the registry", () => { + expect(getLanguageIdFromFilename("data.unknown", mockMonaco)).toBe("plaintext"); + }); + }); + + describe("vsCodeLightPlus", () => { + it("exports the correct base theme object configuration", () => { + expect(vsCodeLightPlus).toBeDefined(); + expect(vsCodeLightPlus.base).toBe("vs"); + expect(vsCodeLightPlus.inherit).toBe(true); + expect(vsCodeLightPlus.rules.length).toBeGreaterThan(0); + expect(vsCodeLightPlus.colors["editor.background"]).toBe("#FFFFFF"); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.ts b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.ts index 9900772d5..b90381f31 100644 --- a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.ts +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/mountUtils.ts @@ -110,7 +110,7 @@ export const vsCodeLightPlus = { }; -function getLines(fileContent: string, hasCarriageReturn: boolean): string[] { +export function getLines(fileContent: string, hasCarriageReturn: boolean): string[] { const splitToken = hasCarriageReturn ? '\r\n' : '\n'; if (!fileContent) return []; diff --git a/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/useDiffEditorSetup.test.ts b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/useDiffEditorSetup.test.ts new file mode 100644 index 000000000..21eeb079a --- /dev/null +++ b/code-review-management/app/(pages)/(protected)/[username]/[repo_name]/pull/[id]/changes/_components/SuggestionModulePopup/_components/useDiffEditorSetup.test.ts @@ -0,0 +1,663 @@ +import { renderHook, act } from "@testing-library/react"; // or "@testing-library/react" depending on your version +import { useDiffEditorSetup } from "./useDiffEditorSetup"; +import * as mountUtils from "./mountUtils"; +import { Monaco } from "@monaco-editor/react"; +import type { editor } from "monaco-editor"; + +interface MockKeyEvent { + keyCode: number; + preventDefault: jest.Mock; + stopPropagation: jest.Mock; +} +// Mock the imported utilities +jest.mock("./mountUtils", () => ({ + getLineCount: jest.fn((str: string) => (str ? str.split("\n").length : 0)), + calculateExpandedRegions: jest.fn(), + getLanguageIdFromFilename: jest.fn().mockReturnValue("typescript"), + vsCodeLightPlus: { base: "vs", rules: [], colors: {} }, +})); + +jest.mock("./SuggestionDiffEditor.module.css", () => ({ + "monaco-hover-line-yellow": "mock-hover-class", + "monaco-expand-plus-btn": "mock-plus-btn-class", +})); + +// Helper to create a comprehensive, strongly-typed Monaco mock +const createMonacoMock = () => { + const createEmitter = () => { + type Listener = (data: T) => void; + const listeners: Listener[] = []; + return { + event: (cb: Listener) => { + listeners.push(cb); + return { dispose: jest.fn() }; + }, + fire: (data: T) => listeners.forEach((cb) => cb(data)), + }; + }; + + const cursorPositionEmitter = createEmitter<{ position: { lineNumber: number } }>(); + + interface MockSelection { + isEmpty: () => boolean; + startLineNumber?: number; + endLineNumber?: number; + } + + interface MockPosition { + lineNumber: number; + column: number; + } + + const keyDownEmitter = createEmitter(); + const mouseDownEmitter = createEmitter<{ target: { type: number; position?: { lineNumber: number } } }>(); + const mouseMoveEmitter = createEmitter<{ target: { type: number; position?: { lineNumber: number } } }>(); + const mouseLeaveEmitter = createEmitter(); + const modelContentEmitter = createEmitter(); + + const mockDecorations = { + set: jest.fn(), + clear: jest.fn(), + }; + + let currentLineCount = 10; + + const mockModel = { + setEOL: jest.fn(), + getLineCount: jest.fn(() => currentLineCount), // <-- Make this dynamic + getLineMaxColumn: jest.fn().mockReturnValue(50), + getValueInRange: jest.fn().mockReturnValue("new code content"), + }; + + let currentSelections: MockSelection[] = [ + { isEmpty: () => true, startLineNumber: 1, endLineNumber: 1 } + ]; + let currentPosition: MockPosition = { lineNumber: 1, column: 1 }; + + const createMockSubEditor = () => ({ + getModel: jest.fn().mockReturnValue(mockModel), + createDecorationsCollection: jest.fn().mockReturnValue(mockDecorations), + onDidChangeCursorPosition: cursorPositionEmitter.event, + onKeyDown: keyDownEmitter.event, + onMouseDown: mouseDownEmitter.event, + onMouseMove: mouseMoveEmitter.event, + onMouseLeave: mouseLeaveEmitter.event, + onDidChangeModelContent: modelContentEmitter.event, + updateOptions: jest.fn(), + getSelections: jest.fn(() => currentSelections), + getPosition: jest.fn(() => currentPosition), + revealLineNearTop: jest.fn(), + }); + + const mockModifiedEditor = createMockSubEditor(); + const mockOriginalEditor = createMockSubEditor(); + + const mockEditorInstance = { + getOriginalEditor: jest.fn().mockReturnValue(mockOriginalEditor), + getModifiedEditor: jest.fn().mockReturnValue(mockModifiedEditor), + }; + + const mockMonaco = { + editor: { + defineTheme: jest.fn(), + setTheme: jest.fn(), + setModelLanguage: jest.fn(), + EndOfLineSequence: { LF: 0, CRLF: 1 }, + MouseTargetType: { GUTTER_LINE_NUMBERS: 2, CONTENT_TEXT: 6 }, // Moved inside `editor` to fix the TypeError + }, + Range: jest.fn().mockImplementation((r1: number, c1: number, r2: number, c2: number) => ({ r1, c1, r2, c2 })), + KeyCode: { + Backspace: 1, Delete: 2, Enter: 3, Space: 4, + KeyA: 31, KeyZ: 56, Digit0: 21, Digit9: 30 + }, + languages: { + typescript: { + JsxEmit: { + React: 1, // Dummy value to prevent the undefined error + }, + // You will likely need to mock these defaults too, + // as setting compiler options usually calls these immediately after! + typescriptDefaults: { + setCompilerOptions: jest.fn(), + setDiagnosticsOptions: jest.fn(), + }, + javascriptDefaults: { + setCompilerOptions: jest.fn(), + setDiagnosticsOptions: jest.fn(), + }, + }, + }, + }; + + return { + mockMonaco, + mockEditorInstance, + mockModifiedEditor, + mockOriginalEditor, + mockModel, + mockDecorations, + setSelections: (selections: MockSelection[]) => { currentSelections = selections; }, + setPosition: (position: MockPosition) => { currentPosition = position; }, + setLineCount: (count: number) => { currentLineCount = count; }, + emitters: { + cursorPosition: cursorPositionEmitter, + keyDown: keyDownEmitter, + mouseDown: mouseDownEmitter, + mouseMove: mouseMoveEmitter, + mouseLeave: mouseLeaveEmitter, + modelContent: modelContentEmitter, + }, + }; +}; + +describe("useDiffEditorSetup", () => { + const defaultProps = { + beforeCode: "line1\nline2", // 2 lines -> Start line is 3 + originalCode: "line3", // 1 line + modifiedCode: "line3-changed", // 1 line -> End line is 3 + afterCode: "line4\nline5", // 2 lines + hasCarriageReturn: false, + filename: "test.ts", + onCodeChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // --- Initialization & Setup --- + + it("sets up themes, languages, EOL, and reveals top line on mount", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + expect(mockMonaco.editor.setTheme).toHaveBeenCalledWith("vs-light-plus"); + + // Test the setTimeout for revealLineNearTop (covers line 358) + jest.runAllTimers(); + expect(mockModifiedEditor.revealLineNearTop).toHaveBeenCalledWith(3); + }); + + it("sets EndOfLineSequence to CRLF for both original and modified models when hasCarriageReturn is true", () => { + const propsWithCR = { ...defaultProps, hasCarriageReturn: true }; + const { result } = renderHook(() => useDiffEditorSetup(propsWithCR)); + const { mockMonaco, mockEditorInstance, mockOriginalEditor, mockModifiedEditor } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + const originalModel = mockOriginalEditor.getModel(); + const modifiedModel = mockModifiedEditor.getModel(); + + expect(originalModel?.setEOL).toHaveBeenCalledWith( + mockMonaco.editor.EndOfLineSequence.CRLF + ); + + expect(modifiedModel?.setEOL).toHaveBeenCalledWith( + mockMonaco.editor.EndOfLineSequence.CRLF + ); + }); + + it("calculates startLine as 1 when beforeCode is entirely empty", () => { + // Empty beforeCode means beforeArr.length === 0, triggering the `: 1` fallback + const emptyBeforeProps = { ...defaultProps, beforeCode: "" }; + + const { result } = renderHook(() => useDiffEditorSetup(emptyBeforeProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Fast-forward the setTimeout + jest.runAllTimers(); + + // startLine should evaluate to 1, and revealLineNearTop is called with it + expect(mockModifiedEditor.revealLineNearTop).toHaveBeenCalledWith(1); + }); + + it("safely handles missing models during mount and decoration updates", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, mockOriginalEditor } = createMonacoMock(); + + // Force getModel to return null. + // This hits the `if (originalModel)` false branch (Lines 97-104) + // and the `?.getLineCount() || 0` fallback (Lines 119-120). + mockModifiedEditor.getModel.mockReturnValue(null); + mockOriginalEditor.getModel.mockReturnValue(null); + + // If it doesn't throw an error, it successfully bypassed those blocks! + expect(() => { + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + }).not.toThrow(); + + // Ensure setModelLanguage was NOT called because the models were null + expect(mockMonaco.editor.setModelLanguage).not.toHaveBeenCalled(); + }); + + it("bypasses keydown boundary checks if selections or position are null", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + const mockEvent: MockKeyEvent = { + keyCode: mockMonaco.KeyCode.Backspace, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + mockModifiedEditor.getSelections.mockReturnValueOnce( + null as unknown as ReturnType + ); + act(() => emitters.keyDown.fire(mockEvent)); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + + mockModifiedEditor.getPosition.mockReturnValueOnce( + null as unknown as ReturnType + ); + act(() => emitters.keyDown.fire(mockEvent)); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + + // --- Decorations Coverage (Lines 203-252) --- + + it("calculates and sets appropriate editor decorations", () => { + // 1. Tell Jest to control time + jest.useFakeTimers(); + + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // 2. Fast-forward the clock by 100ms so your setTimeout(..., 50) resolves + jest.advanceTimersByTime(100); + + const setCalls = mockModifiedEditor.createDecorationsCollection().set.mock.calls; + + // Now this will exist! + const decorationsArray: editor.IModelDeltaDecoration[] = setCalls[0][0]; + + // Use the correct Monaco type for the filter parameter + const dimDecorations = decorationsArray.filter( + (d: editor.IModelDeltaDecoration) => d.options.inlineClassName === "readOnlyTextDim" + ); + expect(dimDecorations.length).toBeGreaterThanOrEqual(2); + + const blockDecorations = decorationsArray.filter( + (d: editor.IModelDeltaDecoration) => d.options.className?.includes("modifiedBlock") + ); + expect(blockDecorations.length).toBeGreaterThan(0); + + // 3. Clean up the timers so subsequent tests aren't affected + jest.useRealTimers(); + }); + + // --- ReadOnly Enforcement (Lines 257-269) --- + + it("toggles readOnly mode when cursor moves in and out of boundaries", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Cursor at line 1 (outside bounds -> readonly) + act(() => emitters.cursorPosition.fire({ position: { lineNumber: 1 } })); + expect(mockModifiedEditor.updateOptions).toHaveBeenCalledWith({ readOnly: true }); + + // Cursor at line 3 (inside bounds -> editable) + act(() => emitters.cursorPosition.fire({ position: { lineNumber: 3 } })); + expect(mockModifiedEditor.updateOptions).toHaveBeenCalledWith({ readOnly: false }); + }); + + // --- Keydown Boundary Check (Lines 273-279, 299-316) --- + + it("prevents modifying keys when selection crosses boundaries", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, setSelections, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Simulate user selecting text from inside bounds (3) to outside bounds (4) + setSelections([{ isEmpty: () => false, startLineNumber: 3, endLineNumber: 4 }]); + + const mockEvent = { + keyCode: mockMonaco.KeyCode.Backspace, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + act(() => emitters.keyDown.fire(mockEvent)); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + it("prevents modifying alphanumeric keys (A-Z, 0-9) when selection crosses boundaries", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, setSelections, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Simulate user selecting text crossing bounds (Line 3 is editable, Line 4 is read-only) + setSelections([{ isEmpty: () => false, startLineNumber: 3, endLineNumber: 4 }]); + + // 1. Test Alphabet Key (e.g., KeyA) + const keyAEvent: MockKeyEvent = { + keyCode: mockMonaco.KeyCode.KeyA, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + act(() => emitters.keyDown.fire(keyAEvent)); + + expect(keyAEvent.preventDefault).toHaveBeenCalled(); + expect(keyAEvent.stopPropagation).toHaveBeenCalled(); + + // 2. Test Digit Key (e.g., Digit5) + const digitEvent: MockKeyEvent = { + keyCode: mockMonaco.KeyCode.Digit0 + 5, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + act(() => emitters.keyDown.fire(digitEvent)); + + expect(digitEvent.preventDefault).toHaveBeenCalled(); + expect(digitEvent.stopPropagation).toHaveBeenCalled(); + }); + + it("prevents backspace at col 1 of start bound and delete at max col of end bound", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, setPosition, setSelections, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Ensure selection is empty + setSelections([{ isEmpty: () => true }]); + + // Scenario A: Backspace at very beginning of start bound + setPosition({ lineNumber: 3, column: 1 }); // Start line is 3 + const backspaceEvent = { keyCode: mockMonaco.KeyCode.Backspace, preventDefault: jest.fn(), stopPropagation: jest.fn() }; + act(() => emitters.keyDown.fire(backspaceEvent)); + expect(backspaceEvent.preventDefault).toHaveBeenCalled(); + + // Scenario B: Delete at very end of end bound + setPosition({ lineNumber: 3, column: 50 }); // max col mocked to 50 + const deleteEvent = { keyCode: mockMonaco.KeyCode.Delete, preventDefault: jest.fn(), stopPropagation: jest.fn() }; + act(() => emitters.keyDown.fire(deleteEvent)); + expect(deleteEvent.preventDefault).toHaveBeenCalled(); + }); + + // --- Hover Interactions (Lines 286-296) --- + + it("sets hover decorations correctly and clears them on invalid targets or leaving", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + const mockDecorations = mockModifiedEditor.createDecorationsCollection(); + + // 1. Hover on an expandable line (line 2, outside bounds) + act(() => { + emitters.mouseMove.fire({ + target: { + type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, + position: { lineNumber: 2 }, + }, + }); + }); + expect(mockDecorations.set).toHaveBeenCalled(); + + // 2. Hover on an invalid target type (Content text instead of gutter) + act(() => { + emitters.mouseMove.fire({ + target: { type: mockMonaco.editor.MouseTargetType.CONTENT_TEXT, position: { lineNumber: 2 } }, + }); + }); + expect(mockDecorations.clear).toHaveBeenCalled(); + + // 3. Trigger Mouse Leave + mockDecorations.clear.mockClear(); + + // FIX: We must re-hover a valid line so hoveredLineRef isn't null when we leave! + act(() => { + emitters.mouseMove.fire({ + target: { type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, position: { lineNumber: 2 } }, + }); + }); + + // Now trigger leave + act(() => emitters.mouseLeave.fire({})); + expect(mockDecorations.clear).toHaveBeenCalled(); + }); + + it("clears hover state when moving mouse from an expandable to a non-expandable line", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + const mockDecorations = mockModifiedEditor.createDecorationsCollection(); + + // 1. Hover on an expandable line (line 2) to set hoveredLineRef internally + act(() => { + emitters.mouseMove.fire({ + target: { + type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, + position: { lineNumber: 2 }, // start bound is 3, so 2 is expandable + }, + }); + }); + + expect(mockDecorations.set).toHaveBeenCalled(); + + // 2. Hover on a NON-expandable line (line 3 is inside boundaries, isExpandable = false) + act(() => { + emitters.mouseMove.fire({ + target: { + type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, + position: { lineNumber: 3 }, + }, + }); + }); + + // This proves we hit the `else` block containing `clearHover()` + expect(mockDecorations.clear).toHaveBeenCalled(); + }); + + // --- Gutter Click & Code Change (Lines 322-326) --- + + it("triggers code change on gutter click with updated bounds logic", () => { + const mockCalculateRegions = mountUtils.calculateExpandedRegions as jest.Mock; + mockCalculateRegions.mockReturnValue({ + beforeCode: "line1", + originalCode: "line2\nline3", + modifiedCode: "line2\nline3-changed", + afterCode: "line4\nline5", + }); + + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + act(() => emitters.mouseMove.fire({ + target: { type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, position: { lineNumber: 2 } }, + })); + + act(() => emitters.mouseDown.fire({ + target: { type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, position: { lineNumber: 2 } }, + })); + + expect(mockCalculateRegions).toHaveBeenCalledWith(2, expect.any(Object), false); + expect(defaultProps.onCodeChange).toHaveBeenCalled(); + }); + + // --- Prop Updates, Edge Bounds & Content Extraction (Lines 75-76, 221-223, 314-315, 331-353) --- + + it("updates decorations on rerender and handles empty block boundaries", () => { + const emptyProps = { + beforeCode: "line1\nline2", // 2 lines -> start = 3 + originalCode: "", // 0 lines -> originalEnd = 2 + modifiedCode: "", // 0 lines -> end = 2 + afterCode: "", // 0 lines -> afterLines = 0 + hasCarriageReturn: false, + filename: "test.ts", + onCodeChange: jest.fn(), + }; + + const { result, rerender } = renderHook((props) => useDiffEditorSetup(props), { + initialProps: emptyProps + }); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters, setLineCount } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + mockModifiedEditor.createDecorationsCollection().set.mockClear(); + + // 1. Rerender with the new beforeCode + rerender({ ...emptyProps, beforeCode: "line1\nline2\nline3" }); + + // 2. Simulate the user typing a new line into the editor. + setLineCount(4); + + // 3. Ensure your mock `getValueInRange` returns the "newly typed" code + // Note: adjust the exact path to `getValueInRange` based on your createMonacoMock shape + mockModifiedEditor.getModel().getValueInRange.mockReturnValue("user typed this"); + + // 4. Fire the event + act(() => emitters.modelContent.fire({})); + + // 5. Expect the call with the new extracted text + expect(emptyProps.onCodeChange).toHaveBeenCalledWith( + "line1\nline2\nline3", + "", + "user typed this", + "" + ); + }); + + it("extracts text correctly during a standard model content change", () => { + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Firing model content will hit lines 331-353 successfully getting text + act(() => emitters.modelContent.fire({})); + + expect(defaultProps.onCodeChange).toHaveBeenCalledWith( + defaultProps.beforeCode, + defaultProps.originalCode, + "new code content", // comes from mockModel.getValueInRange + defaultProps.afterCode + ); + }); + + it("safely handles model content changes when model is null or onCodeChange is missing", () => { + type OnCodeChangeSignature = (before: string, original: string, modified: string, after: string) => void; + + const noCallbackProps = { + ...defaultProps, + onCodeChange: undefined as unknown as OnCodeChangeSignature + }; + + const { result } = renderHook(() => useDiffEditorSetup(noCallbackProps)); + const { mockMonaco, mockEditorInstance, mockModifiedEditor, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + act(() => emitters.modelContent.fire({})); + mockModifiedEditor.getModel.mockReturnValueOnce(null); + act(() => emitters.modelContent.fire({})); + + expect(true).toBe(true); + }); + + // --- Gutter Click & Code Change (Lines 322-326) --- + + it("triggers code change on gutter click with updated bounds logic", () => { + const mockCalculateRegions = mountUtils.calculateExpandedRegions as jest.Mock; + mockCalculateRegions.mockReturnValue({ + beforeCode: "line1", + originalCode: "line2\nline3", + modifiedCode: "line2\nline3-changed", + afterCode: "line4\nline5", + }); + + const { result } = renderHook(() => useDiffEditorSetup(defaultProps)); + const { mockMonaco, mockEditorInstance, emitters } = createMonacoMock(); + + result.current( + mockEditorInstance as unknown as editor.IStandaloneDiffEditor, + mockMonaco as unknown as Monaco + ); + + // Mock the required hover logic before click + act(() => emitters.mouseMove.fire({ + target: { type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, position: { lineNumber: 2 } }, + })); + + act(() => emitters.mouseDown.fire({ + target: { type: mockMonaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS, position: { lineNumber: 2 } }, + })); + + expect(mockCalculateRegions).toHaveBeenCalledWith(2, expect.any(Object), false); + expect(defaultProps.onCodeChange).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/single-file-content/route.test.ts b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/single-file-content/route.test.ts new file mode 100644 index 000000000..1d0fc2cba --- /dev/null +++ b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/single-file-content/route.test.ts @@ -0,0 +1,362 @@ +import { GET } from "./route"; +import { getToken, JWT } from "next-auth/jwt"; +import { Octokit, RequestError } from "octokit"; +import { FileNameParamsSchema } from "@/types/request.types"; +import { PullRequestSchema, FileContentSchema } from "@/types/github.types"; + +// Mock next-auth/jwt +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +// Define a strict type for the mock request to avoid 'any' +interface MockRequestOptions { + method: string; + url: string; + headers: Record; +} + +// Mock octokit +jest.mock("octokit", () => ({ + RequestError: class extends Error { + status: number; + request: MockRequestOptions; + + constructor(message: string, status: number, options: { request: MockRequestOptions }) { + super(message); + this.status = status; + this.name = "RequestError"; + this.request = options.request; + } + }, + Octokit: jest.fn(), +})); + +// Mock local utilities +jest.mock("@/app/api/_utils/cookie-utils", () => ({ + getCookieName: jest.fn(() => "authjs.session-token"), +})); + +// Mock Zod schemas +jest.mock("@/types/request.types", () => ({ + FileNameParamsSchema: { + safeParse: jest.fn(), + }, +})); + +jest.mock("@/types/github.types", () => ({ + PullRequestSchema: { + parse: jest.fn(), + }, + FileContentSchema: { + parse: jest.fn(), + }, +})); + +// Mock only treeifyError, keep the real ZodError class intact so `new ZodError()` works +jest.mock("zod", () => { + const actualZod = jest.requireActual("zod"); + return { + ...actualZod, + treeifyError: jest.fn(() => "mocked-zod-error"), + }; +}); + +// Define types for our mocks +interface MockOctokitInstance { + rest: { + pulls: { + get: jest.Mock; + }; + repos: { + getContent: jest.Mock; + }; + }; +} + +type RouteContext = { + params: Promise<{ + owner: string; + repo: string; + pull_number: string; + }>; +}; + +describe("GET /api/v1/{owner}/{repo}/pulls/{pull_number}/single-file-content", () => { + let mockRequest: Request; + let mockContext: RouteContext; + + const mockOctokitInstance: MockOctokitInstance = { + rest: { + pulls: { + get: jest.fn(), + }, + repos: { + getContent: jest.fn(), + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRequest = new Request( + "http://localhost:3000/api/v1/owner/repo/pulls/1/single-file-content?path=src/index.ts" + ); + + mockContext = { + params: Promise.resolve({ + owner: "test-owner", + repo: "test-repo", + pull_number: "1", + }), + }; + + jest + .mocked(Octokit) + .mockImplementation(() => mockOctokitInstance as unknown as Octokit); + + // Default successful schema mocks + jest.mocked(FileNameParamsSchema.safeParse).mockReturnValue({ + success: true, + data: "src/index.ts", + }); + }); + + describe("Authentication", () => { + it("should return 401 when token is null", async () => { + jest.mocked(getToken).mockResolvedValue(null); + + const response = await GET(mockRequest, mockContext); + + expect(response.status).toBe(401); + expect(jest.mocked(getToken)).toHaveBeenCalledWith({ + req: mockRequest, + secret: process.env.AUTH_SECRET, + cookieName: "authjs.session-token", + }); + }); + + it("should return 401 when accessToken is undefined", async () => { + const mockToken: JWT = { + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is null", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: null, + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + }); + + describe("Validation & Bad Requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 400 when path query parameter is invalid", async () => { + const { ZodError } = jest.requireActual("zod"); + const mockZodError = new ZodError([ + { + code: "custom", + path: ["path"], + message: "Required", + }, + ]); + + jest.mocked(FileNameParamsSchema.safeParse).mockReturnValue({ + success: false, + error: mockZodError, + } as ReturnType); + + const response = await GET(mockRequest, mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid query parameters"); + expect(data.details).toBe("mocked-zod-error"); + }); + + it("should return 400 when owner or repo is missing from params", async () => { + const badContext: RouteContext = { + params: Promise.resolve({ + owner: "", // Missing owner + repo: "test-repo", + pull_number: "1", + }), + }; + + const response = await GET(mockRequest, badContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 when PR head has no SHA", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: { id: 1 } }); + + jest.mocked(PullRequestSchema.parse).mockReturnValue({ + id: 1, + number: 1, + state: "open", + title: "Test PR", + head: undefined, + } as unknown as ReturnType); + + const response = await GET(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(400); + expect(text).toBe("No SHA at PR head"); + }); + }); + + describe("Successful requests", () => { + const validSha = "6dcb09b5b57875f334f61aebed695e2e4193db5e"; + + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 200 with decoded file content", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: { id: 1 } }); + jest.mocked(PullRequestSchema.parse).mockReturnValue({ + id: 1, + number: 1, + head: { + sha: validSha, + ref: "main", + label: "test-owner:main", + repo: { + name: "test-repo", + }, + }, + } as unknown as ReturnType); + + const base64Content = Buffer.from("const x = 1;", "utf-8").toString( + "base64" + ); + const mockFileResponse = { + type: "file", + encoding: "base64", + size: 12, + name: "index.ts", + path: "src/index.ts", + content: base64Content, + sha: validSha, + url: "https://api.github.com/repos/test-owner/test-repo/contents/src/index.ts", + git_url: "", + html_url: "", + download_url: "", + _links: { git: "", self: "", html: "" }, + }; + + mockOctokitInstance.rest.repos.getContent.mockResolvedValue({ + data: mockFileResponse, + }); + + jest.mocked(FileContentSchema.parse).mockReturnValue( + mockFileResponse as unknown as ReturnType< + typeof FileContentSchema.parse + > + ); + + const response = await GET(mockRequest, mockContext); + + expect(jest.mocked(Octokit)).toHaveBeenCalledWith({ auth: "valid-token" }); + expect(mockOctokitInstance.rest.pulls.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 1, + }); + expect(mockOctokitInstance.rest.repos.getContent).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + path: "src/index.ts", + ref: validSha, + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toBe("const x = 1;"); + }); + }); + + describe("Error handling", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should handle Octokit RequestError and return its status", async () => { + // Because we strictly defined this class in the factory mock, `instanceof` works natively + const mockError = new RequestError("Not Found", 404, { + request: { method: "GET", url: "https://api.github.com", headers: {} }, + }); + + mockOctokitInstance.rest.pulls.get.mockRejectedValue(mockError); + + const response = await GET(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(404); + expect(text).toBe("Not Found"); + }); + + it("should return 500 for schema parsing errors", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: {} }); + jest.mocked(PullRequestSchema.parse).mockImplementation(() => { + throw new Error("Zod parsing error"); + }); + + const response = await GET(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: Zod parsing error"); + }); + + it("should return 500 for generic unknown errors", async () => { + mockOctokitInstance.rest.pulls.get.mockRejectedValue( + new Error("Network failure") + ); + + const response = await GET(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: Network failure"); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/commit/route.test.ts b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/commit/route.test.ts new file mode 100644 index 000000000..14db05010 --- /dev/null +++ b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/commit/route.test.ts @@ -0,0 +1,405 @@ +import { POST } from "./route"; +import { getToken, JWT } from "next-auth/jwt"; +import { Octokit, RequestError } from "octokit"; +import { updateGeminiComment } from "@/lib/api/gemini/geminiCommentor"; +import { SuggestionCommitRequestShema } from "@/types/request.types"; +import { PullRequestSchema, GitHubFileDataSchema } from "@/types/github.types"; + +// Mock next-auth/jwt +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +// Mock Commentor +jest.mock("@/lib/api/gemini/geminiCommentor", () => ({ + updateGeminiComment: jest.fn(), +})); + +// Mock local utilities +jest.mock("@/app/api/_utils/cookie-utils", () => ({ + getCookieName: jest.fn(() => "authjs.session-token"), +})); + +// Mock Zod schemas +jest.mock("@/types/request.types", () => ({ + SuggestionCommitRequestShema: { + safeParse: jest.fn(), + }, +})); + +jest.mock("@/types/github.types", () => ({ + PullRequestSchema: { + parse: jest.fn(), + }, + GitHubFileDataSchema: { + parse: jest.fn(), + }, +})); + +// Define a strict type for the mock request to avoid 'any' +interface MockRequestOptions { + method: string; + url: string; + headers: Record; +} + +// Mock octokit +jest.mock("octokit", () => ({ + RequestError: class extends Error { + status: number; + request: MockRequestOptions; + + constructor( + message: string, + status: number, + options: { request: MockRequestOptions } + ) { + super(message); + this.status = status; + this.name = "RequestError"; + this.request = options.request; + } + }, + Octokit: jest.fn(), +})); + +// Define types for our mocks +interface MockOctokitInstance { + rest: { + pulls: { + get: jest.Mock; + }; + repos: { + getContent: jest.Mock; + createOrUpdateFileContents: jest.Mock; + }; + }; +} + +type RouteContext = { + params: Promise<{ + owner: string; + repo: string; + pull_number: string; + }>; +}; + +describe("POST /api/v1/{owner}/{repo}/pulls/{pull_number}/suggest/commit", () => { + let mockRequest: Request; + let mockContext: RouteContext; + let mockOctokitInstance: MockOctokitInstance; + + const mockSuggestionData = { + githubCommentId: 12345, + deletionContent: "- const old = true;", + additionContent: "+ const new = true;", + relativeLineLocation: 5, + }; + + const mockRequestBody = { + filename: "src/index.ts", + content: "const new = true;", + suggestionData: mockSuggestionData, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + process.env.AUTH_SECRET = "test-secret"; + + mockRequest = new Request( + "http://localhost:3000/api/v1/test-owner/test-repo/pulls/1/suggest/commit", + { + method: "POST", + body: JSON.stringify(mockRequestBody), + headers: { "Content-Type": "application/json" }, + } + ); + + mockContext = { + params: Promise.resolve({ + owner: "test-owner", + repo: "test-repo", + pull_number: "1", + }), + }; + + mockOctokitInstance = { + rest: { + pulls: { + get: jest.fn(), + }, + repos: { + getContent: jest.fn(), + createOrUpdateFileContents: jest.fn(), + }, + }, + }; + + jest + .mocked(Octokit) + .mockImplementation(() => mockOctokitInstance as unknown as Octokit); + + // Default successful schema mock + jest.mocked(SuggestionCommitRequestShema.safeParse).mockReturnValue({ + success: true, + data: mockRequestBody, + } as unknown as ReturnType); + }); + + describe("Authentication", () => { + it("should return 401 when token is null", async () => { + jest.mocked(getToken).mockResolvedValue(null); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + expect(jest.mocked(getToken)).toHaveBeenCalledWith({ + req: mockRequest, + secret: undefined, + cookieName: "authjs.session-token", + }); + }); + + it("should return 401 when accessToken is undefined", async () => { + const mockToken: JWT = { + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is null", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: null, + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + }); + + describe("Validation & Bad Requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 400 when owner or repo is missing", async () => { + const badContext: RouteContext = { + params: Promise.resolve({ + owner: "", // Missing owner + repo: "test-repo", + pull_number: "1", + }), + }; + + const response = await POST(mockRequest, badContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 when request schema parsing fails", async () => { + jest.mocked(SuggestionCommitRequestShema.safeParse).mockReturnValue({ + success: false, + error: new Error("Invalid Body"), + } as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 when there is no SHA at PR head", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: {} }); + jest.mocked(PullRequestSchema.parse).mockReturnValue({ + head: { ref: "feature-branch" }, + } as unknown as ReturnType); + + mockOctokitInstance.rest.repos.getContent.mockResolvedValue({ data: {} }); + jest.mocked(GitHubFileDataSchema.parse).mockReturnValue({ + sha: undefined, // Simulating missing SHA + } as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(400); + expect(text).toBe("No SHA at PR head"); + }); + }); + + describe("Successful requests", () => { + const validSha = "6dcb09b5b57875f334f61aebed695e2e4193db5e"; + const branchName = "feature-update"; + + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + + // Setup common successful PR & File responses + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: {} }); + jest.mocked(PullRequestSchema.parse).mockReturnValue({ + head: { ref: branchName }, + } as unknown as ReturnType); + + mockOctokitInstance.rest.repos.getContent.mockResolvedValue({ data: {} }); + mockOctokitInstance.rest.repos.createOrUpdateFileContents.mockResolvedValue({}); + + // Replaced 'any' with a strictly-typed null assertion + jest.mocked(updateGeminiComment).mockResolvedValue( + null as unknown as Awaited> + ); + }); + + it("should return 200 and execute commit & update simultaneously (Object SHA)", async () => { + // Mock File schema returning a single object + jest.mocked(GitHubFileDataSchema.parse).mockReturnValue({ + sha: validSha, + } as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + + // 1. Verify PR was fetched to get branch + expect(mockOctokitInstance.rest.pulls.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 1, + }); + + // 2. Verify File was fetched to get SHA + expect(mockOctokitInstance.rest.repos.getContent).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + path: "src/index.ts", + ref: branchName, + }); + + // 3. Verify Promise.all parallel calls (Commit) + expect(mockOctokitInstance.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + path: "src/index.ts", + message: "Committing suggestion", + content: Buffer.from("const new = true;").toString("base64"), // Base64 encoded + sha: validSha, + branch: branchName, + }); + + // 4. Verify Promise.all parallel calls (Comment Update with flag = true) + expect(jest.mocked(updateGeminiComment)).toHaveBeenCalledWith( + expect.any(Object), // octokit instance + "test-owner", + "test-repo", + mockSuggestionData, + true // The commit flag + ); + + // Verify Response + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ message: "Success" }); + }); + + it("should extract SHA correctly if GitHubFileDataSchema returns an array (directory)", async () => { + // Mock File schema returning an array (which can happen if path points to a dir) + jest.mocked(GitHubFileDataSchema.parse).mockReturnValue([ + { sha: "array-sha-123" }, + ] as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + + expect(mockOctokitInstance.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( + expect.objectContaining({ + sha: "array-sha-123", + }) + ); + + expect(response.status).toBe(200); + }); + }); + + describe("Error handling", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should handle Octokit RequestError and return its status", async () => { + const mockError = new RequestError("Conflict", 409, { + request: { method: "POST", url: "https://api.github.com", headers: {} }, + }); + + // Reject the initial PR fetch to trigger error early + mockOctokitInstance.rest.pulls.get.mockRejectedValue(mockError); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toBe("{\"message\":\"Failed to apply suggestion\",\"error\":\"Conflict\"}"); + }); + + it("should return 500 for schema parsing errors", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: {} }); + jest.mocked(PullRequestSchema.parse).mockImplementation(() => { + throw new Error("Zod parsing error"); + }); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: Zod parsing error"); + }); + + it("should return 500 for generic unknown errors during parallel execution", async () => { + mockOctokitInstance.rest.pulls.get.mockResolvedValue({ data: {} }); + jest.mocked(PullRequestSchema.parse).mockReturnValue({ + head: { ref: "main" }, + } as unknown as ReturnType); + + mockOctokitInstance.rest.repos.getContent.mockResolvedValue({ data: {} }); + jest.mocked(GitHubFileDataSchema.parse).mockReturnValue({ + sha: "valid-sha", + } as unknown as ReturnType); + + // Simulate a failure in one of the Promise.all calls + mockOctokitInstance.rest.repos.createOrUpdateFileContents.mockRejectedValue( + new Error("Git push failed") + ); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: Git push failed"); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/route.test.ts b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/route.test.ts new file mode 100644 index 000000000..23c4ff64f --- /dev/null +++ b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/route.test.ts @@ -0,0 +1,265 @@ +import { POST } from "./route"; +import { getToken, JWT } from "next-auth/jwt"; +import { Octokit, RequestError } from "octokit"; +import { generateSuggestion } from "@/lib/api/gemini/geminiOrchestrator"; +import { ThreadSuggestionRequestSchema } from "@/types/request.types"; + +// Mock next-auth/jwt +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +// Mock Orchestrator +jest.mock("@/lib/api/gemini/geminiOrchestrator", () => ({ + generateSuggestion: jest.fn(), +})); + +// Mock local utilities +jest.mock("@/app/api/_utils/cookie-utils", () => ({ + getCookieName: jest.fn(() => "authjs.session-token"), +})); + +// Mock Zod schemas +jest.mock("@/types/request.types", () => ({ + ThreadSuggestionRequestSchema: { + safeParse: jest.fn(), + }, +})); + +// Define a strict type for the mock request to avoid 'any' +interface MockRequestOptions { + method: string; + url: string; + headers: Record; +} + +// Mock octokit +jest.mock("octokit", () => ({ + RequestError: class extends Error { + status: number; + request: MockRequestOptions; + + constructor(message: string, status: number, options: { request: MockRequestOptions }) { + super(message); + this.status = status; + this.name = "RequestError"; + this.request = options.request; + } + }, + Octokit: jest.fn(), +})); + +type RouteContext = { + params: Promise<{ + owner: string; + repo: string; + pull_number: string; + }>; +}; + +describe("POST /api/v1/{owner}/{repo}/pulls/{pull_number}/suggest", () => { + let mockRequest: Request; + let mockContext: RouteContext; + const mockRequestBody = { threadId: "thread-123", comment: "Can we improve this?" }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRequest = new Request( + "http://localhost:3000/api/v1/test-owner/test-repo/pulls/1/suggest", + { + method: "POST", + body: JSON.stringify(mockRequestBody), + headers: { "Content-Type": "application/json" }, + } + ); + + mockContext = { + params: Promise.resolve({ + owner: "test-owner", + repo: "test-repo", + pull_number: "1", + }), + }; + + // Default successful schema mock + jest.mocked(ThreadSuggestionRequestSchema.safeParse).mockReturnValue({ + success: true, + data: mockRequestBody, + } as unknown as ReturnType); + }); + + describe("Authentication", () => { + it("should return 401 when token is null", async () => { + jest.mocked(getToken).mockResolvedValue(null); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + expect(jest.mocked(getToken)).toHaveBeenCalledWith({ + req: mockRequest, + secret: process.env.AUTH_SECRET, + cookieName: "authjs.session-token", + }); + }); + + it("should return 401 when accessToken is undefined", async () => { + const mockToken: JWT = { + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is null", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: null, + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + }); + + describe("Validation & Bad Requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 400 when owner, repo, or pull_number is missing", async () => { + const badContext: RouteContext = { + params: Promise.resolve({ + owner: "", // Missing owner + repo: "test-repo", + pull_number: "1", + }), + }; + + const response = await POST(mockRequest, badContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 when pull_number is not a valid number (NaN)", async () => { + const badContext: RouteContext = { + params: Promise.resolve({ + owner: "test-owner", + repo: "test-repo", + pull_number: "invalid-string", // Will evaluate to NaN + }), + }; + + const response = await POST(mockRequest, badContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 and extract error message when schema parsing fails", async () => { + // Mocking the specific error structure the route expects: JSON.parse(reqArgs.error.message)[0]["message"] + const mockErrorArray = [{ message: "Invalid thread structure provided" }]; + + jest.mocked(ThreadSuggestionRequestSchema.safeParse).mockReturnValue({ + success: false, + error: { + message: JSON.stringify(mockErrorArray), + }, + } as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid thread structure provided"); + }); + }); + + describe("Successful requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 200 and call generateSuggestion with correct parameters", async () => { + jest.mocked(generateSuggestion).mockResolvedValue(undefined); + + const response = await POST(mockRequest, mockContext); + + // Verify Octokit instantiation + expect(jest.mocked(Octokit)).toHaveBeenCalledWith({ auth: "valid-token" }); + + // Verify orchestrator call + // Note: We use expect.any(Object) for Octokit since it's instantiated inside the route + expect(jest.mocked(generateSuggestion)).toHaveBeenCalledWith( + expect.any(Object), // octokit instance + mockRequestBody, // reqArgs.data + "test-owner", // owner + "test-repo", // repo + 1 // castedPullNumber + ); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ message: "Success" }); + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); + }); + + describe("Error handling", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should handle Octokit RequestError and return its status", async () => { + const mockError = new RequestError("Rate Limit Exceeded", 429, { + request: { method: "POST", url: "https://api.github.com", headers: {} }, + }); + + // Mock the orchestrator to throw the Octokit error + jest.mocked(generateSuggestion).mockRejectedValue(mockError); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(429); + expect(text).toBe("Rate Limit Exceeded"); + }); + + it("should return 500 for generic unknown errors", async () => { + jest.mocked(generateSuggestion).mockRejectedValue(new Error("AI Model Timeout")); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: AI Model Timeout"); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/update/route.test.ts b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/update/route.test.ts new file mode 100644 index 000000000..d6efef29b --- /dev/null +++ b/code-review-management/app/api/v1/[owner]/[repo]/pulls/[pull_number]/suggest/update/route.test.ts @@ -0,0 +1,287 @@ +import { POST } from "./route"; +import { getToken, JWT } from "next-auth/jwt"; +import { Octokit, RequestError } from "octokit"; +import { updateGeminiComment } from "@/lib/api/gemini/geminiCommentor"; +import { SuggestionCommentUpdateRequestSchema } from "@/types/request.types"; + +// Mock next-auth/jwt +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +// Mock Commentor +jest.mock("@/lib/api/gemini/geminiCommentor", () => ({ + updateGeminiComment: jest.fn(), +})); + +// Mock local utilities +jest.mock("@/app/api/_utils/cookie-utils", () => ({ + getCookieName: jest.fn(() => "authjs.session-token"), +})); + +// Mock Zod schemas +jest.mock("@/types/request.types", () => ({ + SuggestionCommentUpdateRequestSchema: { + safeParse: jest.fn(), + }, +})); + +// Mock zod treeifyError but keep actual Zod objects working if needed +jest.mock("zod", () => { + const actualZod = jest.requireActual("zod"); + return { + ...actualZod, + treeifyError: jest.fn(() => "mocked-treeify-error"), + }; +}); + +// Define a strict type for the mock request to avoid 'any' +interface MockRequestOptions { + method: string; + url: string; + headers: Record; +} + +// Mock octokit +jest.mock("octokit", () => ({ + RequestError: class extends Error { + status: number; + request: MockRequestOptions; + + constructor( + message: string, + status: number, + options: { request: MockRequestOptions } + ) { + super(message); + this.status = status; + this.name = "RequestError"; + this.request = options.request; + } + }, + Octokit: jest.fn(), +})); + +type RouteContext = { + params: Promise<{ + owner: string; + repo: string; + pull_number: string; + }>; +}; + +describe("POST /api/v1/{owner}/{repo}/pulls/{pull_number}/suggest/update", () => { + let mockRequest: Request; + let mockContext: RouteContext; + + // Body matches the expected Zod Schema + const mockRequestBody = { + githubCommentId: 12345, + deletionContent: "- const old = true;", + additionContent: "+ const new = true;", + relativeLineLocation: 5, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRequest = new Request( + "http://localhost:3000/api/v1/test-owner/test-repo/pulls/1/suggest/update", + { + method: "POST", + body: JSON.stringify(mockRequestBody), + headers: { "Content-Type": "application/json" }, + } + ); + + mockContext = { + params: Promise.resolve({ + owner: "test-owner", + repo: "test-repo", + pull_number: "1", + }), + }; + + // Default successful schema mock + jest.mocked(SuggestionCommentUpdateRequestSchema.safeParse).mockReturnValue({ + success: true, + data: mockRequestBody, + } as unknown as ReturnType); + }); + + describe("Authentication", () => { + it("should return 401 when token is null", async () => { + jest.mocked(getToken).mockResolvedValue(null); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + expect(jest.mocked(getToken)).toHaveBeenCalledWith({ + req: mockRequest, + secret: process.env.AUTH_SECRET, + cookieName: "authjs.session-token", + }); + }); + + it("should return 401 when accessToken is undefined", async () => { + const mockToken: JWT = { + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is null", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: null, + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await POST(mockRequest, mockContext); + + expect(response.status).toBe(401); + }); + }); + + describe("Validation & Bad Requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 400 when owner, repo, or pull_number is missing", async () => { + const badContext: RouteContext = { + params: Promise.resolve({ + owner: "", // Missing owner + repo: "test-repo", + pull_number: "1", + }), + }; + + const response = await POST(mockRequest, badContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Missing required parameters"); + }); + + it("should return 400 with treeified error details when schema parsing fails", async () => { + // Mocking a failure from the zod schema parser + jest.mocked(SuggestionCommentUpdateRequestSchema.safeParse).mockReturnValue({ + success: false, + error: new Error("Mock Zod Error"), + } as unknown as ReturnType); + + const response = await POST(mockRequest, mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid query parameters"); + expect(data.details).toBe("mocked-treeify-error"); // Comes from our zod treeifyError mock + }); + }); + + describe("Successful requests", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 200 and the updated GitHub comment", async () => { + // Use 'as unknown as Awaited>' + // to bypass the strict type requirements for this mock data + const mockGithubComment = { + id: 12345, + body: "Updated comment body", + url: "https://api.github.com/comments/12345", + } as unknown as Awaited>; + + jest.mocked(updateGeminiComment).mockResolvedValue(mockGithubComment); + + const response = await POST(mockRequest, mockContext); + + // Verify Octokit instantiation and Commentor call + expect(jest.mocked(Octokit)).toHaveBeenCalledWith({ auth: "valid-token" }); + expect(jest.mocked(updateGeminiComment)).toHaveBeenCalledWith( + expect.any(Object), // octokit instance + "test-owner", // owner + "test-repo", // repo + mockRequestBody // suggestionUpdateData + ); + + // Verify Response + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ + id: 12345, + body: "Updated comment body", + url: "https://api.github.com/comments/12345", + }); + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); + }); + + describe("Error handling", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 500 when updateGeminiComment returns null", async () => { + // Testing the explicit null check inside the try block + jest.mocked(updateGeminiComment).mockResolvedValue(null); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain( + "Server error: Error: Error occured in update function, it returned null" + ); + }); + + it("should handle Octokit RequestError and return its status", async () => { + const mockError = new RequestError("Validation Failed", 422, { + request: { method: "POST", url: "https://api.github.com", headers: {} }, + }); + + jest.mocked(updateGeminiComment).mockRejectedValue(mockError); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(422); + expect(text).toBe("Validation Failed"); + }); + + it("should return 500 for generic unknown errors thrown during update", async () => { + jest.mocked(updateGeminiComment).mockRejectedValue(new Error("Database connection lost")); + + const response = await POST(mockRequest, mockContext); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).toContain("Server error: Error: Database connection lost"); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/app/api/v1/repos/route.test.ts b/code-review-management/app/api/v1/repos/route.test.ts new file mode 100644 index 000000000..da9c77b6f --- /dev/null +++ b/code-review-management/app/api/v1/repos/route.test.ts @@ -0,0 +1,250 @@ +import { GET } from "./route"; +import { Octokit } from "octokit"; +import { getToken, JWT } from "next-auth/jwt"; +import { getDefaultRepo } from "@/mocks/tests/repos"; +import { getDefaultUser } from "@/mocks/tests/users"; + +// Mock next-auth/jwt +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); + +// Mock octokit +jest.mock("octokit", () => ({ + // NOTE: This is a factory mock that replaces the entire module (which is + // probably why it resolves the initial errors), but I also don't know how to + // keep the original class type of RequestError :( + RequestError: jest.fn(), // Added this to avoid undefined error but might need fixing. + Octokit: jest.fn(), // Mocked in the beforeEach() +})); + +// Define types for our mocks +interface MockOctokitInstance { + rest: { + repos: { + listForAuthenticatedUser: jest.Mock; + }; + }; +} + +describe("GET /api/v1/repos", () => { + const mockRepos = [getDefaultRepo()]; + const mockOctokitInstance: MockOctokitInstance = { + rest: { + repos: { + listForAuthenticatedUser: jest.fn(), + }, + }, + }; + let mockRequest: Request; + + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + // Create a mock request + mockRequest = new Request("http://localhost:3000/api/v1/repos"); + jest + .mocked(Octokit) + .mockImplementation(() => mockOctokitInstance as unknown as Octokit); + }); + + describe("Authentication", () => { + it("should return 401 when token is null", async () => { + jest.mocked(getToken).mockResolvedValue(null); + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + expect(getToken).toHaveBeenCalledWith({ + req: mockRequest, + secret: undefined, // Need to mock process.env + cookieName: "authjs.session-token", + }); + }); + + it("should return 401 when accessToken is undefined", async () => { + const mockToken: JWT = { + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + }); + + it("should return 401 when accessToken is null", async () => { + const mockToken: JWT = { + accessToken: undefined, + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is null", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: null, + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + }); + + it("should return 401 when githubId is undefined", async () => { + const mockToken: JWT = { + accessToken: "valid-token", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + }); + }); + + describe("Successful requests", () => { + beforeEach(() => { + // Mock valid token + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + expiresAt: Date.now() + 3600000, // 1 hour from now + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + it("should return 200 with repos when authenticated", async () => { + mockOctokitInstance.rest.repos.listForAuthenticatedUser.mockResolvedValue( + { + data: mockRepos, + }, + ); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + expect(jest.mocked(Octokit)).toHaveBeenCalledWith({ + auth: "valid-token", + }); + expect( + mockOctokitInstance.rest.repos.listForAuthenticatedUser, + ).toHaveBeenCalled(); + + const data: unknown = await response.json(); + expect(Array.isArray(data)).toBe(true); + }); + + it("should filter repos using RepoSchema", async () => { + mockOctokitInstance.rest.repos.listForAuthenticatedUser.mockResolvedValue( + { + data: [ + { + id: 0, + name: "", + full_name: "", + owner: getDefaultUser(), + html_url: "", + description: "", + created_at: "", + updated_at: "", + pushed_at: "", + stargazers_count: 0, + watchers_count: 0, + open_issues_count: 0, + has_pull_requests: true, + visibility: "public", + extraField: "blah", + }, + ], + }, + ); + + const response = await GET(mockRequest); + const data = (await response.json()) as Record[]; + + expect(data[0]).not.toHaveProperty("extraField"); + }); + }); + + describe("Error handling", () => { + beforeEach(() => { + const mockToken: JWT = { + accessToken: "valid-token", + githubId: "12345", + githubLogin: "testuser", + }; + + jest.mocked(getToken).mockResolvedValue(mockToken); + }); + + // it("should handle Octokit RequestError with status", async () => { + // const mockError = Object.assign(new Error("Forbidden"), { + // name: "HttpError", + // status: 403, + // request: { + // method: "GET", + // url: "", + // headers: {}, + // }, + // }); + + // mockOctokitInstance.rest.repos.listForAuthenticatedUser.mockRejectedValue( + // mockError, + // ); + + // const response = await GET(mockRequest); + + // expect(response.status).toBe(403); + // const text = await response.text(); + // expect(text).toBe("Forbidden"); + // }); + + it("should return 500 for parsing errors", async () => { + const mockRepos = [ + { + // Invalid data that will fail RepoSchema.parse + id: "invalid-id", // Should be number + }, + ]; + + mockOctokitInstance.rest.repos.listForAuthenticatedUser.mockResolvedValue( + { + data: mockRepos, + }, + ); + + const response = await GET(mockRequest); + + expect(response.status).toBe(500); + const text = await response.text(); + expect(text).toBe("Server error"); + }); + + it("should return 500 for unknown errors", async () => { + mockOctokitInstance.rest.repos.listForAuthenticatedUser.mockRejectedValue( + new Error("Unknown error"), + ); + + const response = await GET(mockRequest); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/code-review-management/jest.config.ts b/code-review-management/jest.config.ts index 5590500a3..8ae7745f7 100644 --- a/code-review-management/jest.config.ts +++ b/code-review-management/jest.config.ts @@ -13,7 +13,7 @@ const config: Config = { coveragePathIgnorePatterns: ["mocks", "node_modules"], coverageDirectory: "coverage", coverageProvider: "v8", - testEnvironment: "jest-environment-jsdom", + testEnvironment: "./lib/jsdom-environment.ts", moduleNameMapper: { "^@/(.*)$": "/$1", "^@components/(.*)$": "/app/(pages)/_components/$1", diff --git a/code-review-management/lib/api/gemini/geminiCommentor.test.ts b/code-review-management/lib/api/gemini/geminiCommentor.test.ts new file mode 100644 index 000000000..d9036cb40 --- /dev/null +++ b/code-review-management/lib/api/gemini/geminiCommentor.test.ts @@ -0,0 +1,217 @@ +import { commentGeminiSuggestion, updateGeminiComment } from "./geminiCommentor"; // Update with your actual filename +import { Octokit } from "octokit"; +import { CommentSchema, Comment } from "@/types/github.types"; +import { + CodeEditResponse, + SuggestionCommentUpdateRequest, + ThreadSuggestionRequest, +} from "@/types/request.types"; + +// Mock the GitHub types schema +jest.mock("@/types/github.types", () => ({ + CommentSchema: { + parse: jest.fn(), + }, +})); + +describe("geminiCommentor", () => { + let mockOctokit: Octokit; + let originalConsoleError: typeof console.error; + let originalConsoleLog: typeof console.log; + + const mockOwner = "test-owner"; + const mockRepo = "test-repo"; + const mockPullNumber = 42; + + beforeAll(() => { + // Backup console methods + originalConsoleError = console.error; + originalConsoleLog = console.log; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Silence console output for error tests + console.error = jest.fn(); + console.log = jest.fn(); + + // Create deeply-typed mock Octokit instance + mockOctokit = { + rest: { + pulls: { + createReplyForReviewComment: jest.fn(), + updateReviewComment: jest.fn(), + }, + }, + } as unknown as Octokit; + }); + + afterAll(() => { + // Restore console methods + console.error = originalConsoleError; + console.log = originalConsoleLog; + }); + + describe("commentGeminiSuggestion", () => { + const mockFileContext = "line 1\nline 2\nline 3\nline 4\nline 5"; + + // Casting as unknown first if ThreadSuggestionRequest has other irrelevant required fields + const mockThread: ThreadSuggestionRequest = { + id: 123, + line: 1, + filePath: "src/test.ts", + sha: "mock-sha", + side: "RIGHT", + comments: [], + } as unknown as ThreadSuggestionRequest; + + it("should successfully format and post a markdown reply", async () => { + const mockSuggestion: CodeEditResponse = { + deleteRange: { minInclusiveLine: 2, maxExclusiveLine: 4 }, // Targets lines 2 and 3 + additionBlock: { insertionCode: "new line 2\nnew line 3" }, + }; + + jest.mocked(mockOctokit.rest.pulls.createReplyForReviewComment).mockResolvedValue( + {} as unknown as Awaited> + ); + + await commentGeminiSuggestion( + mockOctokit, + mockOwner, + mockRepo, + mockPullNumber, + mockSuggestion, + mockFileContext, + mockThread + ); + + // OPTION 2: Use objectContaining and stringMatching to verify the payload in one clean pass + expect(mockOctokit.rest.pulls.createReplyForReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: mockOwner, + repo: mockRepo, + pull_number: mockPullNumber, + comment_id: mockThread.id, + // Using regex stringMatching to verify multiple parts of the markdown body at once + body: expect.stringMatching(/[\s\S]*- line 2[\s\S]*- line 3[\s\S]*\+ new line 2[\s\S]*\+ new line 3/), + }) + ); + }); + + it("should catch and log errors if Octokit fails", async () => { + const mockSuggestion: CodeEditResponse = { + deleteRange: { minInclusiveLine: 1, maxExclusiveLine: 1 }, // No deletions + additionBlock: { insertionCode: "insertion" }, + }; + + const mockError = new Error("GitHub API Error"); + jest.mocked(mockOctokit.rest.pulls.createReplyForReviewComment).mockRejectedValue(mockError); + + await commentGeminiSuggestion( + mockOctokit, + mockOwner, + mockRepo, + mockPullNumber, + mockSuggestion, + mockFileContext, + mockThread + ); + + expect(console.error).toHaveBeenCalledWith("Failed to reply to review comment:", mockError); + }); + }); + + describe("updateGeminiComment", () => { + const mockSuggestionData: SuggestionCommentUpdateRequest = { + githubCommentId: 456, + deletionContent: "old content", + additionContent: "new content", + relativeLineLocation: 5, + }; + + it("should successfully update the comment with default 'taken' flag (false)", async () => { + const mockParsedComment = { id: 456, body: "updated body" } as unknown as Comment; + + jest.mocked(mockOctokit.rest.pulls.updateReviewComment).mockResolvedValue({ + data: { id: 456 }, + } as unknown as Awaited>); + + jest.mocked(CommentSchema.parse).mockReturnValue(mockParsedComment); + + const result = await updateGeminiComment(mockOctokit, mockOwner, mockRepo, mockSuggestionData); + + // OPTION 2: Object containing check + expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: mockOwner, + repo: mockRepo, + comment_id: mockSuggestionData.githubCommentId, + // Ensure it has the base tag but DOES NOT have the [Commited] tag + body: expect.stringMatching(//), + }) + ); + + expect(CommentSchema.parse).toHaveBeenCalledWith({ id: 456 }); + expect(result).toEqual(mockParsedComment); + }); + + it("should successfully update the comment with 'taken' flag set to true", async () => { + const mockParsedComment = { id: 456, body: "updated body" } as unknown as Comment; + + jest.mocked(mockOctokit.rest.pulls.updateReviewComment).mockResolvedValue({ + data: { id: 456 }, + } as unknown as Awaited>); + + jest.mocked(CommentSchema.parse).mockReturnValue(mockParsedComment); + + const result = await updateGeminiComment( + mockOctokit, + mockOwner, + mockRepo, + mockSuggestionData, + true + ); + + // OPTION 2: Object containing check for the "Commited" state + expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: mockOwner, + repo: mockRepo, + comment_id: mockSuggestionData.githubCommentId, + // Verify both the specific header and the tag suffix + body: expect.stringMatching(/### Gemini Suggestion \(Commited\)[\s\S]*/), + }) + ); + + expect(result).toEqual(mockParsedComment); + }); + + it("should return null and log an error if Octokit throws", async () => { + const mockError = new Error("Rate Limit Exceeded"); + jest.mocked(mockOctokit.rest.pulls.updateReviewComment).mockRejectedValue(mockError); + + const result = await updateGeminiComment(mockOctokit, mockOwner, mockRepo, mockSuggestionData); + + expect(console.log).toHaveBeenCalledWith("Error occured when updating gemini suggestion: Error: Rate Limit Exceeded"); + expect(result).toBeNull(); + expect(CommentSchema.parse).not.toHaveBeenCalled(); + }); + + it("should return null and log an error if Zod parsing fails", async () => { + jest.mocked(mockOctokit.rest.pulls.updateReviewComment).mockResolvedValue({ + data: { id: 456 }, + } as unknown as Awaited>); + + const mockZodError = new Error("Invalid Schema"); + jest.mocked(CommentSchema.parse).mockImplementation(() => { + throw mockZodError; + }); + + const result = await updateGeminiComment(mockOctokit, mockOwner, mockRepo, mockSuggestionData); + + expect(console.log).toHaveBeenCalledWith("Error occured when updating gemini suggestion: Error: Invalid Schema"); + expect(result).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/lib/api/gemini/geminiOrchestrator.test.ts b/code-review-management/lib/api/gemini/geminiOrchestrator.test.ts new file mode 100644 index 000000000..776fcc301 --- /dev/null +++ b/code-review-management/lib/api/gemini/geminiOrchestrator.test.ts @@ -0,0 +1,179 @@ +import { generateSuggestion } from "./geminiOrchestrator"; +import { Octokit } from "octokit"; +import { getFileDiffAndContent } from "./retrieveContext"; +import { getSystemPrompt, getUserPrompt } from "./prompt"; +import { callGeminiToGenerateSuggestion } from "./generateGeminiSuggestion"; +import { commentGeminiSuggestion } from "./geminiCommentor"; +import { ThreadSuggestionRequest } from "@/types/request.types"; + +// Mock the internal dependencies +jest.mock("./retrieveContext", () => ({ + getFileDiffAndContent: jest.fn(), +})); + +jest.mock("./prompt", () => ({ + getSystemPrompt: jest.fn(), + getUserPrompt: jest.fn(), +})); + +jest.mock("./generateGeminiSuggestion", () => ({ + callGeminiToGenerateSuggestion: jest.fn(), +})); + +jest.mock("./geminiCommentor", () => ({ + commentGeminiSuggestion: jest.fn(), +})); + +// Mock console.error to keep the test output clean during error tests +const originalConsoleError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalConsoleError; +}); + +// Define the expected Gemini response type based on your schema +interface MockGeminiResponse { + deleteRange: { + minInclusiveLine: number; + maxExclusiveLine: number; + }; + additionBlock: { + insertionCode: string; + }; +} + +describe("generateSuggestion Orchestrator", () => { + const mockOctokit = {} as Octokit; // Orchestrator only passes this down, so an empty object is fine + const mockOwner = "test-owner"; + const mockRepo = "test-repo"; + const mockPullNumber = 1; + + const mockThreadVal: ThreadSuggestionRequest = { + filePath: "src/utils.ts", + line: 42, + comments: ["Can we optimize this loop?"], + sha: "mock-sha-123", + } as unknown as ThreadSuggestionRequest; // Cast as unknown first if ThreadSuggestionRequest has other irrelevant required fields + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Successful Execution", () => { + it("should successfully orchestrate the flow and call commentGeminiSuggestion", async () => { + // 1. Setup Mock Returns + const mockFileContext = { content: "function test() { return true; }" }; + const mockSystemPrompt = "You are an AI assistant."; + const mockUserPrompt = "Fix this code: function test()..."; + const mockGeminiResponse: MockGeminiResponse = { + deleteRange: { minInclusiveLine: 40, maxExclusiveLine: 45 }, + additionBlock: { insertionCode: "function test() { return false; }" }, + }; + + jest.mocked(getFileDiffAndContent).mockResolvedValue(mockFileContext); + jest.mocked(getSystemPrompt).mockReturnValue(mockSystemPrompt); + jest.mocked(getUserPrompt).mockReturnValue(mockUserPrompt); + jest.mocked(callGeminiToGenerateSuggestion).mockResolvedValue( + mockGeminiResponse as unknown as Awaited> + ); + jest.mocked(commentGeminiSuggestion).mockResolvedValue(undefined); + + // 2. Execute + await generateSuggestion( + mockOctokit, + mockThreadVal, + mockOwner, + mockRepo, + mockPullNumber + ); + + // 3. Verify Context Retrieval + expect(jest.mocked(getFileDiffAndContent)).toHaveBeenCalledWith( + mockOctokit, + mockOwner, + mockRepo, + mockThreadVal.filePath, + mockThreadVal.sha + ); + + // 4. Verify Prompts + expect(jest.mocked(getSystemPrompt)).toHaveBeenCalled(); + expect(jest.mocked(getUserPrompt)).toHaveBeenCalledWith( + mockFileContext, + mockThreadVal.comments, + mockThreadVal.line + ); + + // 5. Verify Gemini Call + expect(jest.mocked(callGeminiToGenerateSuggestion)).toHaveBeenCalledWith( + mockSystemPrompt, + mockUserPrompt + ); + + // 6. Verify Commentor Call + expect(jest.mocked(commentGeminiSuggestion)).toHaveBeenCalledWith( + mockOctokit, + mockOwner, + mockRepo, + mockPullNumber, + mockGeminiResponse, + mockFileContext.content, + mockThreadVal + ); + }); + }); + + describe("Error Handling", () => { + it("should catch, log, and rethrow errors from retrieveContext", async () => { + const mockError = new Error("Failed to fetch file content"); + jest.mocked(getFileDiffAndContent).mockRejectedValue(mockError); + + await expect( + generateSuggestion( + mockOctokit, + mockThreadVal, + mockOwner, + mockRepo, + mockPullNumber + ) + ).rejects.toThrow("Failed to fetch file content"); + + expect(console.error).toHaveBeenCalledWith( + `Error generating gemini data for ${mockThreadVal.filePath}: `, + mockError + ); + + // Verify execution stopped + expect(jest.mocked(getSystemPrompt)).not.toHaveBeenCalled(); + expect(jest.mocked(callGeminiToGenerateSuggestion)).not.toHaveBeenCalled(); + }); + + it("should catch, log, and rethrow errors from the Gemini call", async () => { + const mockFileContext = { content: "const x = 1;" }; + jest.mocked(getFileDiffAndContent).mockResolvedValue(mockFileContext); + + const mockError = new Error("Gemini API rate limit exceeded"); + jest.mocked(callGeminiToGenerateSuggestion).mockRejectedValue(mockError); + + await expect( + generateSuggestion( + mockOctokit, + mockThreadVal, + mockOwner, + mockRepo, + mockPullNumber + ) + ).rejects.toThrow("Gemini API rate limit exceeded"); + + expect(console.error).toHaveBeenCalledWith( + `Error generating gemini data for ${mockThreadVal.filePath}: `, + mockError + ); + + // Verify commentor was never called + expect(jest.mocked(commentGeminiSuggestion)).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/lib/api/gemini/generateGeminiSuggestion.test.ts b/code-review-management/lib/api/gemini/generateGeminiSuggestion.test.ts new file mode 100644 index 000000000..6c9e11ca1 --- /dev/null +++ b/code-review-management/lib/api/gemini/generateGeminiSuggestion.test.ts @@ -0,0 +1,216 @@ +import { callGeminiToGenerateSuggestion } from "./generateGeminiSuggestion"; // Update with your actual filename +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { CodeEditResponseSchema } from "@/types/request.types"; + +// Mock the Gemini SDK and its enums +jest.mock("@google/generative-ai", () => { + return { + GoogleGenerativeAI: jest.fn(), + SchemaType: { + OBJECT: "OBJECT", + INTEGER: "INTEGER", + STRING: "STRING", + }, + }; +}); + +// Mock the Zod schema +jest.mock("@/types/request.types", () => ({ + CodeEditResponseSchema: { + parse: jest.fn(), + }, +})); + +// Define strict types for our mocked Gemini return values +interface MockGeminiResponse { + response: { + text: () => string; + }; +} + +describe("callGeminiToGenerateSuggestion", () => { + let originalEnv: NodeJS.ProcessEnv; + let originalConsoleLog: typeof console.log; + + const mockGenerateContent = jest.fn(); + const mockGetGenerativeModel = jest.fn(); + + const mockSystemPrompt = "System Instruction"; + const mockUserPrompt = "User Instruction"; + + beforeAll(() => { + // Backup environment and console + originalEnv = process.env; + originalConsoleLog = console.log; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Silence console.log to keep test runner output clean + console.log = jest.fn(); + + // Inject test API key + process.env = { ...originalEnv, GEMINI_API_KEY: "test-api-key" }; + + // Setup the mocked chain: GoogleGenerativeAI -> getGenerativeModel -> generateContent + mockGetGenerativeModel.mockReturnValue({ + generateContent: mockGenerateContent, + }); + + jest.mocked(GoogleGenerativeAI).mockImplementation( + () => + ({ + getGenerativeModel: mockGetGenerativeModel, + } as unknown as GoogleGenerativeAI) + ); + }); + + afterAll(() => { + // Restore environment and console + process.env = originalEnv; + console.log = originalConsoleLog; + }); + + describe("Initialization & Validation", () => { + it("should throw an error if GEMINI_API_KEY is missing", async () => { + delete process.env.GEMINI_API_KEY; + + await expect( + callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt) + ).rejects.toThrow("GEMINI_API_KEY is not set in the environment."); + + // Ensure model was never instantiated + expect(GoogleGenerativeAI).not.toHaveBeenCalled(); + }); + }); + + describe("Successful Executions", () => { + it("should successfully call Gemini, parse an object response, and validate it with Zod", async () => { + const mockRawResponse = { + deleteRange: { minInclusiveLine: 1, maxExclusiveLine: 2 }, + additionBlock: { insertionCode: "const a = 1;" }, + }; + + const mockGeminiReturn: MockGeminiResponse = { + response: { + text: () => JSON.stringify(mockRawResponse), + }, + }; + + mockGenerateContent.mockResolvedValue( + mockGeminiReturn as unknown as Awaited> + ); + + jest.mocked(CodeEditResponseSchema.parse).mockReturnValue( + mockRawResponse as unknown as ReturnType + ); + + const result = await callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt); + + // Verify Model Config + expect(mockGetGenerativeModel).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gemini-3-flash-preview", + systemInstruction: mockSystemPrompt, + generationConfig: expect.objectContaining({ + responseMimeType: "application/json", + }), + }) + ); + + // Verify Execution + expect(mockGenerateContent).toHaveBeenCalledWith(mockUserPrompt); + expect(jest.mocked(CodeEditResponseSchema.parse)).toHaveBeenCalledWith(mockRawResponse); + expect(result).toEqual(mockRawResponse); + }); + + it("should extract the first element if Gemini returns a JSON array", async () => { + const mockRawResponse = { + deleteRange: { minInclusiveLine: 5, maxExclusiveLine: 5 }, + additionBlock: { insertionCode: "// new comment" }, + }; + + // Wrap the response in an array (Simulating Gemini's occasional behavior) + const mockGeminiReturn: MockGeminiResponse = { + response: { + text: () => JSON.stringify([mockRawResponse]), + }, + }; + + mockGenerateContent.mockResolvedValue( + mockGeminiReturn as unknown as Awaited> + ); + + jest.mocked(CodeEditResponseSchema.parse).mockReturnValue( + mockRawResponse as unknown as ReturnType + ); + + const result = await callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt); + + // Validation should only receive the extracted object, NOT the array + expect(jest.mocked(CodeEditResponseSchema.parse)).toHaveBeenCalledWith(mockRawResponse); + expect(result).toEqual(mockRawResponse); + }); + }); + + describe("Error Handling", () => { + it("should catch, log, and rethrow errors from the Gemini API", async () => { + const mockError = new Error("API Limit Reached"); + mockGenerateContent.mockRejectedValue(mockError); + + await expect( + callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt) + ).rejects.toThrow("API Limit Reached"); + + expect(console.log).toHaveBeenCalledWith( + "Error when calling gemini: Error: API Limit Reached" + ); + }); + + it("should catch, log, and rethrow JSON parsing errors", async () => { + const mockGeminiReturn: MockGeminiResponse = { + response: { + text: () => "invalid-json-string", + }, + }; + + mockGenerateContent.mockResolvedValue( + mockGeminiReturn as unknown as Awaited> + ); + + await expect( + callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt) + ).rejects.toThrow(SyntaxError); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("Error when calling gemini: SyntaxError") + ); + }); + + it("should catch, log, and rethrow Zod validation errors", async () => { + const mockGeminiReturn: MockGeminiResponse = { + response: { + text: () => JSON.stringify({ invalid: "data" }), + }, + }; + + mockGenerateContent.mockResolvedValue( + mockGeminiReturn as unknown as Awaited> + ); + + const mockZodError = new Error("Zod Validation Failed"); + jest.mocked(CodeEditResponseSchema.parse).mockImplementation(() => { + throw mockZodError; + }); + + await expect( + callGeminiToGenerateSuggestion(mockSystemPrompt, mockUserPrompt) + ).rejects.toThrow("Zod Validation Failed"); + + expect(console.log).toHaveBeenCalledWith( + "Error when calling gemini: Error: Zod Validation Failed" + ); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/lib/api/gemini/prompt.test.ts b/code-review-management/lib/api/gemini/prompt.test.ts new file mode 100644 index 000000000..7839532c3 --- /dev/null +++ b/code-review-management/lib/api/gemini/prompt.test.ts @@ -0,0 +1,89 @@ +import { getSystemPrompt, getUserPrompt } from "./prompt"; // Update with your actual filename +import { Comment } from "@/types/github.types"; +import { FileContext } from "./retrieveContext"; + +describe("Prompt Generators", () => { + describe("getSystemPrompt", () => { + it("should return the static system prompt as a string", () => { + const prompt = getSystemPrompt(); + + // Verify it returns a string + expect(typeof prompt).toBe("string"); + + // Verify it contains the critical instructional constraints + expect(prompt).toContain("RULES:"); + expect(prompt).toContain("1) Do not add any explanations or backticks"); + expect(prompt).toContain("directly replaced as is"); + }); + }); + + describe("getUserPrompt", () => { + it("should correctly number file content lines and format comments", () => { + const mockFileContext: FileContext = { + content: "function hello() {\n console.log('world');\n}", + }; + + // Mock only the fields of the Comment type that the function actually touches + const mockComments = [ + { + user: { login: "reviewer-1" }, + body: "Consider using a standard return here.", + }, + { + user: { login: "reviewer-2" }, + body: "Agreed, this looks off.", + }, + ] as unknown as Comment[]; + + const targetLine = 2; + + const result = getUserPrompt(mockFileContext, mockComments, targetLine); + + // 1. Verify Line Numbering + expect(result).toContain("1 | function hello() {"); + expect(result).toContain("2 | console.log('world');"); + expect(result).toContain("3 | }"); + + // 2. Verify Target Line Injection + expect(result).toContain("Comment Line:\n 2"); + + // 3. Verify Comments Formatting (JSON stringified) + expect(result).toContain('"user": "reviewer-1"'); + expect(result).toContain('"body": "Consider using a standard return here."'); + expect(result).toContain('"user": "reviewer-2"'); + }); + + it("should handle empty file content safely", () => { + const mockFileContext: FileContext = { + content: "", + }; + + const mockComments = [] as unknown as Comment[]; + + const result = getUserPrompt(mockFileContext, mockComments, 1); + + // An empty string split by \n results in an array with one empty string element + expect(result).toContain("1 | "); + expect(result).toContain("[]"); // Empty JSON array for comments + }); + + it("should handle the fallback where comment.user is a string rather than an object", () => { + // Testing the `comment.user.login || comment.user` fallback logic + const mockFileContext: FileContext = { + content: "const x = 1;", + }; + + const mockComments = [ + { + user: "string-fallback-user", // No .login property + body: "This is a fallback test.", + }, + ] as unknown as Comment[]; + + const result = getUserPrompt(mockFileContext, mockComments, 1); + + expect(result).toContain('"user": "string-fallback-user"'); + expect(result).toContain('"body": "This is a fallback test."'); + }); + }); +}); \ No newline at end of file diff --git a/code-review-management/lib/api/gemini/retrieveContext.test.ts b/code-review-management/lib/api/gemini/retrieveContext.test.ts new file mode 100644 index 000000000..eb7ad4ae6 --- /dev/null +++ b/code-review-management/lib/api/gemini/retrieveContext.test.ts @@ -0,0 +1,132 @@ +import { Octokit } from "octokit"; +import { getFileDiffAndContent } from "./retrieveContext"; // Update with your actual filename + +describe("getFileDiffAndContent", () => { + let mockOctokit: Octokit; + let originalConsoleError: typeof console.error; + + const mockOwner = "test-owner"; + const mockRepo = "test-repo"; + const mockFilePath = "src/index.ts"; + const mockSha = "abcdef1234567890"; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock console.error to keep test output clean during the error test + originalConsoleError = console.error; + console.error = jest.fn(); + + // Create a deeply typed mock Octokit instance + mockOctokit = { + rest: { + repos: { + getContent: jest.fn(), + }, + }, + } as unknown as Octokit; + }); + + afterEach(() => { + // Restore the original console.error + console.error = originalConsoleError; + }); + + it("should fetch, decode, and return the file content successfully", async () => { + const rawString = "const hello = 'world';"; + const base64EncodedString = Buffer.from(rawString).toString("base64"); + + // Mock a successful Octokit response for a single file using strict return type inference + jest.mocked(mockOctokit.rest.repos.getContent).mockResolvedValue({ + data: { + type: "file", + content: base64EncodedString, + }, + } as unknown as Awaited>); + + const result = await getFileDiffAndContent( + mockOctokit, + mockOwner, + mockRepo, + mockFilePath, + mockSha + ); + + // Verify Octokit was called with the right parameters + expect(mockOctokit.rest.repos.getContent).toHaveBeenCalledWith({ + owner: mockOwner, + repo: mockRepo, + path: mockFilePath, + ref: mockSha, + }); + + // Verify the base64 string was decoded correctly + expect(result).toEqual({ content: rawString }); + }); + + it("should return an empty string if the GitHub API returns an array (e.g., a directory)", async () => { + // Mock the API returning an array of files instead of a single file object + jest.mocked(mockOctokit.rest.repos.getContent).mockResolvedValue({ + data: [ + { type: "file", name: "file1.ts" }, + { type: "file", name: "file2.ts" }, + ], + } as unknown as Awaited>); + + const result = await getFileDiffAndContent( + mockOctokit, + mockOwner, + mockRepo, + "src/directory", + mockSha + ); + + // Because !Array.isArray(contentData) fails, content remains "" + expect(result).toEqual({ content: "" }); + }); + + it("should return an empty string if the data type is not 'file' (e.g., symlink or submodule)", async () => { + // Mock the API returning a non-file type + jest.mocked(mockOctokit.rest.repos.getContent).mockResolvedValue({ + data: { + type: "symlink", + content: "base64-encoded-target-path", + }, + } as unknown as Awaited>); + + const result = await getFileDiffAndContent( + mockOctokit, + mockOwner, + mockRepo, + "src/symlink", + mockSha + ); + + // Because type === "file" fails, content remains "" + expect(result).toEqual({ content: "" }); + }); + + it("should catch, log, and rethrow errors from Octokit", async () => { + const mockError = new Error("Not Found"); + + // Simulate an API failure + jest.mocked(mockOctokit.rest.repos.getContent).mockRejectedValue(mockError); + + // Verify the function throws the error upwards + await expect( + getFileDiffAndContent( + mockOctokit, + mockOwner, + mockRepo, + mockFilePath, + mockSha + ) + ).rejects.toThrow("Not Found"); + + // Verify the error was properly logged to the console + expect(console.error).toHaveBeenCalledWith( + `Error fetching data for ${mockFilePath} at ${mockSha}:`, + mockError + ); + }); +}); \ No newline at end of file diff --git a/code-review-management/lib/api/gemini/retrieveContext.ts b/code-review-management/lib/api/gemini/retrieveContext.ts index bbc70ee27..802e52916 100644 --- a/code-review-management/lib/api/gemini/retrieveContext.ts +++ b/code-review-management/lib/api/gemini/retrieveContext.ts @@ -5,7 +5,7 @@ export interface FileContext { } /** - * Fetches the diff and full contents of a specific file at a specific commit. + * Fetches the full contents of a specific file at a specific commit. * * @param octokit An authenticated Octokit instance * @param owner The repository owner (e.g., "octocat") * @param repo The repository name (e.g., "hello-world") diff --git a/code-review-management/lib/jsdom-environment.ts b/code-review-management/lib/jsdom-environment.ts new file mode 100644 index 000000000..d66a3d138 --- /dev/null +++ b/code-review-management/lib/jsdom-environment.ts @@ -0,0 +1,16 @@ +import JSDOMEnvironment from "jest-environment-jsdom"; + +/** + * Docs: https://stackoverflow.com/a/77258896 + */ + +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args: ConstructorParameters) { + super(...args); + + this.global.fetch = fetch; + this.global.Headers = Headers; + this.global.Request = Request; + this.global.Response = Response; + } +} diff --git a/code-review-management/package.json b/code-review-management/package.json index 1c099d1a2..1caab1479 100644 --- a/code-review-management/package.json +++ b/code-review-management/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "jest" + "test": "jest", + "test-silent": "jest --silent" }, "dependencies": { "@monaco-editor/react": "^4.7.0",