3e4c730860
This commit adds a complete gamification system with analytics dashboards, leaderboards, and enhanced badge tracking functionality. Backend Features: - Analytics API with overview, user stats, activity trends, top contributors, and street statistics endpoints - Leaderboard API supporting global, weekly, monthly, and friends views - Profile API for viewing and managing user profiles - Enhanced gamification service with badge progress tracking and user stats - Comprehensive test coverage for analytics and leaderboard endpoints - Profile validation middleware for secure profile updates Frontend Features: - Analytics dashboard with multiple tabs (Overview, Activity, Personal Stats) - Interactive charts for activity trends and street statistics - Leaderboard component with pagination and timeframe filtering - Badge collection display with progress tracking - Personal stats component showing user achievements - Contributors list for top performing users - Profile management components (View/Edit) - Toast notifications integrated throughout - Comprehensive test coverage for Leaderboard component Enhancements: - User model enhanced with stats tracking and badge management - Fixed express.Router() capitalization bug in users route - Badge service improvements for better criteria matching - Removed unused imports in Profile component This feature enables users to track their contributions, view community analytics, compete on leaderboards, and earn badges for achievements. 🤖 Generated with OpenCode Co-Authored-By: AI Assistant <noreply@opencode.ai>
481 lines
13 KiB
JavaScript
481 lines
13 KiB
JavaScript
import React from "react";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { BrowserRouter } from "react-router-dom";
|
|
import axios from "axios";
|
|
import { toast } from "react-toastify";
|
|
import Leaderboard from "../components/Leaderboard";
|
|
import { AuthContext } from "../context/AuthContext";
|
|
|
|
// Mock axios
|
|
jest.mock("axios");
|
|
|
|
// Mock react-toastify
|
|
jest.mock("react-toastify", () => ({
|
|
toast: {
|
|
success: jest.fn(),
|
|
error: jest.fn(),
|
|
warning: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock leaderboard data
|
|
const mockLeaderboardData = [
|
|
{
|
|
userId: "user1",
|
|
username: "TopUser",
|
|
email: "topuser@example.com",
|
|
points: 1000,
|
|
streetsAdopted: 5,
|
|
tasksCompleted: 20,
|
|
badges: [
|
|
{ name: "Beginner", icon: "🏅" },
|
|
{ name: "Intermediate", icon: "🏆" },
|
|
],
|
|
},
|
|
{
|
|
userId: "user2",
|
|
username: "SecondUser",
|
|
email: "second@example.com",
|
|
points: 800,
|
|
streetsAdopted: 4,
|
|
tasksCompleted: 15,
|
|
badges: [{ name: "Beginner", icon: "🏅" }],
|
|
},
|
|
{
|
|
userId: "user3",
|
|
username: "ThirdUser",
|
|
email: "third@example.com",
|
|
points: 600,
|
|
streetsAdopted: 3,
|
|
tasksCompleted: 10,
|
|
badges: [],
|
|
},
|
|
];
|
|
|
|
const mockStats = {
|
|
totalUsers: 100,
|
|
totalPoints: 50000,
|
|
averagePoints: 500,
|
|
maxPoints: 1000,
|
|
minPoints: 0,
|
|
};
|
|
|
|
describe("Leaderboard Component", () => {
|
|
const mockAuthContext = {
|
|
auth: {
|
|
isAuthenticated: true,
|
|
user: {
|
|
_id: "user1",
|
|
username: "TopUser",
|
|
points: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const renderLeaderboard = (authContext = mockAuthContext) => {
|
|
return render(
|
|
<BrowserRouter>
|
|
<AuthContext.Provider value={authContext}>
|
|
<Leaderboard />
|
|
</AuthContext.Provider>
|
|
</BrowserRouter>
|
|
);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
localStorage.setItem("token", "mock-token");
|
|
});
|
|
|
|
afterEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
describe("Initial Loading", () => {
|
|
it("should display loading spinner on initial load", () => {
|
|
axios.get.mockImplementation(() => new Promise(() => {})); // Never resolves
|
|
renderLeaderboard();
|
|
|
|
expect(screen.getByText(/loading leaderboard/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should load global leaderboard by default", async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/global")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
if (url.includes("/api/leaderboard/stats")) {
|
|
return Promise.resolve({ data: mockStats });
|
|
}
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/leaderboard/global"),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("should load leaderboard stats", async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/global")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
if (url.includes("/api/leaderboard/stats")) {
|
|
return Promise.resolve({ data: mockStats });
|
|
}
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/total users:/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText(/100/)).toBeInTheDocument();
|
|
expect(screen.getByText(/50,000/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Tab Navigation", () => {
|
|
beforeEach(() => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
});
|
|
|
|
it("should switch to weekly leaderboard when clicking weekly tab", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const weeklyTab = screen.getByRole("button", { name: /this week/i });
|
|
fireEvent.click(weeklyTab);
|
|
|
|
await waitFor(() => {
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/leaderboard/weekly"),
|
|
expect.anything()
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should switch to monthly leaderboard when clicking monthly tab", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const monthlyTab = screen.getByRole("button", { name: /this month/i });
|
|
fireEvent.click(monthlyTab);
|
|
|
|
await waitFor(() => {
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/leaderboard/monthly"),
|
|
expect.anything()
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should switch to friends leaderboard when authenticated", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
|
fireEvent.click(friendsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/leaderboard/friends"),
|
|
expect.objectContaining({
|
|
headers: { "x-auth-token": "mock-token" },
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should show warning when trying to access friends tab without authentication", async () => {
|
|
const unauthContext = {
|
|
auth: {
|
|
isAuthenticated: false,
|
|
user: null,
|
|
},
|
|
};
|
|
|
|
axios.get.mockResolvedValue({ data: mockLeaderboardData });
|
|
renderLeaderboard(unauthContext);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
|
fireEvent.click(friendsTab);
|
|
|
|
expect(toast.warning).toHaveBeenCalledWith(
|
|
"Please login to view friends leaderboard"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("User Display", () => {
|
|
beforeEach(() => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
});
|
|
|
|
it("should display all users in leaderboard", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
expect(screen.getByText("SecondUser")).toBeInTheDocument();
|
|
expect(screen.getByText("ThirdUser")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should highlight current user", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("You")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should display user points", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("1,000")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should display user statistics", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
// Check for streets and tasks counts
|
|
const statsElements = screen.getAllByText(/5|20/);
|
|
expect(statsElements.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should display current user points in alert", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/your points:/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("1000")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Pagination", () => {
|
|
beforeEach(() => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
});
|
|
|
|
it("should disable previous button on first page", async () => {
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const previousButton = screen.getByRole("button", { name: /previous/i });
|
|
expect(previousButton).toBeDisabled();
|
|
});
|
|
|
|
it("should enable next button when there are more results", async () => {
|
|
// Mock 50 results to trigger hasMore
|
|
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
|
|
userId: `user${i}`,
|
|
username: `User${i}`,
|
|
points: 1000 - i * 10,
|
|
streetsAdopted: 1,
|
|
tasksCompleted: 1,
|
|
badges: [],
|
|
}));
|
|
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: largeDataset });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("User0")).toBeInTheDocument();
|
|
});
|
|
|
|
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
expect(nextButton).not.toBeDisabled();
|
|
});
|
|
|
|
it("should load next page when clicking next button", async () => {
|
|
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
|
|
userId: `user${i}`,
|
|
username: `User${i}`,
|
|
points: 1000 - i * 10,
|
|
streetsAdopted: 1,
|
|
tasksCompleted: 1,
|
|
badges: [],
|
|
}));
|
|
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: largeDataset });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("User0")).toBeInTheDocument();
|
|
});
|
|
|
|
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
fireEvent.click(nextButton);
|
|
|
|
await waitFor(() => {
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining("offset=50"),
|
|
expect.anything()
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Error Handling", () => {
|
|
it("should display error message when API fails", async () => {
|
|
axios.get.mockRejectedValue({
|
|
response: { data: { msg: "Server error" } },
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/error loading leaderboard/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(toast.error).toHaveBeenCalledWith("Server error");
|
|
});
|
|
|
|
it("should show retry button on error", async () => {
|
|
axios.get.mockRejectedValue({
|
|
response: { data: { msg: "Server error" } },
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should retry loading when clicking retry button", async () => {
|
|
axios.get
|
|
.mockRejectedValueOnce({
|
|
response: { data: { msg: "Server error" } },
|
|
})
|
|
.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
|
});
|
|
|
|
const retryButton = screen.getByRole("button", { name: /retry/i });
|
|
fireEvent.click(retryButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Empty State", () => {
|
|
it("should display message when leaderboard is empty", async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/no users to display yet/i)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should display friends-specific message when friends leaderboard is empty", async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes("/api/leaderboard/friends")) {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
if (url.includes("/api/leaderboard/")) {
|
|
return Promise.resolve({ data: mockLeaderboardData });
|
|
}
|
|
return Promise.resolve({ data: mockStats });
|
|
});
|
|
|
|
renderLeaderboard();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
|
});
|
|
|
|
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
|
fireEvent.click(friendsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/no friends to display/i)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
});
|