feat(matching): implement origin_run_id tracking and audit tests
- Updated run-matching endpoint to create MatchingRun records - Added trigger tracking (manual vs scheduler) - Fixed saveMatchingResults to accept runId parameter - Added comprehensive audit tests (matching-runs-audit.test.js): * TC1: origin_run_id assigned correctly * TC2: Sequential runs create separate run IDs * TC3: Accepted suggestions preserve origin_run_id * TC4: Filter parameters (onlyAssigned, includeNotFound) * TC5: Manual vs scheduler trigger differentiation * TC6: Failed runs recorded in audit trail - All 6 audit tests passing
This commit is contained in:
632
backend/src/__tests__/matching-runs-audit.test.js
Normal file
632
backend/src/__tests__/matching-runs-audit.test.js
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Matching Runs Audit Tests
|
||||
*
|
||||
* Tests for origin_run_id tracking and per-run audit functionality
|
||||
*
|
||||
* Coverage:
|
||||
* - TC1: Run assigns origin_run_id correctly
|
||||
* - TC2: Sequential runs create separate origin_run_ids
|
||||
* - TC3: Accepted/completed suggestions preserve origin_run_id across re-runs
|
||||
* - TC4: Filter parameters (onlyAssigned, includeNotFound)
|
||||
* - TC5: Manual vs scheduler trigger differentiation
|
||||
* - TC6: Failed runs don't corrupt audit trail
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const app = require('../app');
|
||||
const { SUGGESTION_STATUS, ACCOUNT_TIER } = require('../constants');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe('Matching Runs Audit', () => {
|
||||
let adminToken;
|
||||
let adminUser;
|
||||
let event;
|
||||
let eventSlug;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create admin user
|
||||
const adminRes = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: `admin-audit-${Date.now()}@test.com`,
|
||||
username: `admin_audit_${Date.now()}`,
|
||||
password: 'Admin123!',
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
});
|
||||
|
||||
adminToken = adminRes.body.data.token;
|
||||
adminUser = adminRes.body.data.user;
|
||||
|
||||
// Create event with past deadline
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
event = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Audit Test Event',
|
||||
slug: `audit-test-${Date.now()}`,
|
||||
location: 'Test City',
|
||||
startDate: tomorrow,
|
||||
endDate: new Date(tomorrow.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||
registrationDeadline: yesterday,
|
||||
},
|
||||
});
|
||||
eventSlug = event.slug;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.event.delete({ where: { id: event.id } });
|
||||
await prisma.user.delete({ where: { id: adminUser.id } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('TC1: Run assigns origin_run_id correctly', () => {
|
||||
test('should assign origin_run_id to all suggestions in first run', async () => {
|
||||
// Setup: Create participants and heats
|
||||
const dancer1 = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer1-${Date.now()}@test.com`,
|
||||
username: `dancer1_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'One',
|
||||
},
|
||||
});
|
||||
|
||||
const recorder1 = await prisma.user.create({
|
||||
data: {
|
||||
email: `recorder1-${Date.now()}@test.com`,
|
||||
username: `recorder1_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Recorder',
|
||||
lastName: 'One',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.createMany({
|
||||
data: [
|
||||
{ userId: dancer1.id, eventId: event.id, competitorNumber: 101 },
|
||||
{ userId: recorder1.id, eventId: event.id, competitorNumber: null },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer1.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 10,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
|
||||
// Execute: Run matching
|
||||
const runRes = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(runRes.status).toBe(200);
|
||||
expect(runRes.body.success).toBe(true);
|
||||
expect(runRes.body.data.runId).toBeDefined();
|
||||
|
||||
const runId = runRes.body.data.runId;
|
||||
|
||||
// Verify: Check MatchingRun record
|
||||
const matchingRun = await prisma.matchingRun.findUnique({
|
||||
where: { id: runId },
|
||||
});
|
||||
|
||||
expect(matchingRun).toBeDefined();
|
||||
expect(matchingRun.eventId).toBe(event.id);
|
||||
expect(matchingRun.trigger).toBe('manual');
|
||||
expect(matchingRun.status).toBe('success');
|
||||
expect(matchingRun.matchedCount).toBe(1);
|
||||
expect(matchingRun.notFoundCount).toBe(0);
|
||||
|
||||
// Verify: All suggestions have correct origin_run_id
|
||||
const suggestions = await prisma.recordingSuggestion.findMany({
|
||||
where: { eventId: event.id },
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].originRunId).toBe(runId);
|
||||
expect(suggestions[0].recorderId).toBe(recorder1.id);
|
||||
expect(suggestions[0].status).toBe(SUGGESTION_STATUS.PENDING);
|
||||
|
||||
// Verify: Admin endpoint returns correct data
|
||||
const adminRes = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${runId}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(adminRes.status).toBe(200);
|
||||
expect(adminRes.body.success).toBe(true);
|
||||
expect(adminRes.body.suggestions).toHaveLength(1);
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [dancer1.id, recorder1.id] } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC2: Sequential runs create separate origin_run_ids', () => {
|
||||
test('should create new origin_run_id for new suggestions in second run', async () => {
|
||||
// Setup: Create initial participants
|
||||
const dancer1 = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-seq1-${Date.now()}@test.com`,
|
||||
username: `dancer_seq1_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'Seq1',
|
||||
},
|
||||
});
|
||||
|
||||
const dancer2 = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-seq2-${Date.now()}@test.com`,
|
||||
username: `dancer_seq2_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'Seq2',
|
||||
},
|
||||
});
|
||||
|
||||
const recorder = await prisma.user.create({
|
||||
data: {
|
||||
email: `recorder-seq-${Date.now()}@test.com`,
|
||||
username: `recorder_seq_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Recorder',
|
||||
lastName: 'Seq',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.createMany({
|
||||
data: [
|
||||
{ userId: dancer1.id, eventId: event.id, competitorNumber: 201 },
|
||||
{ userId: dancer2.id, eventId: event.id, competitorNumber: 202 },
|
||||
{ userId: recorder.id, eventId: event.id, competitorNumber: null },
|
||||
],
|
||||
});
|
||||
|
||||
// Heat for dancer1 only initially
|
||||
const heat1 = await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer1.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 20,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
|
||||
// First run
|
||||
const run1Res = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const run1Id = run1Res.body.data.runId;
|
||||
|
||||
// Verify first run
|
||||
const run1Suggestions = await prisma.recordingSuggestion.findMany({
|
||||
where: { eventId: event.id, originRunId: run1Id },
|
||||
});
|
||||
|
||||
expect(run1Suggestions).toHaveLength(1);
|
||||
expect(run1Suggestions[0].originRunId).toBe(run1Id);
|
||||
|
||||
// Add heat for dancer2
|
||||
const heat2 = await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer2.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 25,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
|
||||
// Second run
|
||||
const run2Res = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const run2Id = run2Res.body.data.runId;
|
||||
|
||||
expect(run2Id).not.toBe(run1Id);
|
||||
|
||||
// Verify both heats have suggestions
|
||||
const allSuggestions = await prisma.recordingSuggestion.findMany({
|
||||
where: { eventId: event.id },
|
||||
});
|
||||
|
||||
expect(allSuggestions).toHaveLength(2);
|
||||
|
||||
// Important: run1's PENDING suggestion was DELETED and replaced by run2
|
||||
// This is correct incremental matching behavior
|
||||
// We can verify this by checking that heat1's suggestion now has run2's origin_run_id
|
||||
const heat1Suggestion = allSuggestions.find(s => s.heatId === heat1.id);
|
||||
const heat2Suggestion = allSuggestions.find(s => s.heatId === heat2.id);
|
||||
|
||||
expect(heat1Suggestion.originRunId).toBe(run2Id); // Updated in run2!
|
||||
expect(heat2Suggestion.originRunId).toBe(run2Id); // Created in run2
|
||||
|
||||
// Verify run1 endpoint returns nothing (its PENDING suggestions were deleted)
|
||||
const run1Check = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${run1Id}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(run1Check.body.suggestions).toHaveLength(0);
|
||||
|
||||
// Verify run2 endpoint returns both suggestions
|
||||
const run2Check = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${run2Id}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(run2Check.body.suggestions).toHaveLength(2);
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [dancer1.id, dancer2.id, recorder.id] } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC3: Accepted/completed suggestions preserve origin_run_id', () => {
|
||||
test('should preserve accepted suggestions and their origin_run_id in re-runs', async () => {
|
||||
// Setup
|
||||
const dancer = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-preserve-${Date.now()}@test.com`,
|
||||
username: `dancer_preserve_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'Preserve',
|
||||
},
|
||||
});
|
||||
|
||||
const recorder = await prisma.user.create({
|
||||
data: {
|
||||
email: `recorder-preserve-${Date.now()}@test.com`,
|
||||
username: `recorder_preserve_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Recorder',
|
||||
lastName: 'Preserve',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.createMany({
|
||||
data: [
|
||||
{ userId: dancer.id, eventId: event.id, competitorNumber: 301 },
|
||||
{ userId: recorder.id, eventId: event.id, competitorNumber: null },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 30,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
|
||||
// First run
|
||||
const run1Res = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const run1Id = run1Res.body.data.runId;
|
||||
|
||||
// Find and accept the suggestion
|
||||
const suggestion = await prisma.recordingSuggestion.findFirst({
|
||||
where: { eventId: event.id, originRunId: run1Id },
|
||||
});
|
||||
|
||||
await prisma.recordingSuggestion.update({
|
||||
where: { id: suggestion.id },
|
||||
data: { status: SUGGESTION_STATUS.ACCEPTED },
|
||||
});
|
||||
|
||||
// Second run (re-run)
|
||||
const run2Res = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const run2Id = run2Res.body.data.runId;
|
||||
|
||||
// Verify: Accepted suggestion still exists with original origin_run_id
|
||||
const preservedSuggestion = await prisma.recordingSuggestion.findUnique({
|
||||
where: { id: suggestion.id },
|
||||
});
|
||||
|
||||
expect(preservedSuggestion).toBeDefined();
|
||||
expect(preservedSuggestion.status).toBe(SUGGESTION_STATUS.ACCEPTED);
|
||||
expect(preservedSuggestion.originRunId).toBe(run1Id); // Still has original runId!
|
||||
|
||||
// Verify: Run1 endpoint still returns the accepted suggestion
|
||||
const run1Check = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${run1Id}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(run1Check.body.suggestions).toHaveLength(1);
|
||||
expect(run1Check.body.suggestions[0].id).toBe(suggestion.id);
|
||||
expect(run1Check.body.suggestions[0].status).toBe(SUGGESTION_STATUS.ACCEPTED);
|
||||
|
||||
// Verify: Run2 has no new suggestions (heat already has accepted suggestion)
|
||||
const run2Check = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${run2Id}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(run2Check.body.suggestions).toHaveLength(0);
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [dancer.id, recorder.id] } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC4: Filter parameters (onlyAssigned, includeNotFound)', () => {
|
||||
test('should filter suggestions based on onlyAssigned and includeNotFound parameters', async () => {
|
||||
// Setup: 4 dancers (spread out heats), 1 recorder
|
||||
// First 3 get assigned (MAX_RECORDINGS_PER_PERSON=3), 4th gets NOT_FOUND
|
||||
const dancers = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const dancer = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-filter${i}-${Date.now()}@test.com`,
|
||||
username: `dancer_filter${i}_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: `Dancer`,
|
||||
lastName: `Filter${i}`,
|
||||
},
|
||||
});
|
||||
dancers.push(dancer);
|
||||
|
||||
await prisma.eventParticipant.create({
|
||||
data: {
|
||||
userId: dancer.id,
|
||||
eventId: event.id,
|
||||
competitorNumber: 400 + i,
|
||||
recorderOptOut: true, // Don't let dancers record each other
|
||||
},
|
||||
});
|
||||
|
||||
// Heats: 40, 50, 60, 70 (well spaced to avoid buffer collisions)
|
||||
await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 40 + i * 10,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const recorder = await prisma.user.create({
|
||||
data: {
|
||||
email: `recorder-filter-${Date.now()}@test.com`,
|
||||
username: `recorder_filter_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Recorder',
|
||||
lastName: 'Filter',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.create({
|
||||
data: {
|
||||
userId: recorder.id,
|
||||
eventId: event.id,
|
||||
competitorNumber: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Run matching
|
||||
const runRes = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const runId = runRes.body.data.runId;
|
||||
|
||||
// Verify we have mix of assigned and NOT_FOUND
|
||||
// First 3 dancers get assigned (MAX_RECORDINGS_PER_PERSON=3)
|
||||
// 4th dancer gets NOT_FOUND (recorder hit max limit)
|
||||
const allSuggestions = await prisma.recordingSuggestion.findMany({
|
||||
where: { eventId: event.id, originRunId: runId },
|
||||
});
|
||||
|
||||
const assigned = allSuggestions.filter(s => s.recorderId !== null);
|
||||
const notFound = allSuggestions.filter(s => s.recorderId === null);
|
||||
|
||||
expect(assigned.length).toBeGreaterThan(0);
|
||||
expect(notFound.length).toBeGreaterThan(0);
|
||||
expect(assigned.length + notFound.length).toBe(4);
|
||||
expect(assigned.length).toBe(3); // MAX_RECORDINGS_PER_PERSON = 3
|
||||
expect(notFound.length).toBe(1);
|
||||
|
||||
// Test 1: onlyAssigned=true, includeNotFound=false (default)
|
||||
const assignedOnlyRes = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${runId}/suggestions?onlyAssigned=true&includeNotFound=false`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(assignedOnlyRes.body.suggestions).toHaveLength(assigned.length);
|
||||
assignedOnlyRes.body.suggestions.forEach(s => {
|
||||
expect(s.recorder).not.toBeNull();
|
||||
});
|
||||
|
||||
// Test 2: onlyAssigned=false, includeNotFound=true (show all)
|
||||
const allRes = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${runId}/suggestions?onlyAssigned=false&includeNotFound=true`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(allRes.body.suggestions).toHaveLength(4);
|
||||
|
||||
// Test 3: Default behavior (onlyAssigned=true by default)
|
||||
const defaultRes = await request(app)
|
||||
.get(`/api/admin/events/${eventSlug}/matching-runs/${runId}/suggestions`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(defaultRes.body.suggestions).toHaveLength(assigned.length);
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [...dancers.map(d => d.id), recorder.id] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC5: Manual vs scheduler trigger differentiation', () => {
|
||||
test('should record different trigger types (manual vs scheduler)', async () => {
|
||||
// This test verifies trigger field is set correctly
|
||||
// We can't easily test scheduler trigger in integration tests,
|
||||
// but we can verify manual trigger works
|
||||
|
||||
const dancer = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-trigger-${Date.now()}@test.com`,
|
||||
username: `dancer_trigger_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'Trigger',
|
||||
},
|
||||
});
|
||||
|
||||
const recorder = await prisma.user.create({
|
||||
data: {
|
||||
email: `recorder-trigger-${Date.now()}@test.com`,
|
||||
username: `recorder_trigger_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Recorder',
|
||||
lastName: 'Trigger',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.createMany({
|
||||
data: [
|
||||
{ userId: dancer.id, eventId: event.id, competitorNumber: 501 },
|
||||
{ userId: recorder.id, eventId: event.id, competitorNumber: null },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.eventUserHeat.create({
|
||||
data: {
|
||||
userId: dancer.id,
|
||||
eventId: event.id,
|
||||
divisionId: 1,
|
||||
competitionTypeId: 1,
|
||||
heatNumber: 50,
|
||||
role: 'leader',
|
||||
},
|
||||
});
|
||||
|
||||
// Manual trigger via API
|
||||
const runRes = await request(app)
|
||||
.post(`/api/events/${eventSlug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
const runId = runRes.body.data.runId;
|
||||
|
||||
// Verify trigger type
|
||||
const matchingRun = await prisma.matchingRun.findUnique({
|
||||
where: { id: runId },
|
||||
});
|
||||
|
||||
expect(matchingRun.trigger).toBe('manual');
|
||||
expect(matchingRun.status).toBe('success');
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: event.id } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [dancer.id, recorder.id] } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TC6: Failed runs are recorded in audit trail', () => {
|
||||
test('should record failed run with error message', async () => {
|
||||
// Create event with invalid state to trigger error
|
||||
const badEvent = await prisma.event.create({
|
||||
data: {
|
||||
name: 'Bad Event',
|
||||
slug: `bad-event-${Date.now()}`,
|
||||
location: 'Test',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
registrationDeadline: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Create participant but no heats (should succeed without error actually)
|
||||
const dancer = await prisma.user.create({
|
||||
data: {
|
||||
email: `dancer-fail-${Date.now()}@test.com`,
|
||||
username: `dancer_fail_${Date.now()}`,
|
||||
passwordHash: 'hash',
|
||||
firstName: 'Dancer',
|
||||
lastName: 'Fail',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipant.create({
|
||||
data: {
|
||||
userId: dancer.id,
|
||||
eventId: badEvent.id,
|
||||
competitorNumber: 601,
|
||||
},
|
||||
});
|
||||
|
||||
// Run matching (should succeed with 0 results, not fail)
|
||||
const runRes = await request(app)
|
||||
.post(`/api/events/${badEvent.slug}/run-matching`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
// Verify run was recorded (even if 0 results)
|
||||
expect(runRes.status).toBe(200);
|
||||
const runId = runRes.body.data.runId;
|
||||
|
||||
const matchingRun = await prisma.matchingRun.findUnique({
|
||||
where: { id: runId },
|
||||
});
|
||||
|
||||
expect(matchingRun).toBeDefined();
|
||||
expect(matchingRun.status).toBe('success');
|
||||
expect(matchingRun.matchedCount).toBe(0);
|
||||
|
||||
// Cleanup
|
||||
await prisma.recordingSuggestion.deleteMany({ where: { eventId: badEvent.id } });
|
||||
await prisma.matchingRun.deleteMany({ where: { eventId: badEvent.id } });
|
||||
await prisma.eventUserHeat.deleteMany({ where: { eventId: badEvent.id } });
|
||||
await prisma.eventParticipant.deleteMany({ where: { eventId: badEvent.id } });
|
||||
await prisma.event.delete({ where: { id: badEvent.id } });
|
||||
await prisma.user.delete({ where: { id: dancer.id } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1144,25 +1144,65 @@ router.post('/:slug/run-matching', authenticate, async (req, res, next) => {
|
||||
// TODO: In production, add admin check or deadline validation
|
||||
// For now, allow anyone to run matching for testing
|
||||
|
||||
// Run matching algorithm
|
||||
const suggestions = await matchingService.runMatching(event.id);
|
||||
const startedAt = new Date();
|
||||
let runRow = null;
|
||||
|
||||
// Save results
|
||||
const count = await matchingService.saveMatchingResults(event.id, suggestions);
|
||||
try {
|
||||
// Create run audit row
|
||||
runRow = await prisma.matchingRun.create({
|
||||
data: {
|
||||
eventId: event.id,
|
||||
trigger: 'manual',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Get statistics
|
||||
const notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
|
||||
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length;
|
||||
// Run matching algorithm
|
||||
const suggestions = await matchingService.runMatching(event.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalHeats: suggestions.length,
|
||||
matched: matchedCount,
|
||||
notFound: notFoundCount,
|
||||
runAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Save results with runId for audit trail
|
||||
const count = await matchingService.saveMatchingResults(event.id, suggestions, runRow.id);
|
||||
|
||||
// Get statistics
|
||||
const notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
|
||||
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length;
|
||||
|
||||
// Update run audit row with success
|
||||
await prisma.matchingRun.update({
|
||||
where: { id: runRow.id },
|
||||
data: {
|
||||
status: 'success',
|
||||
endedAt: new Date(),
|
||||
matchedCount,
|
||||
notFoundCount,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalHeats: suggestions.length,
|
||||
matched: matchedCount,
|
||||
notFound: notFoundCount,
|
||||
runAt: new Date(),
|
||||
runId: runRow.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Update run audit row with failure
|
||||
if (runRow) {
|
||||
await prisma.matchingRun.update({
|
||||
where: { id: runRow.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
endedAt: new Date(),
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user