Files
adopt-a-street/backend/__tests__/gamification.test.js
William Valentin 5e872ef952 fix: resolve test infrastructure issues
- Fixed server.js to only start when run directly (not when imported by tests)
- Updated CouchDB service mocks in errorhandling and gamification tests
- Added proper mock implementations for createDocument and updateDocument
- All 221 model tests now passing with standardized error handling

🤖 Generated with [AI Assistant]

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-11-03 10:34:27 -08:00

649 lines
20 KiB
JavaScript

// Mock CouchDB service before importing anything else
jest.mock('../services/couchdbService', () => ({
initialize: jest.fn().mockResolvedValue(true),
isReady: jest.fn().mockReturnValue(true),
create: jest.fn(),
getById: jest.fn(),
find: jest.fn(),
createDocument: jest.fn().mockImplementation((doc) => ({
...doc,
_rev: '1-abc123'
})),
updateDocument: jest.fn().mockImplementation((doc) => ({
...doc,
_rev: '2-def456'
})),
deleteDocument: jest.fn(),
findByType: jest.fn().mockResolvedValue([]),
findUserById: jest.fn().mockImplementation((id) => {
// Mock user lookup for tests
if (id.startsWith('user_')) {
return {
_id: id,
_rev: '1-abc123',
type: 'user',
name: 'Test User',
email: 'test@example.com',
points: 100,
stats: {
streetsAdopted: 1,
tasksCompleted: 1,
postsCreated: 1,
eventsParticipated: 1,
badgesEarnn: 1
}
};
}
return null;
}),
findUserByEmail: jest.fn(),
update: jest.fn(),
getDocument: jest.fn(),
}));
const request = require("supertest");
const app = require("../server");
const User = require("../models/User");
const Task = require("../models/Task");
const Street = require("../models/Street");
const Event = require("../models/Event");
const Post = require("../models/Post");
const couchdbService = require("../services/couchdbService");
const { generateTestId } = require('./utils/idGenerator');
describe("Gamification System", () => {
let testUser;
let testUser2;
let authToken;
let authToken2;
beforeAll(async () => {
// Initialize CouchDB for testing
await couchdbService.initialize();
// Create test users
testUser = await User.create({
name: "Test User",
email: "test@example.com",
password: "password123",
points: 0,
stats: {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 0,
badgesEarned: 0,
},
});
testUser2 = await User.create({
name: "Test User 2",
email: "test2@example.com",
password: "password123",
points: 100,
stats: {
streetsAdopted: 1,
tasksCompleted: 5,
postsCreated: 3,
eventsParticipated: 2,
badgesEarned: 2,
},
});
// Generate auth tokens
const jwt = require("jsonwebtoken");
authToken = jwt.sign(
{ user: { id: testUser._id } },
process.env.JWT_SECRET || "test_secret"
);
authToken2 = jwt.sign(
{ user: { id: testUser2._id } },
process.env.JWT_SECRET || "test_secret"
);
// Create test badges in CouchDB
const badges = [
{
_id: "badge_starter",
type: "badge",
name: "Street Starter",
description: "Adopt your first street",
icon: "🏠",
rarity: "common",
criteria: { type: "street_adoptions", threshold: 1 },
isActive: true,
order: 1,
},
{
_id: "badge_task_master",
type: "badge",
name: "Task Master",
description: "Complete 10 tasks",
icon: "✅",
rarity: "rare",
criteria: { type: "task_completions", threshold: 10 },
isActive: true,
order: 2,
},
{
_id: "badge_social_butterfly",
type: "badge",
name: "Social Butterfly",
description: "Create 20 posts",
icon: "🦋",
rarity: "epic",
criteria: { type: "post_creations", threshold: 20 },
isActive: true,
order: 3,
},
{
_id: "badge_event_enthusiast",
type: "badge",
name: "Event Enthusiast",
description: "Participate in 5 events",
icon: "🎉",
rarity: "rare",
criteria: { type: "event_participations", threshold: 5 },
isActive: true,
order: 4,
},
{
_id: "badge_point_collector",
type: "badge",
name: "Point Collector",
description: "Earn 500 points",
icon: "💰",
rarity: "legendary",
criteria: { type: "points_earned", threshold: 500 },
isActive: true,
order: 5,
},
];
for (const badge of badges) {
await couchdbService.createDocument(badge);
}
});
afterAll(async () => {
await couchdbService.shutdown();
});
beforeEach(async () => {
// Reset user points and stats
const user = await User.findById(testUser._id);
user.points = 0;
user.stats = {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 0,
badgesEarned: 0,
};
user.earnedBadges = [];
await user.save();
});
describe("Points System", () => {
test("should award points for street adoption", async () => {
const street = await Street.create({
name: "Test Street",
location: { type: "Point", coordinates: [-74.0060, 40.7128] },
status: "available",
});
await street.save();
const response = await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken)
.expect(200);
expect(response.body.pointsAwarded).toBe(50);
expect(response.body.newBalance).toBe(50);
// Verify user points updated
const updatedUser = await User.findById(testUser._id);
expect(updatedUser.points).toBe(50);
expect(updatedUser.stats.streetsAdopted).toBe(1);
});
test("should award points for task completion", async () => {
const task = await Task.create({
title: "Test Task",
description: "Test Description",
street: { streetId: generateTestId() },
pointsAwarded: 10,
status: "pending",
});
const response = await request(app)
.put(`/api/tasks/${task._id}/complete`)
.set("x-auth-token", authToken)
.expect(200);
expect(response.body.pointsAwarded).toBe(10);
expect(response.body.newBalance).toBe(10);
const updatedUser = await User.findById(testUser._id);
expect(updatedUser.points).toBe(10);
expect(updatedUser.stats.tasksCompleted).toBe(1);
});
test("should award points for event participation", async () => {
const event = new Event({
title: "Test Event",
description: "Test Description",
date: new Date(Date.now() + 86400000),
location: "Test Location",
participants: [],
});
await event.save();
const response = await request(app)
.put(`/api/events/rsvp/${event._id}`)
.set("x-auth-token", authToken)
.expect(200);
expect(response.body.pointsAwarded).toBe(15);
expect(response.body.newBalance).toBe(15);
const updatedUser = await User.findById(testUser._id);
expect(updatedUser.points).toBe(15);
expect(updatedUser.stats.eventsParticipated).toBe(1);
});
test("should award points for post creation", async () => {
const postData = {
content: "This is a test post",
};
const response = await request(app)
.post("/api/posts")
.set("x-auth-token", authToken)
.send(postData)
.expect(200);
// Points are awarded through CouchDB service
const updatedUser = await User.findById(testUser._id);
expect(updatedUser.points).toBe(5);
expect(updatedUser.stats.postsCreated).toBe(1);
});
test("should track point transactions", async () => {
// Create some activity to generate transactions
const street = new Street({
name: "Test Street",
location: { type: "Point", coordinates: [-74.0060, 40.7128] },
status: "available",
});
await street.save();
await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken);
// Check CouchDB for transactions
const transactions = await couchdbService.findByType('point_transaction', {
'user.userId': testUser._id.toString()
});
expect(transactions.length).toBe(1);
expect(transactions[0].amount).toBe(50);
expect(transactions[0].description).toBe('Street adoption');
expect(transactions[0].balanceAfter).toBe(50);
});
test("should prevent negative points", async () => {
// Try to deduct more points than user has
await expect(
couchdbService.updateUserPoints(testUser._id.toString(), -100, "Penalty")
).rejects.toThrow();
const user = await User.findById(testUser._id);
expect(user.points).toBe(0);
});
});
describe("Badge System", () => {
test("should award street adoption badge", async () => {
// Adopt a street
const street = new Street({
name: "Test Street",
location: { type: "Point", coordinates: [-74.0060, 40.7128] },
status: "available",
});
await street.save();
await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken);
// Check if badge was awarded
const updatedUser = await User.findById(testUser._id);
expect(updatedUser.earnedBadges.length).toBe(1);
expect(updatedUser.earnedBadges[0].name).toBe("Street Starter");
expect(updatedUser.stats.badgesEarned).toBe(1);
});
test("should award task completion badge", async () => {
// Complete 10 tasks
for (let i = 0; i < 10; i++) {
const task = await Task.create({
title: `Task ${i}`,
description: "Test Description",
street: { streetId: generateTestId() },
pointsAwarded: 10,
status: "pending",
});
await request(app)
.put(`/api/tasks/${task._id}/complete`)
.set("x-auth-token", authToken);
}
// Check if badge was awarded
const updatedUser = await User.findById(testUser._id);
const taskMasterBadge = updatedUser.earnedBadges.find(
(badge) => badge.name === "Task Master"
);
expect(taskMasterBadge).toBeDefined();
expect(taskMasterBadge.rarity).toBe("rare");
});
test("should track badge progress", async () => {
// Create 5 posts (out of 20 needed for Social Butterfly badge)
for (let i = 0; i < 5; i++) {
await request(app)
.post("/api/posts")
.set("x-auth-token", authToken)
.send({ content: `Test post ${i}` });
}
// Check badge progress in CouchDB
const userBadges = await couchdbService.findByType('user_badge', {
userId: testUser._id.toString()
});
const socialButterflyProgress = userBadges.find(
(badge) => badge.badgeId === "badge_social_butterfly"
);
expect(socialButterflyProgress).toBeDefined();
expect(socialButterflyProgress.progress).toBe(25); // 5/20 = 25%
});
test("should not award duplicate badges", async () => {
// Adopt a street twice (second attempt should fail but still check badges)
const street = new Street({
name: "Test Street",
location: { type: "Point", coordinates: [-74.0060, 40.7128] },
status: "available",
});
await street.save();
await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken);
// Try to adopt again (should fail)
await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken)
.expect(400);
// Should still only have one badge
const updatedUser = await User.findById(testUser._id);
const streetStarterBadges = updatedUser.earnedBadges.filter(
(badge) => badge.name === "Street Starter"
);
expect(streetStarterBadges.length).toBe(1);
});
test("should award point-based badge", async () => {
// Accumulate 500 points through various activities
const activities = [
{ type: 'street', points: 50, count: 4 }, // 4 street adoptions = 200 points
{ type: 'task', points: 10, count: 20 }, // 20 tasks = 200 points
{ type: 'event', points: 15, count: 6 }, // 6 events = 90 points
{ type: 'post', points: 5, count: 2 }, // 2 posts = 10 points
];
for (const activity of activities) {
for (let i = 0; i < activity.count; i++) {
if (activity.type === 'street') {
const street = new Street({
name: `Street ${i}`,
location: { type: "Point", coordinates: [-74.0060, 40.7128] },
status: "available",
});
await street.save();
await request(app)
.put(`/api/streets/adopt/${street._id}`)
.set("x-auth-token", authToken);
} else if (activity.type === 'task') {
const task = await Task.create({
title: `Task ${i}`,
description: "Test Description",
street: { streetId: generateTestId() },
pointsAwarded: activity.points,
status: "pending",
});
await request(app)
.put(`/api/tasks/${task._id}/complete`)
.set("x-auth-token", authToken);
} else if (activity.type === 'event') {
const event = await Event.create({
title: `Event ${i}`,
description: "Test Description",
date: new Date(Date.now() + 86400000),
location: "Test Location",
participants: [],
});
await request(app)
.put(`/api/events/rsvp/${event._id}`)
.set("x-auth-token", authToken);
} else if (activity.type === 'post') {
await request(app)
.post("/api/posts")
.set("x-auth-token", authToken)
.send({ content: `Test post ${i}` });
}
}
}
// Check if Point Collector badge was awarded
const updatedUser = await User.findById(testUser._id);
const pointCollectorBadge = updatedUser.earnedBadges.find(
(badge) => badge.name === "Point Collector"
);
expect(pointCollectorBadge).toBeDefined();
expect(pointCollectorBadge.rarity).toBe("legendary");
});
});
describe("Leaderboard System", () => {
beforeEach(async () => {
// Set up users with different point levels
await User.findByIdAndUpdate(testUser._id, { points: 250 });
await User.findByIdAndUpdate(testUser2._id, { points: 450 });
// Create a third user
const testUser3 = new User({
name: "Leader User",
email: "leader@example.com",
password: "password123",
points: 750,
stats: {
streetsAdopted: 5,
tasksCompleted: 25,
postsCreated: 10,
eventsParticipated: 8,
badgesEarned: 4,
},
});
await testUser3.save();
});
test("should return leaderboard in correct order", async () => {
const response = await request(app)
.get("/api/rewards/leaderboard")
.expect(200);
expect(response.body.length).toBe(3);
expect(response.body[0].points).toBe(750);
expect(response.body[0].name).toBe("Leader User");
expect(response.body[1].points).toBe(450);
expect(response.body[2].points).toBe(250);
});
test("should limit leaderboard results", async () => {
const response = await request(app)
.get("/api/rewards/leaderboard?limit=2")
.expect(200);
expect(response.body.length).toBe(2);
expect(response.body[0].points).toBe(750);
expect(response.body[1].points).toBe(450);
});
test("should include user stats in leaderboard", async () => {
const response = await request(app)
.get("/api/rewards/leaderboard")
.expect(200);
const leader = response.body[0];
expect(leader.stats).toBeDefined();
expect(leader.stats.streetsAdopted).toBe(5);
expect(leader.stats.tasksCompleted).toBe(25);
expect(leader.stats.badgesEarned).toBe(4);
});
test("should handle empty leaderboard", async () => {
// Delete all users
await User.deleteMany({});
const response = await request(app)
.get("/api/rewards/leaderboard")
.expect(200);
expect(response.body).toHaveLength(0);
});
});
describe("Point Transactions", () => {
test("should create transaction record for point changes", async () => {
await couchdbService.updateUserPoints(
testUser._id.toString(),
25,
"Test transaction",
{
entityType: "Test",
entityId: "test123",
entityName: "Test Entity"
}
);
const transactions = await couchdbService.findByType('point_transaction', {
'user.userId': testUser._id.toString()
});
expect(transactions.length).toBe(1);
expect(transactions[0].amount).toBe(25);
expect(transactions[0].description).toBe("Test transaction");
expect(transactions[0].relatedEntity.entityType).toBe("Test");
expect(transactions[0].relatedEntity.entityId).toBe("test123");
expect(transactions[0].balanceAfter).toBe(25);
});
test("should track transaction history", async () => {
// Create multiple transactions
await couchdbService.updateUserPoints(testUser._id.toString(), 50, "Street adoption");
await couchdbService.updateUserPoints(testUser._id.toString(), 10, "Task completion");
await couchdbService.updateUserPoints(testUser._id.toString(), 15, "Event participation");
const transactions = await couchdbService.findByType('point_transaction', {
'user.userId': testUser._id.toString()
}, {
sort: [{ createdAt: 'desc' }]
});
expect(transactions.length).toBe(3);
expect(transactions[0].amount).toBe(15); // Most recent
expect(transactions[1].amount).toBe(10);
expect(transactions[2].amount).toBe(50); // Oldest
});
test("should categorize transactions correctly", async () => {
await couchdbService.updateUserPoints(testUser._id.toString(), 50, "Street adoption");
await couchdbService.updateUserPoints(testUser._id.toString(), 10, "Completed task: Test task");
await couchdbService.updateUserPoints(testUser._id.toString(), 5, "Created post: Test post");
await couchdbService.updateUserPoints(testUser._id.toString(), 15, "Joined event: Test event");
const transactions = await couchdbService.findByType('point_transaction', {
'user.userId': testUser._id.toString()
});
const types = transactions.map(t => t.type);
expect(types).toContain('street_adoption');
expect(types).toContain('task_completion');
expect(types).toContain('post_creation');
expect(types).toContain('event_participation');
});
});
describe("Performance Tests", () => {
test("should handle concurrent point updates", async () => {
const startTime = Date.now();
const promises = [];
for (let i = 0; i < 50; i++) {
promises.push(
couchdbService.updateUserPoints(
testUser._id.toString(),
5,
`Concurrent transaction ${i}`
)
);
}
await Promise.all(promises);
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete within 5 seconds
expect(duration).toBeLessThan(5000);
// Check final balance
const user = await User.findById(testUser._id);
expect(user.points).toBe(250); // 50 * 5
});
test("should handle large leaderboard efficiently", async () => {
// Create many users
const users = [];
for (let i = 0; i < 100; i++) {
users.push({
name: `User ${i}`,
email: `user${i}@example.com`,
password: "password123",
points: Math.floor(Math.random() * 1000),
});
}
await User.insertMany(users);
const startTime = Date.now();
const response = await request(app)
.get("/api/rewards/leaderboard")
.expect(200);
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete within 2 seconds even with 100+ users
expect(duration).toBeLessThan(2000);
expect(response.body.length).toBeGreaterThan(100);
});
});
});