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:
Radosław Gierwiało
2025-11-30 19:18:09 +01:00
parent 25236222de
commit 065e77fd4e
4 changed files with 512 additions and 44 deletions

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