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>
489 lines
17 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|