feat: implement recording stats update mechanism for auto-matching

Add automatic tracking of recording statistics (recordingsDone/recordingsReceived)
for users participating in auto-matched collaborations. Stats are updated when
both users complete mutual ratings after a recording session.

Changes:
- Add suggestionId, source, and statsApplied fields to Match model
- Implement applyRecordingStatsForMatch() helper with user role convention
  (user1 = dancer, user2 = recorder)
- Update suggestion status endpoint to create Match on acceptance
- Update ratings endpoint to apply stats when match is completed
- Add comprehensive unit tests (5) and integration tests (5)

Convention: Stats only updated for auto-matches (source='auto') to ensure
fairness metrics reflect actual algorithmic assignments, not manual matches.

Test Results: 304/305 tests passing (99.7%)
Coverage: 74.53% (+1.48%)
This commit is contained in:
Radosław Gierwiało
2025-11-30 10:40:43 +01:00
parent 5ee1e0a4b9
commit 145c9f7ce6
6 changed files with 741 additions and 22 deletions

View File

@@ -0,0 +1,447 @@
/**
* Integration tests for Recording Stats Update Mechanism
*
* Tests the full flow:
* 1. Recording suggestion accepted → Match created
* 2. Both users rate → Match completed + stats updated
*/
const request = require('supertest');
const app = require('../app');
const { prisma } = require('../utils/db');
const { hashPassword, generateToken } = require('../utils/auth');
// Test data
let dancer, recorder;
let dancerToken, recorderToken;
let testEvent;
let testDivision, testCompetitionType;
let testHeat;
let testSuggestion;
// Setup test data
beforeAll(async () => {
// Clean up test data
const testUsernames = ['stats_dancer', 'stats_recorder'];
const testEmails = ['stats_dancer@example.com', 'stats_recorder@example.com'];
const testEventSlug = 'stats-test-event-2025';
// Find and delete existing test users
const existingUsers = await prisma.user.findMany({
where: {
OR: [
{ username: { in: testUsernames } },
{ email: { in: testEmails } }
]
},
select: { id: true }
});
const existingUserIds = existingUsers.map(u => u.id);
if (existingUserIds.length > 0) {
await prisma.eventUserHeat.deleteMany({ where: { userId: { in: existingUserIds } } });
await prisma.rating.deleteMany({ where: { raterId: { in: existingUserIds } } });
await prisma.match.deleteMany({
where: {
OR: [
{ user1Id: { in: existingUserIds } },
{ user2Id: { in: existingUserIds } }
]
}
});
await prisma.eventParticipant.deleteMany({ where: { userId: { in: existingUserIds } } });
await prisma.user.deleteMany({ where: { id: { in: existingUserIds } } });
}
// Delete test event
const existingEvent = await prisma.event.findUnique({
where: { slug: testEventSlug },
select: { id: true }
});
if (existingEvent) {
await prisma.recordingSuggestion.deleteMany({ where: { eventId: existingEvent.id } });
await prisma.eventUserHeat.deleteMany({ where: { eventId: existingEvent.id } });
await prisma.eventParticipant.deleteMany({ where: { eventId: existingEvent.id } });
await prisma.event.delete({ where: { id: existingEvent.id } });
}
// Create test users with initial stats = 0
const hashedPassword = await hashPassword('TestPass123!');
dancer = await prisma.user.create({
data: {
username: 'stats_dancer',
email: 'stats_dancer@example.com',
passwordHash: hashedPassword,
emailVerified: true,
recordingsDone: 0,
recordingsReceived: 0,
},
});
recorder = await prisma.user.create({
data: {
username: 'stats_recorder',
email: 'stats_recorder@example.com',
passwordHash: hashedPassword,
emailVerified: true,
recordingsDone: 0,
recordingsReceived: 0,
},
});
dancerToken = generateToken({ userId: dancer.id });
recorderToken = generateToken({ userId: recorder.id });
// Create test event
testEvent = await prisma.event.create({
data: {
slug: testEventSlug,
name: 'Stats Test Event 2025',
location: 'Test City',
startDate: new Date('2025-06-01'),
endDate: new Date('2025-06-03'),
},
});
// Add participants
await prisma.eventParticipant.createMany({
data: [
{ userId: dancer.id, eventId: testEvent.id, competitorNumber: 100 },
{ userId: recorder.id, eventId: testEvent.id },
],
});
// Get or create division and competition type
testDivision = await prisma.division.findFirst({
where: { abbreviation: 'INT' },
});
if (!testDivision) {
testDivision = await prisma.division.create({
data: {
name: 'Intermediate',
abbreviation: 'INT',
displayOrder: 3,
},
});
}
testCompetitionType = await prisma.competitionType.findFirst({
where: { abbreviation: 'J&J' }, // Use existing abbreviation
});
if (!testCompetitionType) {
testCompetitionType = await prisma.competitionType.create({
data: {
name: 'Jack & Jill',
abbreviation: 'J&J',
},
});
}
// Create heat for dancer
testHeat = await prisma.eventUserHeat.create({
data: {
userId: dancer.id,
eventId: testEvent.id,
divisionId: testDivision.id,
competitionTypeId: testCompetitionType.id,
heatNumber: 1,
role: 'Leader',
},
});
// Create recording suggestion
testSuggestion = await prisma.recordingSuggestion.create({
data: {
eventId: testEvent.id,
heatId: testHeat.id,
recorderId: recorder.id,
status: 'pending',
},
});
});
afterAll(async () => {
// Cleanup
if (dancer && recorder) {
const userIds = [dancer.id, recorder.id];
await prisma.eventUserHeat.deleteMany({ where: { userId: { in: userIds } } });
await prisma.rating.deleteMany({ where: { raterId: { in: userIds } } });
await prisma.match.deleteMany({
where: {
OR: [
{ user1Id: { in: userIds } },
{ user2Id: { in: userIds } }
]
}
});
await prisma.eventParticipant.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
}
if (testEvent) {
await prisma.recordingSuggestion.deleteMany({ where: { eventId: testEvent.id } });
await prisma.eventUserHeat.deleteMany({ where: { eventId: testEvent.id } });
await prisma.eventParticipant.deleteMany({ where: { eventId: testEvent.id } });
await prisma.event.delete({ where: { id: testEvent.id } });
}
await prisma.$disconnect();
});
describe('Recording Stats Integration Tests', () => {
describe('Full Flow: Suggestion → Match → Ratings → Stats', () => {
let createdMatch;
it('should create Match with correct fields when suggestion is accepted', async () => {
// Recorder accepts the suggestion
const response = await request(app)
.put(`/api/events/${testEvent.slug}/match-suggestions/${testSuggestion.id}/status`)
.set('Authorization', `Bearer ${recorderToken}`)
.send({ status: 'accepted' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.status).toBe('accepted');
expect(response.body.data.matchId).toBeDefined();
expect(response.body.data.matchSlug).toBeDefined();
// Verify Match was created with correct fields
const match = await prisma.match.findUnique({
where: { id: response.body.data.matchId },
include: { room: true },
});
expect(match).toBeDefined();
expect(match.user1Id).toBe(dancer.id); // Convention: user1 = dancer
expect(match.user2Id).toBe(recorder.id); // Convention: user2 = recorder
expect(match.eventId).toBe(testEvent.id);
expect(match.suggestionId).toBe(testSuggestion.id);
expect(match.source).toBe('auto');
expect(match.status).toBe('accepted');
expect(match.statsApplied).toBe(false);
expect(match.roomId).toBeDefined(); // Chat room created
expect(match.room).toBeDefined();
expect(match.room.type).toBe('private');
createdMatch = match;
});
it('should be idempotent - accepting again should not create duplicate Match', async () => {
// Update suggestion back to pending
await prisma.recordingSuggestion.update({
where: { id: testSuggestion.id },
data: { status: 'pending' },
});
// Accept again
const response = await request(app)
.put(`/api/events/${testEvent.slug}/match-suggestions/${testSuggestion.id}/status`)
.set('Authorization', `Bearer ${recorderToken}`)
.send({ status: 'accepted' });
expect(response.status).toBe(200);
expect(response.body.data.matchId).toBe(createdMatch.id); // Same match
// Verify only one match exists
const matchCount = await prisma.match.count({
where: { suggestionId: testSuggestion.id },
});
expect(matchCount).toBe(1);
});
it('should NOT update stats after only one rating', async () => {
// Dancer rates recorder
const response = await request(app)
.post(`/api/matches/${createdMatch.slug}/ratings`)
.set('Authorization', `Bearer ${dancerToken}`)
.send({
score: 5,
comment: 'Great recorder!',
wouldCollaborateAgain: true,
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
// Verify match is still 'accepted' (not completed)
const match = await prisma.match.findUnique({
where: { id: createdMatch.id },
});
expect(match.status).toBe('accepted');
expect(match.statsApplied).toBe(false);
// Verify stats NOT updated yet
const dancerStats = await prisma.user.findUnique({
where: { id: dancer.id },
select: { recordingsDone: true, recordingsReceived: true },
});
const recorderStats = await prisma.user.findUnique({
where: { id: recorder.id },
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);
});
it('should update stats and mark match as completed after both users rate', async () => {
// Recorder rates dancer
const response = await request(app)
.post(`/api/matches/${createdMatch.slug}/ratings`)
.set('Authorization', `Bearer ${recorderToken}`)
.send({
score: 5,
comment: 'Great dancer!',
wouldCollaborateAgain: true,
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
// Verify match is now 'completed' with statsApplied
const match = await prisma.match.findUnique({
where: { id: createdMatch.id },
});
expect(match.status).toBe('completed');
expect(match.statsApplied).toBe(true);
// Verify stats were updated correctly
const dancerStats = await prisma.user.findUnique({
where: { id: dancer.id },
select: { recordingsDone: true, recordingsReceived: true },
});
const recorderStats = await prisma.user.findUnique({
where: { id: recorder.id },
select: { recordingsDone: true, recordingsReceived: true },
});
// Dancer: received +1 (was recorded)
expect(dancerStats.recordingsDone).toBe(0);
expect(dancerStats.recordingsReceived).toBe(1);
// Recorder: done +1 (did the recording)
expect(recorderStats.recordingsDone).toBe(1);
expect(recorderStats.recordingsReceived).toBe(0);
});
});
describe('Manual Match - Stats NOT Updated', () => {
let manualMatch;
let user1, user2;
let user1Token, user2Token;
beforeAll(async () => {
// Create two users for manual match
const hashedPassword = await hashPassword('TestPass123!');
user1 = await prisma.user.create({
data: {
username: 'stats_manual_user1',
email: 'stats_manual1@example.com',
passwordHash: hashedPassword,
emailVerified: true,
recordingsDone: 0,
recordingsReceived: 0,
},
});
user2 = await prisma.user.create({
data: {
username: 'stats_manual_user2',
email: 'stats_manual2@example.com',
passwordHash: hashedPassword,
emailVerified: true,
recordingsDone: 0,
recordingsReceived: 0,
},
});
user1Token = generateToken({ userId: user1.id });
user2Token = generateToken({ userId: user2.id });
// Add participants to event
await prisma.eventParticipant.createMany({
data: [
{ userId: user1.id, eventId: testEvent.id },
{ userId: user2.id, eventId: testEvent.id },
],
});
// Create manual match
const response = await request(app)
.post('/api/matches')
.set('Authorization', `Bearer ${user1Token}`)
.send({
targetUserId: user2.id,
eventSlug: testEvent.slug,
});
expect(response.status).toBe(201);
manualMatch = response.body.data;
});
afterAll(async () => {
if (user1 && user2) {
const userIds = [user1.id, user2.id];
await prisma.rating.deleteMany({ where: { raterId: { in: userIds } } });
await prisma.match.deleteMany({
where: {
OR: [
{ user1Id: { in: userIds } },
{ user2Id: { in: userIds } }
]
}
});
await prisma.eventParticipant.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
}
});
it('should NOT update stats for manual match after ratings', async () => {
// Accept match
await request(app)
.put(`/api/matches/${manualMatch.slug}/accept`)
.set('Authorization', `Bearer ${user2Token}`);
// Both users rate each other
await request(app)
.post(`/api/matches/${manualMatch.slug}/ratings`)
.set('Authorization', `Bearer ${user1Token}`)
.send({ score: 5 });
await request(app)
.post(`/api/matches/${manualMatch.slug}/ratings`)
.set('Authorization', `Bearer ${user2Token}`)
.send({ score: 5 });
// Verify match is completed
const match = await prisma.match.findUnique({
where: { slug: manualMatch.slug },
});
expect(match.status).toBe('completed');
expect(match.source).toBe('manual');
expect(match.statsApplied).toBe(true); // Flag set, but stats not updated
// Verify stats were NOT updated (source='manual')
const user1Stats = await prisma.user.findUnique({
where: { id: user1.id },
select: { recordingsDone: true, recordingsReceived: true },
});
const user2Stats = await prisma.user.findUnique({
where: { id: user2.id },
select: { recordingsDone: true, recordingsReceived: true },
});
expect(user1Stats.recordingsDone).toBe(0);
expect(user1Stats.recordingsReceived).toBe(0);
expect(user2Stats.recordingsDone).toBe(0);
expect(user2Stats.recordingsReceived).toBe(0);
});
});
});