diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..d941733 --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,5 @@ +/** + * Jest setup file - runs before all tests + * Loads environment variables from .env.development + */ +require('dotenv').config({ path: '.env.development' }); diff --git a/backend/package.json b/backend/package.json index 61c2e9d..31a7baa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ }, "jest": { "testEnvironment": "node", + "setupFilesAfterEnv": ["/jest.setup.js"], "coveragePathIgnorePatterns": [ "/node_modules/" ], diff --git a/backend/src/__tests__/ratings-stats-flow.test.js b/backend/src/__tests__/ratings-stats-flow.test.js new file mode 100644 index 0000000..a2e48fd --- /dev/null +++ b/backend/src/__tests__/ratings-stats-flow.test.js @@ -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 + }); +}); diff --git a/docs/TODO.md b/docs/TODO.md index 220d6ac..3b4ae23 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,17 +12,6 @@ ### 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** - **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) @@ -36,7 +25,13 @@ - **Status:** Test plan documented, implementation pending - **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 - Dual Buffer System (prep before + rest after dancing) - Clickable Usernames with @ prefix in profiles @@ -59,48 +54,43 @@ #### ✅ Implemented Scenarios - **S1-S3:** Basic flow, collision detection, limits (covered by existing tests) - **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) - **S14.1:** Only recorder can accept/reject (implemented in MVP) #### 🔴 Critical Gaps (P0 - Before Production) -1. **S10: Ratings & Stats System** - **CRITICAL** - - 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** +1. **S14.2: Admin Middleware** - **SECURITY** - Admin endpoints not protected: `/admin/events/:slug/run-now`, `/admin/matching-runs` - Need: `requireAdmin` middleware -3. **S14.3: Event Participant Validation** - **SECURITY** +2. **S14.3: Event Participant Validation** - **SECURITY** - Inconsistent checks across endpoints - Need: Audit all suggestion/match endpoints for participant validation #### ⚠️ 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 - 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) - Rate limit manual match requests (10/minute) -6. **S16.1: Socket Notifications** +5. **S16.1: Socket Notifications** - Real-time notification when new suggestion created #### 📋 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` - Filters: `onlyAssigned`, `includeNotFound` -8. **S15.3: Zombie Matches Cleanup** +7. **S15.3: Zombie Matches Cleanup** - 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 ### Test Scenarios by Category @@ -249,38 +239,57 @@
-S10: RATINGS & STATS 🔴 NOT IMPLEMENTED - CRITICAL! +S10: RATINGS & STATS ✅ IMPLEMENTED (2025-11-30) -#### S10.1: Auto match completed → stats updated exactly once -- **Given:** Auto Match A↔B (suggestionId != null, statsApplied=false), both rated -- **When:** Ratings endpoint called +#### S10.1: Auto match completed → stats updated exactly once ✅ +- **Given:** Auto Match A↔B (source='auto', statsApplied=false), both rated +- **When:** Second rating submitted - **Then:** - - `recordingsDone++` for recorder - - `recordingsReceived++` for dancer + - `recordingsDone++` for recorder (user2) + - `recordingsReceived++` for dancer (user1) - `match.status = 'completed'` - `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 - **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 -- **Given:** Match A↔B (suggestionId=null), both rated +#### S10.3: Manual match completion → no stats update ✅ +- **Given:** Match A↔B (source='manual'), both rated - **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 -- **Given:** Auto Match A↔B has `statsApplied=true`, user edits rating -- **Then:** Stats don't change (already applied) +#### S10.4: Rating edit → no double counting ✅ +- **Given:** User tries to rate same match twice +- **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 -// Match model +// Match model (Prisma schema) +source: String // 'auto' | 'manual' statsApplied: Boolean @default(false) +suggestionId: Int? // null for manual matches -// After both ratings (pseudocode): -if (bothRated && !match.statsApplied && match.suggestionId) { - // Increment stats - // Set statsApplied = true +// Stats application (backend/src/services/matching.js:679-701) +async function applyRecordingStatsForMatch(match) { + if (match.source !== 'auto') return; // Manual matches ignored + + 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 } } + }) + ]); } ```