Files
adopt-a-street/backend/__tests__/routes/analytics.test.js
William Valentin 3e4c730860 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>
2025-11-03 13:53:48 -08:00

489 lines
17 KiB
JavaScript

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);
});
});
});