feat: implement comprehensive gamification, analytics, and leaderboard system

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>
This commit is contained in:
William Valentin
2025-11-03 13:53:48 -08:00
parent ae77e30ffb
commit 3e4c730860
34 changed files with 5533 additions and 190 deletions

View File

@@ -1,4 +1,18 @@
// This file runs before any modules are loaded
// Set test environment variables FIRST (before any module loads)
// Must be at least 32 chars for validation
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
process.env.COUCHDB_URL = 'http://localhost:5984';
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
process.env.PORT = '5001';
// Mock dotenv to prevent .env file from overriding test values
jest.mock('dotenv', () => ({
config: jest.fn()
}));
// Mock axios first since couchdbService uses it
jest.mock('axios', () => ({
create: jest.fn(() => ({

View File

@@ -0,0 +1,488 @@
const request = require("supertest");
const { app } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const jwt = require("jsonwebtoken");
// Mock couchdbService
jest.mock("../../services/couchdbService");
describe("Analytics API", () => {
let authToken;
let mockUser;
beforeAll(() => {
// Create a mock user and token
mockUser = {
_id: "user_123",
type: "user",
name: "Test User",
email: "test@test.com",
points: 100,
isPremium: false,
adoptedStreets: ["street_1"],
completedTasks: ["task_1", "task_2"],
posts: ["post_1"],
events: ["event_1"],
earnedBadges: [{ badgeId: "badge_1" }],
stats: {
streetsAdopted: 1,
tasksCompleted: 2,
postsCreated: 1,
eventsParticipated: 1,
badgesEarned: 1,
},
};
authToken = jwt.sign({ id: mockUser._id }, process.env.JWT_SECRET || "test_secret");
// Mock couchdbService methods
couchdbService.find = jest.fn();
couchdbService.findUserById = jest.fn();
couchdbService.getDocument = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("GET /api/analytics/overview", () => {
it("should return overview statistics without auth token", async () => {
const mockUsers = [
{ type: "user", points: 100 },
{ type: "user", points: 150 },
];
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
const mockEvents = [
{ type: "event", status: "upcoming" },
{ type: "event", status: "completed" },
];
const mockPosts = [{ type: "post" }, { type: "post" }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("overview");
expect(res.body.overview).toHaveProperty("totalUsers", 2);
expect(res.body.overview).toHaveProperty("totalStreets", 3);
expect(res.body.overview).toHaveProperty("adoptedStreets", 2);
expect(res.body.overview).toHaveProperty("totalTasks", 2);
expect(res.body.overview).toHaveProperty("completedTasks", 1);
expect(res.body.overview).toHaveProperty("totalEvents", 2);
expect(res.body.overview).toHaveProperty("totalPosts", 2);
expect(res.body.overview).toHaveProperty("totalPoints", 250);
expect(res.body.overview).toHaveProperty("averagePointsPerUser", 125);
});
it("should filter by timeframe", async () => {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const mockUsers = [{ type: "user", points: 100 }];
const mockStreets = [{ type: "street", status: "adopted" }];
const mockTasks = [
{ type: "task", status: "completed", createdAt: now.toISOString() },
];
const mockEvents = [
{ type: "event", status: "upcoming", createdAt: now.toISOString() },
];
const mockPosts = [{ type: "post", createdAt: now.toISOString() }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview?timeframe=7d")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.timeframe).toBe("7d");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/overview");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/user/:userId", () => {
it("should return user-specific analytics", async () => {
couchdbService.findUserById.mockResolvedValue(mockUser);
couchdbService.getDocument.mockResolvedValue({
_id: "street_1",
name: "Main Street",
});
const mockTasks = [
{ type: "task", completedBy: { userId: mockUser._id }, createdAt: new Date().toISOString() },
];
const mockPosts = [
{ type: "post", user: { userId: mockUser._id }, likesCount: 5, commentsCount: 3, createdAt: new Date().toISOString() },
];
const mockEvents = [
{ type: "event", participants: [{ userId: mockUser._id }], createdAt: new Date().toISOString() },
];
const mockTransactions = [
{ type: "point_transaction", user: { userId: mockUser._id }, amount: 50, createdAt: new Date().toISOString() },
{ type: "point_transaction", user: { userId: mockUser._id }, amount: -20, createdAt: new Date().toISOString() },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "point_transaction") return mockTransactions;
return [];
});
const res = await request(app)
.get(`/api/analytics/user/${mockUser._id}`)
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("user");
expect(res.body.user.name).toBe("Test User");
expect(res.body).toHaveProperty("stats");
expect(res.body.stats).toHaveProperty("streetsAdopted");
expect(res.body.stats).toHaveProperty("tasksCompleted");
expect(res.body.stats).toHaveProperty("pointsEarned", 50);
expect(res.body.stats).toHaveProperty("pointsSpent", 20);
expect(res.body.stats).toHaveProperty("totalLikesReceived", 5);
expect(res.body.stats).toHaveProperty("totalCommentsReceived", 3);
expect(res.body).toHaveProperty("recentActivity");
});
it("should return 404 for non-existent user", async () => {
couchdbService.findUserById.mockResolvedValue(null);
const res = await request(app)
.get("/api/analytics/user/invalid_user_id")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(404);
expect(res.body.msg).toBe("User not found");
});
it("should require authentication", async () => {
const res = await request(app).get(`/api/analytics/user/${mockUser._id}`);
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/activity", () => {
it("should return activity data grouped by day", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [{ type: "post", createdAt: today }];
const mockEvents = [{ type: "event", createdAt: today }];
const mockStreets = [{ type: "street", status: "adopted", createdAt: today }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=day")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("activity");
expect(res.body).toHaveProperty("groupBy", "day");
expect(res.body).toHaveProperty("summary");
expect(Array.isArray(res.body.activity)).toBe(true);
});
it("should group activity by week", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=week")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("week");
});
it("should group activity by month", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=month")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("month");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/activity");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/top-contributors", () => {
it("should return top contributors by points", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
{
_id: "user_2",
name: "User 2",
email: "user2@test.com",
points: 300,
stats: { tasksCompleted: 7, postsCreated: 3, streetsAdopted: 1 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?limit=5")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("contributors");
expect(res.body.contributors.length).toBeLessThanOrEqual(5);
expect(res.body.metric).toBe("points");
// Verify sorted by score descending
if (res.body.contributors.length > 1) {
expect(res.body.contributors[0].score).toBeGreaterThanOrEqual(
res.body.contributors[1].score
);
}
});
it("should return top contributors by tasks", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=tasks")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("tasks");
});
it("should return top contributors by posts", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=posts")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("posts");
});
it("should return top contributors by streets", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=streets")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("streets");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/top-contributors");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/street-stats", () => {
it("should return street adoption and task statistics", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{ type: "task", status: "pending", street: { streetId: "street_2", name: "Oak St" } },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("adoption");
expect(res.body.adoption).toHaveProperty("totalStreets", 3);
expect(res.body.adoption).toHaveProperty("adoptedStreets", 2);
expect(res.body.adoption).toHaveProperty("availableStreets", 1);
expect(res.body.adoption).toHaveProperty("adoptionRate");
expect(res.body).toHaveProperty("tasks");
expect(res.body.tasks).toHaveProperty("totalTasks", 3);
expect(res.body.tasks).toHaveProperty("completedTasks", 2);
expect(res.body.tasks).toHaveProperty("pendingTasks", 1);
expect(res.body.tasks).toHaveProperty("completionRate");
expect(res.body).toHaveProperty("topStreets");
expect(Array.isArray(res.body.topStreets)).toBe(true);
});
it("should calculate correct adoption rate", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "available" },
];
const mockTasks = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.adoption.adoptionRate).toBe(50.0);
});
it("should calculate correct task completion rate", async () => {
const mockStreets = [];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.tasks.completionRate).toBe(75.0);
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/street-stats");
expect(res.statusCode).toBe(401);
});
});
});

View File

@@ -0,0 +1,396 @@
const request = require("supertest");
const { app, server } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const User = require("../../models/User");
const PointTransaction = require("../../models/PointTransaction");
const Badge = require("../../models/Badge");
const UserBadge = require("../../models/UserBadge");
const jwt = require("jsonwebtoken");
const { clearCache } = require("../../middleware/cache");
describe("Leaderboard Routes", () => {
let testUsers = [];
let authToken;
let testUserId;
beforeAll(async () => {
await couchdbService.initialize();
});
beforeEach(async () => {
// Clear cache before each test
clearCache();
// Create test users with varying points
const userPromises = [];
for (let i = 0; i < 10; i++) {
const userData = {
name: `Test User ${i}`,
email: `testuser${i}@example.com`,
password: "password123",
points: (10 - i) * 100, // 1000, 900, 800, ..., 100
adoptedStreets: [],
completedTasks: [],
stats: {
streetsAdopted: i,
tasksCompleted: i * 2,
postsCreated: i,
eventsParticipated: i,
badgesEarned: 0
}
};
userPromises.push(User.create(userData));
}
testUsers = await Promise.all(userPromises);
// Set test user ID and create auth token
testUserId = testUsers[0]._id;
authToken = jwt.sign({ id: testUserId }, process.env.JWT_SECRET, {
expiresIn: "1h"
});
// Create some point transactions for weekly/monthly leaderboards
const now = new Date();
const transactionPromises = [];
// Create transactions for this week
for (let i = 0; i < 5; i++) {
const weeklyTransaction = {
user: testUsers[i]._id,
amount: (5 - i) * 50,
transactionType: "earned",
description: `Weekly test transaction ${i}`,
balanceAfter: testUsers[i].points + (5 - i) * 50,
createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(weeklyTransaction));
}
// Create transactions for this month (but not this week)
for (let i = 5; i < 8; i++) {
const monthlyTransaction = {
user: testUsers[i]._id,
amount: (8 - i) * 30,
transactionType: "earned",
description: `Monthly test transaction ${i}`,
balanceAfter: testUsers[i].points + (8 - i) * 30,
createdAt: new Date(now.getTime() - (i + 5) * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(monthlyTransaction));
}
await Promise.all(transactionPromises);
});
afterEach(async () => {
// Clean up test data
const deletePromises = [];
// Delete test users
for (const user of testUsers) {
if (user._id && user._rev) {
deletePromises.push(
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
}
}
// Delete test transactions
const transactions = await couchdbService.find({
selector: { type: "point_transaction" }
});
for (const transaction of transactions) {
deletePromises.push(
couchdbService.deleteDocument(transaction._id, transaction._rev).catch(() => {})
);
}
await Promise.all(deletePromises);
testUsers = [];
clearCache();
});
afterAll(async () => {
await new Promise((resolve) => {
server.close(resolve);
});
});
describe("GET /api/leaderboard/global", () => {
it("should get global leaderboard with top users", async () => {
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThan(0);
expect(res.body.data.length).toBeLessThanOrEqual(100);
// Check first user has highest points
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("rank", 1);
expect(firstUser).toHaveProperty("userId");
expect(firstUser).toHaveProperty("username");
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("streetsAdopted");
expect(firstUser).toHaveProperty("tasksCompleted");
expect(firstUser).toHaveProperty("badges");
// Verify sorting by points descending
for (let i = 0; i < res.body.data.length - 1; i++) {
expect(res.body.data[i].points).toBeGreaterThanOrEqual(
res.body.data[i + 1].points
);
}
});
it("should respect limit parameter", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.length).toBeLessThanOrEqual(5);
expect(res.body.limit).toBe(5);
});
it("should respect offset parameter", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global?limit=5&offset=5")
.expect(200);
expect(res2.body.success).toBe(true);
expect(res2.body.offset).toBe(5);
// First user in second page should have lower points than last in first page
if (res2.body.data.length > 0 && res1.body.data.length === 5) {
expect(res2.body.data[0].points).toBeLessThanOrEqual(
res1.body.data[4].points
);
}
});
it("should cache leaderboard results", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
it("should limit maximum request to 500", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=1000")
.expect(200);
expect(res.body.limit).toBe(500);
});
});
describe("GET /api/leaderboard/weekly", () => {
it("should get weekly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("week");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this week", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
// Weekly leaderboard should have users with weekly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
it("should respect limit and offset", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly?limit=3&offset=1")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.limit).toBe(3);
expect(res.body.offset).toBe(1);
});
});
describe("GET /api/leaderboard/monthly", () => {
it("should get monthly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("month");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this month", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
// Monthly leaderboard should have users with monthly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
});
describe("GET /api/leaderboard/friends", () => {
it("should require authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.expect(401);
expect(res.body.success).toBe(false);
});
it("should get friends leaderboard with authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should include user if no friends", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
// Should at least include the user themselves
const userEntry = res.body.data.find(entry => entry.userId === testUserId);
if (res.body.data.length > 0) {
expect(userEntry || res.body.data.length === 1).toBeTruthy();
}
});
});
describe("GET /api/leaderboard/user/:userId", () => {
it("should get user leaderboard position", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
expect(res.body.data).toHaveProperty("userId", testUserId);
expect(res.body.data).toHaveProperty("username");
expect(res.body.data).toHaveProperty("points");
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("percentile");
});
it("should support timeframe parameter", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}?timeframe=week`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
});
it("should return 404 for non-existent user", async () => {
const res = await request(app)
.get("/api/leaderboard/user/nonexistent_user_id")
.expect(404);
expect(res.body.success).toBe(false);
});
});
describe("GET /api/leaderboard/stats", () => {
it("should get leaderboard statistics", async () => {
const res = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("totalPoints");
expect(res.body.data).toHaveProperty("avgPoints");
expect(res.body.data).toHaveProperty("maxPoints");
expect(res.body.data).toHaveProperty("minPoints");
expect(res.body.data).toHaveProperty("weeklyStats");
expect(res.body.data.weeklyStats).toHaveProperty("totalPoints");
expect(res.body.data.weeklyStats).toHaveProperty("activeUsers");
expect(res.body.data.weeklyStats).toHaveProperty("transactions");
});
it("should cache statistics", async () => {
const res1 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
});
describe("Edge Cases", () => {
it("should handle empty leaderboard gracefully", async () => {
// Delete all users first
const deletePromises = testUsers.map(user =>
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
await Promise.all(deletePromises);
clearCache();
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
});
it("should handle invalid limit gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=invalid")
.expect(200);
expect(res.body.success).toBe(true);
expect(typeof res.body.limit).toBe("number");
});
it("should handle negative offset gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?offset=-5")
.expect(200);
expect(res.body.success).toBe(true);
});
});
});