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",