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:
@@ -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(() => ({
|
||||
|
||||
488
backend/__tests__/routes/analytics.test.js
Normal file
488
backend/__tests__/routes/analytics.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
396
backend/__tests__/routes/leaderboard.test.js
Normal file
396
backend/__tests__/routes/leaderboard.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user