test(ratings): add comprehensive E2E test for ratings & stats flow
Add end-to-end test verifying the complete ratings and stats update flow: - Auto match creation from suggestion acceptance - Both users rating each other - Stats updated exactly once (recordingsDone/recordingsReceived) - Manual matches do NOT update stats - Double-rating prevention (idempotency) Test coverage (9 scenarios): - STEP 1-3: Event creation, user enrollment, heat declaration - STEP 4: Matching algorithm execution + saveMatchingResults fix - STEP 5: Suggestion acceptance creates auto match (source='auto') - STEP 6a: First rating (no stats update yet) - STEP 6b: Second rating triggers stats update + match completion - STEP 7: Verify duplicate rating prevention - STEP 8: Verify manual matches don't affect fairness stats Infrastructure: - Add jest.setup.js to load .env.development for all tests - Update package.json to use setupFilesAfterEnv Documentation: - Mark S10 (Ratings & Stats) as ✅ IMPLEMENTED in TODO.md - Remove from Critical Gaps section - Add detailed implementation references All tests passing ✅
This commit is contained in:
5
backend/jest.setup.js
Normal file
5
backend/jest.setup.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Jest setup file - runs before all tests
|
||||||
|
* Loads environment variables from .env.development
|
||||||
|
*/
|
||||||
|
require('dotenv').config({ path: '.env.development' });
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/jest.setup.js"],
|
||||||
"coveragePathIgnorePatterns": [
|
"coveragePathIgnorePatterns": [
|
||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
|
|||||||
453
backend/src/__tests__/ratings-stats-flow.test.js
Normal file
453
backend/src/__tests__/ratings-stats-flow.test.js
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* End-to-End Test: Ratings & Stats Flow
|
||||||
|
*
|
||||||
|
* Tests the complete flow from auto-matching to rating to stats update:
|
||||||
|
* 1. Create event with deadline
|
||||||
|
* 2. Users join and declare heats
|
||||||
|
* 3. Run matching algorithm
|
||||||
|
* 4. Recorder accepts suggestion (creates auto match)
|
||||||
|
* 5. Both users rate each other
|
||||||
|
* 6. Verify stats are updated exactly once
|
||||||
|
* 7. Verify no double-counting on repeated requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../app');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { MATCH_STATUS } = require('../constants');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
describe('Ratings & Stats Flow (End-to-End)', () => {
|
||||||
|
let dancerToken, recorderToken;
|
||||||
|
let dancerId, recorderId;
|
||||||
|
let dancer2Token, recorder2Token;
|
||||||
|
let dancer2Id, recorder2Id;
|
||||||
|
let eventId, eventSlug;
|
||||||
|
let matchSlug;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Cleanup any stale test data first
|
||||||
|
await prisma.rating.deleteMany({});
|
||||||
|
await prisma.match.deleteMany({});
|
||||||
|
await prisma.recordingSuggestion.deleteMany({});
|
||||||
|
await prisma.eventUserHeat.deleteMany({});
|
||||||
|
await prisma.eventParticipant.deleteMany({});
|
||||||
|
await prisma.event.deleteMany({ where: { name: { startsWith: 'Rating Test Event' } } });
|
||||||
|
await prisma.user.deleteMany({ where: {
|
||||||
|
OR: [
|
||||||
|
{ email: { contains: 'dancer-' } },
|
||||||
|
{ email: { contains: 'recorder-' } },
|
||||||
|
]
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Create two test users
|
||||||
|
const dancerRes = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
email: `dancer-${Date.now()}@test.com`,
|
||||||
|
username: `dancer_${Date.now()}`,
|
||||||
|
password: 'Test1234!',
|
||||||
|
firstName: 'Dancer',
|
||||||
|
lastName: 'TestUser',
|
||||||
|
});
|
||||||
|
|
||||||
|
dancerToken = dancerRes.body.data.token;
|
||||||
|
dancerId = dancerRes.body.data.user.id;
|
||||||
|
|
||||||
|
const recorderRes = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
email: `recorder-${Date.now()}@test.com`,
|
||||||
|
username: `recorder_${Date.now()}`,
|
||||||
|
password: 'Test1234!',
|
||||||
|
firstName: 'Recorder',
|
||||||
|
lastName: 'TestUser',
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderToken = recorderRes.body.data.token;
|
||||||
|
recorderId = recorderRes.body.data.user.id;
|
||||||
|
|
||||||
|
// Create two more users for manual match test (STEP 8)
|
||||||
|
const timestamp2 = Date.now() + 1000; // Add offset to ensure unique timestamps
|
||||||
|
const dancer2Res = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
email: `dancer2-${timestamp2}@test.com`,
|
||||||
|
username: `dancer2_${timestamp2}`,
|
||||||
|
password: 'Test1234!',
|
||||||
|
firstName: 'DancerTwo',
|
||||||
|
lastName: 'TestUser',
|
||||||
|
});
|
||||||
|
|
||||||
|
dancer2Token = dancer2Res.body.data.token;
|
||||||
|
dancer2Id = dancer2Res.body.data.user.id;
|
||||||
|
|
||||||
|
const timestamp3 = Date.now() + 2000; // Different timestamp for recorder2
|
||||||
|
const recorder2Res = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
email: `recorder2-${timestamp3}@test.com`,
|
||||||
|
username: `recorder2_${timestamp3}`,
|
||||||
|
password: 'Test1234!',
|
||||||
|
firstName: 'RecorderTwo',
|
||||||
|
lastName: 'TestUser',
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder2Token = recorder2Res.body.data.token;
|
||||||
|
recorder2Id = recorder2Res.body.data.user.id;
|
||||||
|
|
||||||
|
// Verify initial stats are 0/0
|
||||||
|
const dancerStats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancerId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
expect(dancerStats.recordingsDone).toBe(0);
|
||||||
|
expect(dancerStats.recordingsReceived).toBe(0);
|
||||||
|
|
||||||
|
const recorderStats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorderId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
expect(recorderStats.recordingsDone).toBe(0);
|
||||||
|
expect(recorderStats.recordingsReceived).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup - order matters due to foreign keys
|
||||||
|
if (eventId) {
|
||||||
|
await prisma.rating.deleteMany({
|
||||||
|
where: { match: { eventId } },
|
||||||
|
});
|
||||||
|
await prisma.match.deleteMany({ where: { eventId } });
|
||||||
|
await prisma.recordingSuggestion.deleteMany({ where: { eventId } });
|
||||||
|
await prisma.eventUserHeat.deleteMany({ where: { eventId } });
|
||||||
|
await prisma.eventParticipant.deleteMany({ where: { eventId } });
|
||||||
|
await prisma.event.delete({ where: { id: eventId } });
|
||||||
|
}
|
||||||
|
// Only delete users if they were successfully created
|
||||||
|
const userIdsToDelete = [dancerId, recorderId, dancer2Id, recorder2Id].filter(id => id !== undefined);
|
||||||
|
if (userIdsToDelete.length > 0) {
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: userIdsToDelete } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 1: Create event with deadline in past (ready for matching)', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Create event directly in DB for test
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name: `Rating Test Event ${Date.now()}`,
|
||||||
|
slug: `rating-test-${Date.now()}`,
|
||||||
|
location: 'Test City',
|
||||||
|
startDate: now,
|
||||||
|
endDate: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
registrationDeadline: yesterday, // Deadline already passed
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
eventId = event.id;
|
||||||
|
eventSlug = event.slug;
|
||||||
|
expect(eventId).toBeDefined();
|
||||||
|
expect(eventSlug).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 2: Both users join event', async () => {
|
||||||
|
// Create participants directly in DB (including dancer2 and recorder2 for STEP 8)
|
||||||
|
await prisma.eventParticipant.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: dancerId, eventId, competitorNumber: 101 },
|
||||||
|
{ userId: recorderId, eventId, competitorNumber: 102 },
|
||||||
|
{ userId: dancer2Id, eventId, competitorNumber: 103 },
|
||||||
|
{ userId: recorder2Id, eventId, competitorNumber: 104 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const participants = await prisma.eventParticipant.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
expect(participants.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 3: Dancer declares heat', async () => {
|
||||||
|
// Get or create division and competition type
|
||||||
|
let division = await prisma.division.findFirst();
|
||||||
|
if (!division) {
|
||||||
|
division = await prisma.division.create({
|
||||||
|
data: { name: 'Newcomer', abbreviation: 'NEW' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let compType = await prisma.competitionType.findFirst();
|
||||||
|
if (!compType) {
|
||||||
|
compType = await prisma.competitionType.create({
|
||||||
|
data: { name: 'Jack & Jill', abbreviation: 'J&J' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create heat directly in DB
|
||||||
|
await prisma.eventUserHeat.create({
|
||||||
|
data: {
|
||||||
|
userId: dancerId,
|
||||||
|
eventId,
|
||||||
|
divisionId: division.id,
|
||||||
|
competitionTypeId: compType.id,
|
||||||
|
heatNumber: 5,
|
||||||
|
role: 'leader',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const heats = await prisma.eventUserHeat.findMany({
|
||||||
|
where: { eventId, userId: dancerId },
|
||||||
|
});
|
||||||
|
expect(heats.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 4: Run matching algorithm', async () => {
|
||||||
|
const matchingService = require('../services/matching');
|
||||||
|
const generatedSuggestions = await matchingService.runMatching(eventId);
|
||||||
|
|
||||||
|
// Save suggestions to database
|
||||||
|
await matchingService.saveMatchingResults(eventId, generatedSuggestions);
|
||||||
|
|
||||||
|
// Verify suggestion was created
|
||||||
|
const suggestions = await prisma.recordingSuggestion.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
expect(suggestions.length).toBeGreaterThan(0);
|
||||||
|
expect(suggestions[0].status).toBe('pending');
|
||||||
|
expect(suggestions[0].recorderId).toBe(recorderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 5: Recorder accepts suggestion (creates auto match)', async () => {
|
||||||
|
// Get the suggestion
|
||||||
|
const suggestion = await prisma.recordingSuggestion.findFirst({
|
||||||
|
where: { eventId, recorderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/events/${eventSlug}/match-suggestions/${suggestion.id}/status`)
|
||||||
|
.set('Authorization', `Bearer ${recorderToken}`)
|
||||||
|
.send({ status: 'accepted' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.status).toBe('accepted');
|
||||||
|
expect(res.body.data.matchSlug).toBeDefined();
|
||||||
|
|
||||||
|
matchSlug = res.body.data.matchSlug;
|
||||||
|
|
||||||
|
// Verify match was created with correct properties
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { slug: matchSlug },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
user1Id: true,
|
||||||
|
user2Id: true,
|
||||||
|
source: true,
|
||||||
|
status: true,
|
||||||
|
statsApplied: true,
|
||||||
|
suggestionId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(match).toBeTruthy();
|
||||||
|
expect(match.user1Id).toBe(dancerId); // Convention: user1 = dancer
|
||||||
|
expect(match.user2Id).toBe(recorderId); // Convention: user2 = recorder
|
||||||
|
expect(match.source).toBe('auto'); // ✅ CRITICAL: Must be 'auto'
|
||||||
|
expect(match.status).toBe(MATCH_STATUS.ACCEPTED);
|
||||||
|
expect(match.statsApplied).toBe(false); // ✅ Not applied yet
|
||||||
|
expect(match.suggestionId).toBe(suggestion.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 6a: Dancer rates recorder (first rating)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/matches/${matchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${dancerToken}`)
|
||||||
|
.send({
|
||||||
|
score: 5,
|
||||||
|
comment: 'Great recorder!',
|
||||||
|
wouldCollaborateAgain: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.data.raterId).toBe(dancerId);
|
||||||
|
expect(res.body.data.ratedId).toBe(recorderId);
|
||||||
|
expect(res.body.data.score).toBe(5);
|
||||||
|
|
||||||
|
// Verify match is still ACCEPTED (not COMPLETED yet - need both ratings)
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { slug: matchSlug },
|
||||||
|
select: { status: true, statsApplied: true },
|
||||||
|
});
|
||||||
|
expect(match.status).toBe(MATCH_STATUS.ACCEPTED);
|
||||||
|
expect(match.statsApplied).toBe(false); // ✅ Still false
|
||||||
|
|
||||||
|
// Verify stats are NOT updated yet (need both ratings)
|
||||||
|
const dancerStats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancerId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
const recorderStats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorderId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
expect(dancerStats.recordingsDone).toBe(0);
|
||||||
|
expect(dancerStats.recordingsReceived).toBe(0);
|
||||||
|
expect(recorderStats.recordingsDone).toBe(0);
|
||||||
|
expect(recorderStats.recordingsReceived).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 6b: Recorder rates dancer (second rating - triggers completion)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/matches/${matchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${recorderToken}`)
|
||||||
|
.send({
|
||||||
|
score: 4,
|
||||||
|
comment: 'Good dancer!',
|
||||||
|
wouldCollaborateAgain: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.data.raterId).toBe(recorderId);
|
||||||
|
expect(res.body.data.ratedId).toBe(dancerId);
|
||||||
|
expect(res.body.data.score).toBe(4);
|
||||||
|
|
||||||
|
// Verify match is now COMPLETED
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { slug: matchSlug },
|
||||||
|
select: { status: true, statsApplied: true },
|
||||||
|
});
|
||||||
|
expect(match.status).toBe(MATCH_STATUS.COMPLETED);
|
||||||
|
expect(match.statsApplied).toBe(true); // ✅ Applied!
|
||||||
|
|
||||||
|
// ✅ CRITICAL: Verify stats are NOW updated
|
||||||
|
const dancerStats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancerId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
const recorderStats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorderId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dancerStats.recordingsDone).toBe(0); // Dancer doesn't record
|
||||||
|
expect(dancerStats.recordingsReceived).toBe(1); // ✅ Dancer was recorded +1
|
||||||
|
expect(recorderStats.recordingsDone).toBe(1); // ✅ Recorder did record +1
|
||||||
|
expect(recorderStats.recordingsReceived).toBe(0); // Recorder wasn't recorded
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 7: Verify double-rating prevention (idempotency)', async () => {
|
||||||
|
// Try to rate again - should fail
|
||||||
|
const res1 = await request(app)
|
||||||
|
.post(`/api/matches/${matchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${dancerToken}`)
|
||||||
|
.send({
|
||||||
|
score: 3,
|
||||||
|
comment: 'Changed my mind',
|
||||||
|
wouldCollaborateAgain: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res1.status).toBe(400);
|
||||||
|
expect(res1.body.error).toContain('already rated');
|
||||||
|
|
||||||
|
const res2 = await request(app)
|
||||||
|
.post(`/api/matches/${matchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${recorderToken}`)
|
||||||
|
.send({
|
||||||
|
score: 2,
|
||||||
|
comment: 'Changed my mind too',
|
||||||
|
wouldCollaborateAgain: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
expect(res2.body.error).toContain('already rated');
|
||||||
|
|
||||||
|
// Verify stats are STILL the same (no double-counting)
|
||||||
|
const dancerStats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancerId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
const recorderStats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorderId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dancerStats.recordingsDone).toBe(0);
|
||||||
|
expect(dancerStats.recordingsReceived).toBe(1); // Still 1, not 2!
|
||||||
|
expect(recorderStats.recordingsDone).toBe(1); // Still 1, not 2!
|
||||||
|
expect(recorderStats.recordingsReceived).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STEP 8: Verify manual matches do NOT update stats', async () => {
|
||||||
|
// Create a manual match between dancer2 and recorder2 (different users)
|
||||||
|
const manualRes = await request(app)
|
||||||
|
.post('/api/matches')
|
||||||
|
.set('Authorization', `Bearer ${dancer2Token}`)
|
||||||
|
.send({
|
||||||
|
targetUserId: recorder2Id,
|
||||||
|
eventSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manualRes.status).toBe(201);
|
||||||
|
const manualMatchSlug = manualRes.body.data.slug;
|
||||||
|
|
||||||
|
// Accept manual match
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/matches/${manualMatchSlug}/accept`)
|
||||||
|
.set('Authorization', `Bearer ${recorder2Token}`);
|
||||||
|
|
||||||
|
// Rate manual match - both users
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/matches/${manualMatchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${dancer2Token}`)
|
||||||
|
.send({ score: 5, comment: 'Manual test', wouldCollaborateAgain: true });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/matches/${manualMatchSlug}/ratings`)
|
||||||
|
.set('Authorization', `Bearer ${recorder2Token}`)
|
||||||
|
.send({ score: 5, comment: 'Manual test', wouldCollaborateAgain: true });
|
||||||
|
|
||||||
|
// Verify manual match is completed
|
||||||
|
const manualMatch = await prisma.match.findUnique({
|
||||||
|
where: { slug: manualMatchSlug },
|
||||||
|
select: { status: true, statsApplied: true, source: true },
|
||||||
|
});
|
||||||
|
expect(manualMatch.status).toBe(MATCH_STATUS.COMPLETED);
|
||||||
|
expect(manualMatch.source).toBe('manual');
|
||||||
|
|
||||||
|
// ✅ CRITICAL: Verify original users' stats unchanged (still from auto match only)
|
||||||
|
const dancerStats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancerId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
const recorderStats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorderId },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dancerStats.recordingsDone).toBe(0); // Still 0 (from auto match)
|
||||||
|
expect(dancerStats.recordingsReceived).toBe(1); // Still 1 (from auto match)
|
||||||
|
expect(recorderStats.recordingsDone).toBe(1); // Still 1 (from auto match)
|
||||||
|
expect(recorderStats.recordingsReceived).toBe(0); // Still 0 (from auto match)
|
||||||
|
|
||||||
|
// ✅ CRITICAL: Verify manual match did NOT update dancer2/recorder2 stats
|
||||||
|
const dancer2Stats = await prisma.user.findUnique({
|
||||||
|
where: { id: dancer2Id },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
const recorder2Stats = await prisma.user.findUnique({
|
||||||
|
where: { id: recorder2Id },
|
||||||
|
select: { recordingsDone: true, recordingsReceived: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dancer2Stats.recordingsDone).toBe(0); // Manual match = no stats update
|
||||||
|
expect(dancer2Stats.recordingsReceived).toBe(0); // Manual match = no stats update
|
||||||
|
expect(recorder2Stats.recordingsDone).toBe(0); // Manual match = no stats update
|
||||||
|
expect(recorder2Stats.recordingsReceived).toBe(0); // Manual match = no stats update
|
||||||
|
});
|
||||||
|
});
|
||||||
97
docs/TODO.md
97
docs/TODO.md
@@ -12,17 +12,6 @@
|
|||||||
|
|
||||||
### High Priority Tasks
|
### High Priority Tasks
|
||||||
|
|
||||||
**🔴 CRITICAL: Recording Stats Update Mechanism**
|
|
||||||
- **Issue:** Fields `recordingsDone` and `recordingsReceived` exist in database but no mechanism to update them
|
|
||||||
- **Requirements:**
|
|
||||||
- Analyze how to update these fields consistently with tier system and ratings
|
|
||||||
- Determine update trigger: after match completion? after rating? automatic on suggestion acceptance?
|
|
||||||
- Ensure consistency with existing rating system
|
|
||||||
- Consider edge cases: declined suggestions, cancelled matches, incomplete ratings
|
|
||||||
- Design API endpoints or automated triggers for stat updates
|
|
||||||
- **Impact:** Tier system fairness algorithm depends on accurate karma tracking
|
|
||||||
- **Dependencies:** Matches API, Ratings API, Recording Suggestions
|
|
||||||
|
|
||||||
**🟡 HIGH: Matching Algorithm Integration Tests**
|
**🟡 HIGH: Matching Algorithm Integration Tests**
|
||||||
- **Issue:** Only unit tests for helper functions exist, no end-to-end tests for `runMatching()`
|
- **Issue:** Only unit tests for helper functions exist, no end-to-end tests for `runMatching()`
|
||||||
- **Test Plan:** `backend/src/__tests__/matching-scenarios.md` (18 scenarios defined)
|
- **Test Plan:** `backend/src/__tests__/matching-scenarios.md` (18 scenarios defined)
|
||||||
@@ -36,7 +25,13 @@
|
|||||||
- **Status:** Test plan documented, implementation pending
|
- **Status:** Test plan documented, implementation pending
|
||||||
- **Extended Scenarios:** See comprehensive test scenarios below
|
- **Extended Scenarios:** See comprehensive test scenarios below
|
||||||
|
|
||||||
### Recently Completed (2025-11-29)
|
### Recently Completed (2025-11-30)
|
||||||
|
- **Ratings & Stats System** - Auto matches update recordingsDone/recordingsReceived stats, manual matches don't
|
||||||
|
- E2E test: `backend/src/__tests__/ratings-stats-flow.test.js` (9 test scenarios)
|
||||||
|
- Atomic stats application with `statsApplied` flag to prevent double-counting
|
||||||
|
- Frontend UI already exists in `RatePartnerPage.jsx`
|
||||||
|
|
||||||
|
### Previously Completed (2025-11-29)
|
||||||
- 3-Tier Account System (BASIC/SUPPORTER/COMFORT) with fairness algorithm
|
- 3-Tier Account System (BASIC/SUPPORTER/COMFORT) with fairness algorithm
|
||||||
- Dual Buffer System (prep before + rest after dancing)
|
- Dual Buffer System (prep before + rest after dancing)
|
||||||
- Clickable Usernames with @ prefix in profiles
|
- Clickable Usernames with @ prefix in profiles
|
||||||
@@ -59,48 +54,43 @@
|
|||||||
#### ✅ Implemented Scenarios
|
#### ✅ Implemented Scenarios
|
||||||
- **S1-S3:** Basic flow, collision detection, limits (covered by existing tests)
|
- **S1-S3:** Basic flow, collision detection, limits (covered by existing tests)
|
||||||
- **S7.1-7.2:** Manual match blocks auto suggestions (implemented 2025-11-30)
|
- **S7.1-7.2:** Manual match blocks auto suggestions (implemented 2025-11-30)
|
||||||
|
- **S10:** Ratings & Stats System (implemented 2025-11-30, E2E tested)
|
||||||
- **S12:** Multi-heat collision detection (existing logic)
|
- **S12:** Multi-heat collision detection (existing logic)
|
||||||
- **S14.1:** Only recorder can accept/reject (implemented in MVP)
|
- **S14.1:** Only recorder can accept/reject (implemented in MVP)
|
||||||
|
|
||||||
#### 🔴 Critical Gaps (P0 - Before Production)
|
#### 🔴 Critical Gaps (P0 - Before Production)
|
||||||
|
|
||||||
1. **S10: Ratings & Stats System** - **CRITICAL**
|
1. **S14.2: Admin Middleware** - **SECURITY**
|
||||||
- Fields `recordingsDone`/`recordingsReceived` exist but NEVER updated
|
|
||||||
- Fairness algorithm depends on these stats - currently broken!
|
|
||||||
- Need: `statsApplied` flag on Match model
|
|
||||||
- Need: Auto-increment stats after both users rate (only for auto matches)
|
|
||||||
|
|
||||||
2. **S14.2: Admin Middleware** - **SECURITY**
|
|
||||||
- Admin endpoints not protected: `/admin/events/:slug/run-now`, `/admin/matching-runs`
|
- Admin endpoints not protected: `/admin/events/:slug/run-now`, `/admin/matching-runs`
|
||||||
- Need: `requireAdmin` middleware
|
- Need: `requireAdmin` middleware
|
||||||
|
|
||||||
3. **S14.3: Event Participant Validation** - **SECURITY**
|
2. **S14.3: Event Participant Validation** - **SECURITY**
|
||||||
- Inconsistent checks across endpoints
|
- Inconsistent checks across endpoints
|
||||||
- Need: Audit all suggestion/match endpoints for participant validation
|
- Need: Audit all suggestion/match endpoints for participant validation
|
||||||
|
|
||||||
#### ⚠️ High Priority (P1 - First Month)
|
#### ⚠️ High Priority (P1 - First Month)
|
||||||
|
|
||||||
4. **E9/S13.2: Manual match created AFTER auto suggestion**
|
3. **E9/S13.2: Manual match created AFTER auto suggestion**
|
||||||
- Current: Manual blocks only NEW auto suggestions, old pending remain
|
- Current: Manual blocks only NEW auto suggestions, old pending remain
|
||||||
- Need: Cleanup conflicting pending auto suggestions when manual match created
|
- Need: Cleanup conflicting pending auto suggestions when manual match created
|
||||||
|
|
||||||
5. **S15.1-15.2: Rate Limiting & Spam Protection**
|
4. **S15.1-15.2: Rate Limiting & Spam Protection**
|
||||||
- Max pending outgoing requests (20)
|
- Max pending outgoing requests (20)
|
||||||
- Rate limit manual match requests (10/minute)
|
- Rate limit manual match requests (10/minute)
|
||||||
|
|
||||||
6. **S16.1: Socket Notifications**
|
5. **S16.1: Socket Notifications**
|
||||||
- Real-time notification when new suggestion created
|
- Real-time notification when new suggestion created
|
||||||
|
|
||||||
#### 📋 Medium Priority (P2 - Q1 2025)
|
#### 📋 Medium Priority (P2 - Q1 2025)
|
||||||
|
|
||||||
7. **S11.3-11.4: Matching Run Details API**
|
6. **S11.3-11.4: Matching Run Details API**
|
||||||
- Endpoint: `GET /matching-runs/:id/suggestions`
|
- Endpoint: `GET /matching-runs/:id/suggestions`
|
||||||
- Filters: `onlyAssigned`, `includeNotFound`
|
- Filters: `onlyAssigned`, `includeNotFound`
|
||||||
|
|
||||||
8. **S15.3: Zombie Matches Cleanup**
|
7. **S15.3: Zombie Matches Cleanup**
|
||||||
- Auto-cancel pending matches older than 30 days
|
- Auto-cancel pending matches older than 30 days
|
||||||
|
|
||||||
9. **S16.3: Email Reminders**
|
8. **S16.3: Email Reminders**
|
||||||
- Reminder before event for accepted recording assignments
|
- Reminder before event for accepted recording assignments
|
||||||
|
|
||||||
### Test Scenarios by Category
|
### Test Scenarios by Category
|
||||||
@@ -249,38 +239,57 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>S10: RATINGS & STATS</b> 🔴 NOT IMPLEMENTED - CRITICAL!</summary>
|
<summary><b>S10: RATINGS & STATS</b> ✅ IMPLEMENTED (2025-11-30)</summary>
|
||||||
|
|
||||||
#### S10.1: Auto match completed → stats updated exactly once
|
#### S10.1: Auto match completed → stats updated exactly once ✅
|
||||||
- **Given:** Auto Match A↔B (suggestionId != null, statsApplied=false), both rated
|
- **Given:** Auto Match A↔B (source='auto', statsApplied=false), both rated
|
||||||
- **When:** Ratings endpoint called
|
- **When:** Second rating submitted
|
||||||
- **Then:**
|
- **Then:**
|
||||||
- `recordingsDone++` for recorder
|
- `recordingsDone++` for recorder (user2)
|
||||||
- `recordingsReceived++` for dancer
|
- `recordingsReceived++` for dancer (user1)
|
||||||
- `match.status = 'completed'`
|
- `match.status = 'completed'`
|
||||||
- `match.statsApplied = true`
|
- `match.statsApplied = true`
|
||||||
|
- **Implementation:** `backend/src/routes/matches.js:961-995` (atomic check-and-set)
|
||||||
|
- **Tests:** `backend/src/__tests__/ratings-stats-flow.test.js` (STEP 6b)
|
||||||
|
|
||||||
#### S10.2: Only one rated → no stats
|
#### S10.2: Only one rated → no stats ✅
|
||||||
- **Given:** Auto Match A↔B, only A rated
|
- **Given:** Auto Match A↔B, only A rated
|
||||||
- **Then:** `statsApplied` stays false, stats don't change
|
- **Then:** `statsApplied` stays false, stats don't change
|
||||||
|
- **Tests:** `backend/src/__tests__/ratings-stats-flow.test.js` (STEP 6a)
|
||||||
|
|
||||||
#### S10.3: Manual match completion → no stats update
|
#### S10.3: Manual match completion → no stats update ✅
|
||||||
- **Given:** Match A↔B (suggestionId=null), both rated
|
- **Given:** Match A↔B (source='manual'), both rated
|
||||||
- **Then:** Stats don't change (manual matches don't affect fairness)
|
- **Then:** Stats don't change (manual matches don't affect fairness)
|
||||||
|
- **Implementation:** `backend/src/services/matching.js:682` (early return if source !== 'auto')
|
||||||
|
- **Tests:** `backend/src/__tests__/ratings-stats-flow.test.js` (STEP 8)
|
||||||
|
|
||||||
#### S10.4: Rating edit → no double counting
|
#### S10.4: Rating edit → no double counting ✅
|
||||||
- **Given:** Auto Match A↔B has `statsApplied=true`, user edits rating
|
- **Given:** User tries to rate same match twice
|
||||||
- **Then:** Stats don't change (already applied)
|
- **Then:** 400 error "already rated", stats unchanged
|
||||||
|
- **Implementation:** Unique constraint: `(matchId, raterId, ratedId)`
|
||||||
|
- **Tests:** `backend/src/__tests__/ratings-stats-flow.test.js` (STEP 7)
|
||||||
|
|
||||||
**Implementation needed:**
|
**Implementation:**
|
||||||
```javascript
|
```javascript
|
||||||
// Match model
|
// Match model (Prisma schema)
|
||||||
|
source: String // 'auto' | 'manual'
|
||||||
statsApplied: Boolean @default(false)
|
statsApplied: Boolean @default(false)
|
||||||
|
suggestionId: Int? // null for manual matches
|
||||||
|
|
||||||
// After both ratings (pseudocode):
|
// Stats application (backend/src/services/matching.js:679-701)
|
||||||
if (bothRated && !match.statsApplied && match.suggestionId) {
|
async function applyRecordingStatsForMatch(match) {
|
||||||
// Increment stats
|
if (match.source !== 'auto') return; // Manual matches ignored
|
||||||
// Set statsApplied = true
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: match.user2Id }, // recorder
|
||||||
|
data: { recordingsDone: { increment: 1 } }
|
||||||
|
}),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: match.user1Id }, // dancer
|
||||||
|
data: { recordingsReceived: { increment: 1 } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user