Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion js/src/__mock__/user/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiURL } from "@/lib/api/common/apiURL";
import { QuestionDifficulty, Tag } from "@/lib/api/types/schema";
import { http, HttpResponse } from "msw";

const allUsersUrl = ApiURL.create("/api/user/all", {
Expand All @@ -14,7 +15,14 @@ export const MOCK_USERS = [
nickname: null,
admin: false,
profileUrl: null,
tags: [],
tags: [
{
id: "tag-1",
createdAt: "2024-01-01T00:00:00Z",
userId: "user-1",
tag: Tag.Patina,
},
],
achievements: [],
},
{
Expand Down Expand Up @@ -86,3 +94,120 @@ export const getAllUsersErrorHandler = http.get(
return HttpResponse.error();
},
);

const userProfileUrl = ApiURL.create("/api/user/{userId}/profile", {
method: "GET",
params: {
userId: MOCK_USERS[0].id,
},
});

export const getUserProfileHandler = http.get(
userProfileUrl.url.toString(),
() => {
return HttpResponse.json({
success: true,
message: "User profile loaded!",
payload: MOCK_USERS[0],
} satisfies ReturnType<typeof userProfileUrl.res>);
},
);

const userSubmissionsUrl = ApiURL.create("/api/user/{userId}/submissions", {
method: "GET",
params: {
userId: MOCK_USERS[0].id,
},
});

export const MOCK_USER_SUBMISSIONS = [
{
id: "submission-1",
userId: "user-1",
questionSlug: "two-sum",
questionNumber: 1,
questionTitle: "Two Sum",
questionLink: "https://leetcode.com/problems/two-sum",
questionDifficulty: QuestionDifficulty.Easy,
description: "<p>Given an array of integers, return indices.</p>",
acceptanceRate: 0.49,
pointsAwarded: 10,
code: "def twoSum(nums, target):\n pass",
runtime: "50ms",
memory: "16MB",
language: "python3",
createdAt: "2024-01-01T00:00:00Z",
submittedAt: "2024-01-01T00:00:00Z",
submissionId: null,
topics: [],
},
{
id: "submission-2",
userId: "user-1",
questionSlug: "add-two-numbers",
questionNumber: 2,
questionTitle: "Add Two Numbers",
questionLink: "https://leetcode.com/problems/add-two-numbers",
questionDifficulty: QuestionDifficulty.Medium,
description: "<p>Add two numbers represented by linked lists.</p>",
acceptanceRate: 0.49,
pointsAwarded: 20,
code: "def addTwoNumbers(l1, l2):\n pass",
runtime: "50ms",
memory: "16MB",
language: "python3",
createdAt: "2024-01-02T00:00:00Z",
submittedAt: "2024-01-02T00:00:00Z",
submissionId: null,
topics: [],
},
];

export const userSubmissionsHandler = http.get(
userSubmissionsUrl.url.toString(),
() => {
return HttpResponse.json({
success: true,
message: "Submissions loaded!",
payload: {
hasNextPage: false,
pages: 1,
pageSize: 20,
items: MOCK_USER_SUBMISSIONS,
},
} satisfies ReturnType<typeof userSubmissionsUrl.res>);
},
);

export const userSubmissionsEmptyHandler = http.get(
userSubmissionsUrl.url.toString(),
() => {
return HttpResponse.json({
success: true,
message: "Submissions loaded!",
payload: {
hasNextPage: false,
pages: 1,
pageSize: 20,
items: [],
},
} satisfies ReturnType<typeof userSubmissionsUrl.res>);
},
);

export const userSubmissionsFailedHandler = http.get(
userSubmissionsUrl.url.toString(),
() => {
return HttpResponse.json({
success: false,
message: "Failed to load submissions",
} satisfies ReturnType<typeof userSubmissionsUrl.res>);
},
);

export const userSubmissionsErrorHandler = http.get(
userSubmissionsUrl.url.toString(),
() => {
return HttpResponse.error();
},
);
111 changes: 111 additions & 0 deletions js/src/app/dashboard/Dashboard.page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
MOCK_USER_SUBMISSIONS,
userSubmissionsEmptyHandler,
userSubmissionsErrorHandler,
userSubmissionsFailedHandler,
userSubmissionsHandler,
} from "@/__mock__/user";
import RecentSubmissions from "@/app/dashboard/_components/RecentSubmissions/RecentSubmissions";
import { TestUtils, TestUtilTypes } from "@/lib/test";
import { screen, waitFor } from "@testing-library/react";
import { setupServer } from "msw/node";

describe("RecentSubmissions", () => {
const server = setupServer(userSubmissionsHandler);

beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());

let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null;
beforeEach(() => {
renderProviderFn = TestUtils.getRenderWithAllProvidersFn();
});

it("should render 'Submissions' heading after successful API call", async () => {
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
expect(screen.getByText("Submissions")).toBeInTheDocument();
});
});

it("should render each submission title", async () => {
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
for (const q of MOCK_USER_SUBMISSIONS) {
expect(screen.getByText(q.questionTitle)).toBeInTheDocument();
}
});
});

it("should link each submission to its submission page", async () => {
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

const first = MOCK_USER_SUBMISSIONS[0];
await waitFor(() => {
expect(
screen.getByText(first.questionTitle).closest("a"),
).toHaveAttribute("href", `/submission/${first.id}`);
});
});

it("should render difficulty and points for a submission", async () => {
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

const first = MOCK_USER_SUBMISSIONS[0];
await waitFor(() => {
expect(
screen.getByText(`+${first.pointsAwarded} points`),
).toBeInTheDocument();
});
expect(
screen.getAllByText(first.questionDifficulty).length,
).toBeGreaterThan(0);
});

it("should render 'View all' link to the user's profile", async () => {
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
expect(screen.getByRole("link", { name: "View all" })).toHaveAttribute(
"href",
"/user/user-1",
);
});
});

it("should render empty message when user has no submissions", async () => {
server.use(userSubmissionsEmptyHandler);
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
expect(screen.getByText(/no problems solved yet/i)).toBeInTheDocument();
});
});

it("should render failed message when API returns success false", async () => {
server.use(userSubmissionsFailedHandler);
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
expect(
screen.getAllByText("Failed to load submissions").length,
).toBeGreaterThan(0);
});
});

it("should render error message when API call fails", async () => {
server.use(userSubmissionsErrorHandler);
renderProviderFn?.(<RecentSubmissions userId="user-1" />);

await waitFor(() => {
expect(
screen.getByText("Sorry, something went wrong. Please try again."),
).toBeInTheDocument();
});
});
});
38 changes: 38 additions & 0 deletions js/src/app/user/[userId]/UserProfile.page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { getUserProfileHandler } from "@/__mock__/user";
import UserProfilePage from "@/app/user/[userId]/UserProfile.page";
import { TestUtils, TestUtilTypes } from "@/lib/test";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { setupServer } from "msw/node";
import { Route, Routes } from "react-router-dom";

const mockNavigate = vi.fn();
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useNavigate: () => mockNavigate,
}));

class ResizeObserverMock {
observe() {}
unobserve() {}
Expand Down Expand Up @@ -137,4 +145,34 @@ describe("UserProfilePage", () => {
);
});
});

describe("should navigate to leaderboard when a tag is clicked", () => {
const server = setupServer(getUserProfileHandler);

beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());

let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null;
beforeEach(() => {
renderProviderFn = TestUtils.getRenderWithAllProvidersFn();
});

it("should navigate to leaderboard with tag query param", async () => {
const user = userEvent.setup();
renderProviderFn?.(routes, "/user/user-1");

const tagBadge = await screen.findByRole("button", {
name: "Patina Network",
});

expect(tagBadge).toBeInTheDocument();

await user.click(tagBadge);

expect(mockNavigate).toHaveBeenCalledWith("/leaderboard?patina=true");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import Toast from "@/components/ui/toast/Toast";
import { useUserProfileQuery } from "@/lib/api/queries/user";
import { Group, Text, Stack } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";

export default function UserTags({ userId }: { userId: string }) {
const navigate = useNavigate();
const { data, status } = useUserProfileQuery({ userId });

if (status === "pending") {
Expand Down Expand Up @@ -34,7 +36,15 @@ export default function UserTags({ userId }: { userId: string }) {

return (
<Stack gap="md" w="100%">
<TagList tags={tags} size={40} gap="xs" expanded={true} />
<TagList
tags={tags}
size={40}
gap="xs"
expanded={true}
onTagClick={(userTag) =>
navigate(`/leaderboard?${userTag.tag.toLowerCase()}=true`)
}
/>
<UserAchievement achievements={achievements} size={40} gap="xs" />
</Stack>
);
Expand Down
Loading
Loading