From ed4682fce881797d20d1d2da336e25019f04cb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Efe=20Ak=C3=A7a?= Date: Fri, 20 Dec 2024 16:23:14 +0300 Subject: [PATCH 1/2] chore(frontend): fixed coverage report and added some tests --- .../HighlightedQuestionCard.tsx.html | 286 ++++++++++++++++++ frontend/src/components/AnswerCard.test.tsx | 68 +++++ frontend/src/components/CodeSnippet.test.tsx | 134 ++++++++ frontend/src/components/CustomAnchor.test.tsx | 101 +++++++ frontend/src/components/ExerciseCard.test.tsx | 54 ++++ .../components/SearchQuestionsList.test.tsx | 167 ++++++++++ frontend/src/components/Tag.test.tsx | 102 +++++++ frontend/src/components/Tag.tsx | 1 + frontend/src/components/Tags.test.tsx | 189 ++++++++++++ frontend/src/components/Tags.tsx | 6 +- frontend/src/routes/create-question.test.tsx | 141 +++++++++ frontend/src/routes/create-question.tsx | 52 ++-- frontend/tests/setup.ts | 6 + frontend/vitest.config.ts | 31 ++ 14 files changed, 1314 insertions(+), 24 deletions(-) create mode 100644 frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html create mode 100644 frontend/src/components/AnswerCard.test.tsx create mode 100644 frontend/src/components/CodeSnippet.test.tsx create mode 100644 frontend/src/components/CustomAnchor.test.tsx create mode 100644 frontend/src/components/ExerciseCard.test.tsx create mode 100644 frontend/src/components/SearchQuestionsList.test.tsx create mode 100644 frontend/src/components/Tag.test.tsx create mode 100644 frontend/src/components/Tags.test.tsx create mode 100644 frontend/src/routes/create-question.test.tsx diff --git a/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html new file mode 100644 index 00000000..c18ad8ef --- /dev/null +++ b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html @@ -0,0 +1,286 @@ + + + + + + Code coverage report for frontend/src/components/HighlightedQuestionCard.tsx + + + + + + + + + +
+
+

All files / frontend/src/components HighlightedQuestionCard.tsx

+
+ +
+ 4.54% + Statements + 2/44 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 4.54% + Lines + 2/44 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +681x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Card } from "@/components/ui/card";
+import { QuestionSummary } from "@/services/api/programmingForumSchemas";
+ 
+import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react";
+import React from "react";
+import { Link } from "react-router-dom";
+import placeholderProfile from "@/assets/placeholder_profile.png";
+ 
+function capitalizeString(difficulty: string): React.ReactNode {
+  return difficulty.charAt(0).toUpperCase() + difficulty.slice(1);
+}
+export const HighlightedQuestionCard: React.FC<Partial<QuestionSummary>> = ({
+  id,
+  title,
+  content,
+  upvoteCount,
+  difficulty,
+  answerCount,
+  author,
+}) => {
+  return (
+    <Card className="border-none bg-blue-100 px-6 py-8 shadow-sm">
+      <div className="flex flex-col gap-6">
+        <h3 className="line-clamp-2 text-xl font-semibold text-gray-800">
+          {title}
+        </h3>
+        <p className="line-clamp-3 text-sm font-light text-gray-800">
+          {content}
+        </p>
+        <div className="flex flex-col gap-3 text-xs text-gray-700">
+          <div className="flex items-center gap-1">
+            <Star className="h-4 w-4" />
+            <span>{upvoteCount} votes</span>
+          </div>
+          <div className="flex items-center gap-1">
+            <MessageSquare className="h-4 w-4" />
+            <span>{answerCount} answers</span>
+          </div>
+          {difficulty && (
+            <div className="flex items-center gap-1">
+              <StarsIcon className="h-4 w-4" />
+              <span>{capitalizeString(difficulty)}</span>
+            </div>
+          )}
+        </div>
+        <div className="flex items-center justify-between">
+          {author && (
+            <Link to={`/users/${author.id}`} className="h-10 w-10">
+              <img
+                src={author?.profilePicture || placeholderProfile}
+                alt={author.name}
+                className="h-full w-full rounded-full object-cover"
+              />
+            </Link>
+          )}
+          <Link
+            to={`/question/${id}`}
+            className="flex items-center text-sm font-medium text-gray-800 hover:underline p-2"
+          >
+            Go to question
+            <ArrowRight className="ml-1 h-4 w-4" />
+          </Link>
+        </div>
+      </div>
+    </Card>
+  );
+};
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/AnswerCard.test.tsx b/frontend/src/components/AnswerCard.test.tsx new file mode 100644 index 00000000..7192084f --- /dev/null +++ b/frontend/src/components/AnswerCard.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { AnswerCard } from "./AnswerCard"; + +describe("AnswerCard", () => { + const mockProps = { + id: 1, + title: "Test Answer", + content: "This is a test answer content", + votes: 5, + questionId: 123, + author: { + id: 456, + name: "Test Author", + profilePicture: "test-profile.jpg", + }, + }; + + const renderWithRouter = (ui: React.ReactElement) => { + return render({ui}); + }; + + it("renders answer card with correct content", () => { + renderWithRouter(); + + expect(screen.getByText(mockProps.title)).toBeInTheDocument(); + expect(screen.getByText(mockProps.content)).toBeInTheDocument(); + expect(screen.getByText(`${mockProps.votes} votes`)).toBeInTheDocument(); + }); + + it("renders author profile picture", () => { + renderWithRouter(); + + const profilePicture = screen.getByAltText( + "Profile picture", + ) as HTMLImageElement; + expect(profilePicture).toBeInTheDocument(); + expect(profilePicture.src).toContain(mockProps.author.profilePicture); + }); + + it("renders default profile picture when author picture is not provided", () => { + const propsWithoutPicture = { + ...mockProps, + author: { ...mockProps.author, profilePicture: "" }, + }; + renderWithRouter(); + + const profilePicture = screen.getByAltText( + "Profile picture", + ) as HTMLImageElement; + expect(profilePicture).toBeInTheDocument(); + expect(profilePicture.src).toContain("placeholder_profile"); + }); + + it("contains correct navigation links", () => { + renderWithRouter(); + + const authorLink = screen.getByRole("link", { name: /profile picture/i }); + const answerLink = screen.getByRole("link", { name: /go to answer/i }); + + expect(authorLink).toHaveAttribute("href", `/users/${mockProps.author.id}`); + expect(answerLink).toHaveAttribute( + "href", + `/question/${mockProps.questionId}`, + ); + }); +}); diff --git a/frontend/src/components/CodeSnippet.test.tsx b/frontend/src/components/CodeSnippet.test.tsx new file mode 100644 index 00000000..bbf930ce --- /dev/null +++ b/frontend/src/components/CodeSnippet.test.tsx @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useExecuteCode } from "@/services/api/programmingForumComponents"; +import { UseMutationResult } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CodeSnippet } from "./CodeSnippet"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useExecuteCode: vi.fn(), +})); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +describe("CodeSnippet", () => { + const mockProps = { + code: 'console.log("Hello, World!");', + language: "javascript", + }; + + beforeEach(() => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + data: undefined, + error: undefined, + } as unknown as UseMutationResult); + }); + + it("renders code snippet with correct language", () => { + render(); + + expect(screen.getByText("JavaScript Code Snippet")).toBeInTheDocument(); + screen.getAllByRole("code").forEach((code) => { + expect(code).toHaveTextContent(mockProps.code); + }); + }); + + it("handles code execution", () => { + const mockMutate = vi.fn(); + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: mockMutate, + isPending: false, + isSuccess: false, + isError: false, + } as unknown as UseMutationResult); + + render(); + + const executeButton = screen.getByRole("button", { name: /execute/i }); + fireEvent.click(executeButton); + + expect(mockMutate).toHaveBeenCalledWith({ + body: { + code: mockProps.code, + language: mockProps.language, + }, + }); + }); + + it("shows loading state during execution", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: true, + isSuccess: false, + isError: false, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText("Executing...")).toBeInTheDocument(); + }); + + it("shows success output after execution", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: true, + isError: false, + data: { + data: { + output: "Hello, World!", + executionTime: 0.5, + }, + }, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText(/output \(in 0\.5s\):/i)).toBeInTheDocument(); + expect(screen.getByText("Hello, World!")).toBeInTheDocument(); + }); + + it("shows error message on execution failure", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: true, + error: { + payload: { + error: { + errorMessage: "Execution failed", + }, + }, + }, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText("Error:")).toBeInTheDocument(); + expect(screen.getByText("Execution failed")).toBeInTheDocument(); + }); + + it("copies code to clipboard when copy button is clicked", async () => { + render(); + + const copyButton = screen.getByRole("button", { name: /copy link/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + // Since the code is broken up into multiple elements for syntax highlighting, + // we should verify the button click rather than the exact clipboard content + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/CustomAnchor.test.tsx b/frontend/src/components/CustomAnchor.test.tsx new file mode 100644 index 00000000..c8f39fab --- /dev/null +++ b/frontend/src/components/CustomAnchor.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useNavigate } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CustomAnchor from "./CustomAnchor"; + +// Mock useNavigate +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: vi.fn(), + }; +}); + +describe("CustomAnchor", () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + }); + + it("renders a span when no href is provided", () => { + render( + + Test Link + , + ); + + expect(screen.getByText("Test Link").tagName).toBe("SPAN"); + }); + + it("renders an anchor with correct href when provided", () => { + render( + + Test Link + , + ); + + const link = screen.getByText("Test Link"); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "#tag-123"); + }); + + it("navigates to tag page when tag link is clicked", () => { + render( + + Tag Link + , + ); + + fireEvent.click(screen.getByText("Tag Link")); + expect(mockNavigate).toHaveBeenCalledWith("/tag/123"); + }); + + it("navigates to question page when question link is clicked", () => { + render( + + Question Link + , + ); + + fireEvent.click(screen.getByText("Question Link")); + expect(mockNavigate).toHaveBeenCalledWith("/question/456"); + }); + + it("sets correct title for tag links", () => { + render( + + Tag Link + , + ); + + expect(screen.getByText("Tag Link")).toHaveAttribute("title", "Tag: 123"); + }); + + it("sets correct title for question links", () => { + render( + + Question Link + , + ); + + expect(screen.getByText("Question Link")).toHaveAttribute( + "title", + "Question: 456", + ); + }); + + it("sets loading title for invalid href patterns", () => { + render( + + Invalid Link + , + ); + + expect(screen.getByText("Invalid Link")).toHaveAttribute( + "title", + "Loading...", + ); + }); +}); diff --git a/frontend/src/components/ExerciseCard.test.tsx b/frontend/src/components/ExerciseCard.test.tsx new file mode 100644 index 00000000..a82c7873 --- /dev/null +++ b/frontend/src/components/ExerciseCard.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { ExerciseCard } from "./ExerciseCard"; + +describe("ExerciseCard", () => { + const mockProps = { + id: 1, + title: "Test Exercise", + description: "This is a test exercise description", + difficulty: "Medium", + tags: ["javascript", "algorithms", "arrays"], + link: "tracks/javascript/exercises/test-exercise", + }; + + const renderWithRouter = (ui: React.ReactElement) => { + return render({ui}); + }; + + it("renders exercise card with correct content", () => { + renderWithRouter(); + + expect(screen.getByText(mockProps.title)).toBeInTheDocument(); + expect(screen.getByText(mockProps.description)).toBeInTheDocument(); + expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument(); + }); + + it("renders all tags correctly", () => { + renderWithRouter(); + + mockProps.tags.forEach((tag) => { + expect(screen.getByText(tag)).toBeInTheDocument(); + }); + }); + + it("contains correct exercism link", () => { + renderWithRouter(); + + const exerciseLink = screen.getByRole("link", { name: /go to exercise/i }); + expect(exerciseLink).toHaveAttribute( + "href", + `https://exercism.org/${mockProps.link}`, + ); + expect(exerciseLink).toHaveAttribute("target", "_blank"); + }); + + it("displays difficulty label correctly", () => { + renderWithRouter(); + + const difficultyLabel = screen.getByText(/difficulty:/i); + expect(difficultyLabel).toBeInTheDocument(); + expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/SearchQuestionsList.test.tsx b/frontend/src/components/SearchQuestionsList.test.tsx new file mode 100644 index 00000000..7149d5ec --- /dev/null +++ b/frontend/src/components/SearchQuestionsList.test.tsx @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useSearchQuestions } from "@/services/api/programmingForumComponents"; +import { DifficultyLevel } from "@/services/api/programmingForumSchemas"; +import { UseQueryResult } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchQuestionsList } from "./SearchQuestionsList"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useSearchQuestions: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +describe("SearchQuestionsList", () => { + const mockQuestions = [ + { + id: 1, + title: "Test Question 1", + content: "Content 1", + difficulty: "EASY" as DifficultyLevel, + upvoteCount: 5, + downvoteCount: 1, + answerCount: 2, + }, + { + id: 2, + title: "Test Question 2", + content: "Content 2", + difficulty: "MEDIUM" as DifficultyLevel, + upvoteCount: 3, + downvoteCount: 0, + answerCount: 1, + }, + ]; + + const mockSearchParams = new URLSearchParams(); + mockSearchParams.set("q", "test"); + mockSearchParams.set("sortBy", "recommended"); + + beforeEach(() => { + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + vi.mocked(useSearchQuestions).mockReturnValue({ + data: { + data: { + items: mockQuestions, + totalItems: mockQuestions.length, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + }); + + it("renders search results correctly", () => { + render( + + + , + ); + + expect( + screen.getByText(`Found ${mockQuestions.length} results`), + ).toBeInTheDocument(); + mockQuestions.forEach((question) => { + expect(screen.getByText(question.title)).toBeInTheDocument(); + expect(screen.getByText(question.content)).toBeInTheDocument(); + }); + }); + + it("displays loading state", () => { + vi.mocked(useSearchQuestions).mockReturnValue({ + data: null, + isLoading: true, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("displays error state", () => { + const errorMessage = "Failed to fetch questions"; + + vi.mocked(useSearchQuestions).mockReturnValue({ + data: null, + isLoading: false, + error: { + status: 500, + payload: { + status: 500, + error: { + errorMessage, + stackTrace: "Stack trace", + }, + }, + }, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("displays empty state message when no results", () => { + vi.mocked(useSearchQuestions).mockReturnValue({ + data: { + data: { + items: [], + totalItems: 0, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("No questions found")).toBeInTheDocument(); + expect( + screen.getByText("Try searching for specific topics or keywords."), + ).toBeInTheDocument(); + }); + + it("handles difficulty filter change", () => { + render( + + + , + ); + + const difficultyFilter = screen.getByRole("combobox"); + fireEvent.click(difficultyFilter); + const mediumOption = screen.getAllByText("Medium"); + fireEvent.click(mediumOption[1]); + + expect(useSearchQuestions).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + difficulty: "MEDIUM", + }), + }), + ); + }); +}); diff --git a/frontend/src/components/Tag.test.tsx b/frontend/src/components/Tag.test.tsx new file mode 100644 index 00000000..bc47aa8c --- /dev/null +++ b/frontend/src/components/Tag.test.tsx @@ -0,0 +1,102 @@ +import { TagDetails } from "@/services/api/programmingForumSchemas"; +import useAuthStore from "@/services/auth"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { Tag } from "./Tag"; + +// Mock the auth store +vi.mock("@/services/auth", () => ({ + default: vi.fn(), +})); + +describe("Tag", () => { + const mockTag = { + tagId: "123", + name: "JavaScript", + logoImage: "javascript-logo.png", + description: "A programming language", + }; + + it("renders tag information correctly", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + expect(screen.getByText(mockTag.name!)).toBeInTheDocument(); + expect(screen.getByText(mockTag.description!)).toBeInTheDocument(); + expect( + screen.getByAltText(`The logo image of ${mockTag.name}`), + ).toHaveAttribute("src", mockTag.logoImage); + }); + + it("shows 'See all questions' link", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const link = screen.getByText(/see all questions/i); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", `/tag/${mockTag.tagId}`); + }); + + it("shows create question link when authenticated", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: "mock-token" }); + + render( + + + , + ); + + const createLink = screen.getByRole("link", { name: /create question/i }); + expect(createLink).toBeInTheDocument(); + expect(createLink).toHaveAttribute( + "href", + `/questions/new?tagIds=${encodeURIComponent(mockTag.tagId)}`, + ); + }); + + it("does not show create question link when not authenticated", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const createLink = screen.queryByRole("link", { name: /create question/i }); + expect(createLink).not.toBeInTheDocument(); + }); + + it("renders image with correct alt and title attributes", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const image = screen.getByAltText(`The logo image of ${mockTag.name}`); + expect(image).toHaveAttribute( + "title", + `alt:The logo image of ${mockTag.name}`, + ); + expect(image).toHaveClass( + "h-full", + "w-full", + "rounded-2xl", + "object-cover", + ); + }); +}); diff --git a/frontend/src/components/Tag.tsx b/frontend/src/components/Tag.tsx index d12ea132..3530c8f1 100644 --- a/frontend/src/components/Tag.tsx +++ b/frontend/src/components/Tag.tsx @@ -39,6 +39,7 @@ export const Tag = ({ {!!token && ( diff --git a/frontend/src/components/Tags.test.tsx b/frontend/src/components/Tags.test.tsx new file mode 100644 index 00000000..7c1be1d8 --- /dev/null +++ b/frontend/src/components/Tags.test.tsx @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useSearchTags } from "@/services/api/programmingForumComponents"; +import { TagDetails } from "@/services/api/programmingForumSchemas"; +import { UseQueryResult } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import TagsPage from "./Tags"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useSearchTags: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +describe("TagsPage", () => { + const mockTags: Partial[] = [ + { + tagId: "1", + name: "JavaScript", + description: "A programming language", + logoImage: "js-logo.png", + }, + { + tagId: "2", + name: "Python", + description: "Another programming language", + logoImage: "python-logo.png", + }, + ]; + + const mockSearchParams = new URLSearchParams(); + mockSearchParams.set("q", ""); + + beforeEach(() => { + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: mockTags, + totalItems: mockTags.length, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + }); + + it("renders tags list correctly", () => { + render( + + + , + ); + + expect(screen.getByText("Tags")).toBeInTheDocument(); + mockTags.forEach((tag) => { + expect(screen.getByText(tag.name!)).toBeInTheDocument(); + expect(screen.getByText(tag.description!)).toBeInTheDocument(); + expect( + screen.getByAltText(`The logo image of ${tag.name}`), + ).toHaveAttribute("src", tag.logoImage!); + }); + }); + + it("displays loading state", () => { + vi.mocked(useSearchTags).mockReturnValue({ + data: null, + isLoading: true, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("displays error state", () => { + const errorMessage = "Failed to fetch tags"; + vi.mocked(useSearchTags).mockReturnValue({ + data: null, + isLoading: false, + error: { + status: 500, + payload: { + status: 500, + error: { + errorMessage, + stackTrace: "Stack trace", + }, + }, + }, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("shows create tag button", () => { + render( + + + , + ); + + const createButton = screen.getByRole("link", { name: /create tag/i }); + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveAttribute("href", "/tags/new"); + }); + + it("loads more tags when scrolling", () => { + const { container } = render( + + + , + ); + + const infiniteScroll = container.querySelector("div"); + fireEvent.scroll(infiniteScroll as Element); + + expect(useSearchTags).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + pageSize: 20, + }), + }), + expect.any(Object), + ); + }); + + it("handles empty tags list", () => { + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: [], + totalItems: 0, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("updates search results when query changes", () => { + const searchParams = new URLSearchParams(); + searchParams.set("q", "javascript"); + vi.mocked(useSearchParams).mockReturnValue([searchParams, vi.fn()]); + + render( + + + , + ); + + expect(useSearchTags).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + q: "javascript", + }), + }), + expect.any(Object), + ); + }); +}); diff --git a/frontend/src/components/Tags.tsx b/frontend/src/components/Tags.tsx index a1de0918..b12f9966 100644 --- a/frontend/src/components/Tags.tsx +++ b/frontend/src/components/Tags.tsx @@ -65,7 +65,7 @@ export default function TagsPage() { size="icon" className="rounded-full bg-red-500 text-white" > - + @@ -87,9 +87,7 @@ export default function TagsPage() { {isLoading && (
- +
Loading...
diff --git a/frontend/src/routes/create-question.test.tsx b/frontend/src/routes/create-question.test.tsx new file mode 100644 index 00000000..d4d02431 --- /dev/null +++ b/frontend/src/routes/create-question.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + useCreateQuestion, + useSearchTags, +} from "@/services/api/programmingForumComponents"; +import { + UseMutationResult, + UseQueryResult, + useQueryClient, +} from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useNavigate, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import QuestionCreationPage from "./create-question"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useCreateQuestion: vi.fn(), + useSearchTags: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: vi.fn(), + useSearchParams: vi.fn(), + }; +}); + +// Mock react-query +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +describe("QuestionCreationPage", () => { + const mockNavigate = vi.fn(); + const mockQueryClient = { + invalidateQueries: vi.fn(), + }; + const mockCreateQuestion = vi.fn(); + const mockSearchParams = new URLSearchParams(); + + const mockTags = [ + { tagId: "1", name: "javascript" }, + { tagId: "2", name: "python" }, + ]; + + beforeEach(() => { + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + vi.mocked(useQueryClient).mockReturnValue(mockQueryClient as any); + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + + vi.mocked(useCreateQuestion).mockReturnValue({ + mutateAsync: mockCreateQuestion, + isLoading: false, + } as unknown as UseMutationResult); + + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: mockTags, + }, + }, + isLoading: false, + } as unknown as UseQueryResult); + }); + + it("renders the form correctly", () => { + render( + + + , + ); + + expect(screen.getByText("Create a new question")).toBeInTheDocument(); + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/content/i)).toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: /difficulty/i }), + ).toBeInTheDocument(); + }); + + it("shows validation errors for empty fields", async () => { + render( + + + , + ); + + // Submit without filling the form + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + await waitFor(() => { + expect(screen.getByText("Title is required")).toBeInTheDocument(); + expect(screen.getByText("Content is required")).toBeInTheDocument(); + expect( + screen.getByText("At least one tag is required"), + ).toBeInTheDocument(); + }); + }); + + it("loads tags from URL parameters", () => { + const mockSearchParamsWithTags = new URLSearchParams(); + mockSearchParamsWithTags.set("tagIds", "1,2"); + vi.mocked(useSearchParams).mockReturnValue([ + mockSearchParamsWithTags, + vi.fn(), + ]); + + render( + + + , + ); + + expect(screen.getByText("javascript")).toBeInTheDocument(); + expect(screen.getByText("python")).toBeInTheDocument(); + }); + + it("toggles preview mode", () => { + render( + + + , + ); + + const previewButton = screen.getByRole("button", { name: /preview/i }); + fireEvent.click(previewButton); + + expect(screen.getByText("Preview")).toBeInTheDocument(); + + fireEvent.click(previewButton); + expect(screen.queryByText("Preview Mode")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/create-question.tsx b/frontend/src/routes/create-question.tsx index 9b509f46..a6e6f9b6 100644 --- a/frontend/src/routes/create-question.tsx +++ b/frontend/src/routes/create-question.tsx @@ -1,3 +1,4 @@ +import { ContentWithSnippets } from "@/components/ContentWithSnippets"; import { MultiSelect } from "@/components/multi-select"; import { Button } from "@/components/ui/button"; import { @@ -8,23 +9,26 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { useCreateQuestion, useSearchTags, } from "@/services/api/programmingForumComponents"; -import { Info } from "lucide-react"; import { queryKeyFn } from "@/services/api/programmingForumContext"; import { TagDetails } from "@/services/api/programmingForumSchemas"; import { zodResolver } from "@hookform/resolvers/zod"; import { InvalidateQueryFilters, useQueryClient } from "@tanstack/react-query"; +import { Info } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { z } from "zod"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import SyntaxHighlighter from "react-syntax-highlighter"; -import { ContentWithSnippets } from "@/components/ContentWithSnippets"; +import { z } from "zod"; // Schema validation for the form const newQuestionSchema = z.object({ @@ -119,8 +123,8 @@ export default function QuestionCreationPage() { return (
-

Create a new question

- +

Create a new question

+
-
{ @@ -186,7 +189,10 @@ export default function QuestionCreationPage() { render={({ field }) => ( - + @@ -217,16 +223,19 @@ export default function QuestionCreationPage() { render={({ field }) => ( - {isPreviewMode ? ( -
- -
- ) : ( -