Fix all 45 audited bugs from bug-fixes.md
Verified every finding against the code, then fixed correctness (redirects, advancement reset, duplicate heats, high-jump attempt entry, placement ranking with tie handling, delete-dependency 500s), security (secrets out of config, config-driven admin seed, login lockout, role authorization with school scoping, heat-time IDOR, forwarded headers, last-admin guards), schema integrity (nullable+filtered ExistingStudentId, unique indexes for rounds/heats/bar heights via SchemaIntegrityFixes migration), performance (N+1 removal in high jump/reports/standings/dashboard, SQL-side student paging), and hygiene (duplicate notifications, auto-dismiss scope, local bootstrap-icons, orphaned files, test-data.sql tournament creation). FluentValidation is now registered; AutoMapper bumped to 14.0.0 (advisory fully patched only in licence-changed 15.1.1 — documented). 11 new tests; 63/63 passing. Credential rotation and deploy-time DB_CONNECTION_STRING are required manual follow-ups, documented in bug-fixes.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,102 @@ public class RegistrationEligibilityTests
|
||||
Assert.Contains("already registered", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_InactiveStudent_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
var student = CreateStudent(Sex.Male);
|
||||
student.IsActive = false;
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("deactivated", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_InactiveSchool_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
var student = CreateStudent(Sex.Male);
|
||||
var school = CreateSchool(SchoolLevel.Secondary);
|
||||
school.IsActive = false;
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
SetupSchool(school);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("school is deactivated", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_CompletedTournament_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
tel.Tournament.Status = TournamentStatus.Completed;
|
||||
var student = CreateStudent(Sex.Male);
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("completed", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_ArchivedTournament_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
tel.Tournament.IsArchived = true;
|
||||
var student = CreateStudent(Sex.Male);
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("archived", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterStudent_RelayOnlyRegistration_ThrowsInsteadOfCrashing()
|
||||
{
|
||||
var dto = new Application.DTOs.EventRegistrationCreateDto
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
StudentId = null,
|
||||
RelayTeamId = 5
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _service.RegisterStudentAsync(dto, "tester"));
|
||||
Assert.Contains("relay", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterStudent_NeitherStudentNorRelay_Throws()
|
||||
{
|
||||
var dto = new Application.DTOs.EventRegistrationCreateDto
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
StudentId = null,
|
||||
RelayTeamId = null
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _service.RegisterStudentAsync(dto, "tester"));
|
||||
Assert.Contains("student", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_TelNotFound_ReturnsFalse()
|
||||
{
|
||||
|
||||
145
tests/SportsDivision.Application.Tests/ScoringPlacementTests.cs
Normal file
145
tests/SportsDivision.Application.Tests/ScoringPlacementTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Moq;
|
||||
using SportsDivision.Application.Services;
|
||||
using SportsDivision.Domain.Entities;
|
||||
using SportsDivision.Domain.Enums;
|
||||
using SportsDivision.Domain.Interfaces;
|
||||
|
||||
namespace SportsDivision.Application.Tests;
|
||||
|
||||
public class ScoringPlacementTests
|
||||
{
|
||||
private readonly Mock<IUnitOfWork> _mockUow;
|
||||
private readonly ScoringService _service;
|
||||
|
||||
public ScoringPlacementTests()
|
||||
{
|
||||
_mockUow = new Mock<IUnitOfWork>();
|
||||
_service = new ScoringService(_mockUow.Object, null!);
|
||||
}
|
||||
|
||||
private void Setup(EventCategory category, List<Score> scores)
|
||||
{
|
||||
_mockUow.Setup(u => u.TournamentEventLevels.GetWithRegistrationsAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(new TournamentEventLevel
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
Event = new Event { Category = category }
|
||||
});
|
||||
_mockUow.Setup(u => u.Scores.GetByTournamentEventLevelAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(scores);
|
||||
_mockUow.Setup(u => u.PlacementPointConfigs.GetAllAsync())
|
||||
.ReturnsAsync(new List<PlacementPointConfig>
|
||||
{
|
||||
new() { Placement = 1, Points = 10 },
|
||||
new() { Placement = 2, Points = 8 },
|
||||
new() { Placement = 3, Points = 6 },
|
||||
new() { Placement = 4, Points = 5 },
|
||||
});
|
||||
_mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
|
||||
}
|
||||
|
||||
private static Score MakeScore(int regId, decimal performance) =>
|
||||
new() { EventRegistrationId = regId, RawPerformance = performance };
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_FieldEvent_RanksByPerformanceDescending()
|
||||
{
|
||||
// Points-based ranking used to assign arbitrary places when no scoring
|
||||
// constant existed (all CalculatedPoints = 0); ranking is now on the mark.
|
||||
var s1 = MakeScore(1, 10.00m);
|
||||
var s2 = MakeScore(2, 12.50m);
|
||||
var s3 = MakeScore(3, 11.00m);
|
||||
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(2, s3.Placement);
|
||||
Assert.Equal(3, s1.Placement);
|
||||
Assert.Equal(10, s2.PlacementPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_TrackEvent_RanksAscending()
|
||||
{
|
||||
var s1 = MakeScore(1, 11.20m);
|
||||
var s2 = MakeScore(2, 10.90m);
|
||||
Setup(EventCategory.Track, new List<Score> { s1, s2 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(2, s1.Placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_Ties_SharePlaceAndSplitPooledPoints()
|
||||
{
|
||||
// Two identical throws share 1st (competition ranking 1, 1, 3) and split
|
||||
// the pooled points for places 1 and 2: (10 + 8) / 2 = 9.
|
||||
var s1 = MakeScore(1, 12.50m);
|
||||
var s2 = MakeScore(2, 12.50m);
|
||||
var s3 = MakeScore(3, 11.00m);
|
||||
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s1.Placement);
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(9, s1.PlacementPoints);
|
||||
Assert.Equal(9, s2.PlacementPoints);
|
||||
Assert.Equal(3, s3.Placement);
|
||||
Assert.Equal(6, s3.PlacementPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_ZeroMark_GetsNoPlacementAndOldPlacementCleared()
|
||||
{
|
||||
var foul = MakeScore(1, 0m);
|
||||
foul.Placement = 1; // stale from an earlier calculation
|
||||
foul.PlacementPoints = 10;
|
||||
var valid = MakeScore(2, 9.50m);
|
||||
Setup(EventCategory.Field, new List<Score> { foul, valid });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Null(foul.Placement);
|
||||
Assert.Equal(0, foul.PlacementPoints);
|
||||
Assert.Equal(1, valid.Placement);
|
||||
}
|
||||
}
|
||||
|
||||
public class HeatAdvancementResetTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CalculateAdvancement_ClearsStaleFlagsBeforeRecalculating()
|
||||
{
|
||||
// An athlete who advanced in a previous calculation but no longer qualifies
|
||||
// after a time correction must lose the flag on recalculation.
|
||||
var stale = new HeatLane { HeatLaneId = 1, Time = 12.5m, IsAdvanced = true, AdvanceReason = AdvanceReason.TopN };
|
||||
var faster = new HeatLane { HeatLaneId = 2, Time = 11.0m };
|
||||
var round = new Round
|
||||
{
|
||||
RoundId = 1,
|
||||
AdvanceTopN = 1,
|
||||
AdvanceFastestLosers = 0,
|
||||
Heats = new List<Heat>
|
||||
{
|
||||
new() { HeatId = 1, HeatLanes = new List<HeatLane> { stale, faster } }
|
||||
}
|
||||
};
|
||||
|
||||
var mockUow = new Mock<IUnitOfWork>();
|
||||
mockUow.Setup(u => u.Rounds.GetWithHeatsAsync(1)).ReturnsAsync(round);
|
||||
mockUow.Setup(u => u.HeatLanes.Update(It.IsAny<HeatLane>()));
|
||||
mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
|
||||
var service = new HeatManagementService(mockUow.Object, null!);
|
||||
|
||||
await service.CalculateAdvancementAsync(1);
|
||||
|
||||
Assert.False(stale.IsAdvanced);
|
||||
Assert.Null(stale.AdvanceReason);
|
||||
Assert.True(faster.IsAdvanced);
|
||||
Assert.Equal(AdvanceReason.TopN, faster.AdvanceReason);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public class StudentTests
|
||||
DateOfBirth = new DateOnly(2012, 2, 29) // leap year
|
||||
};
|
||||
|
||||
// March 1, 2025 - non-leap year, birthday hasn't technically passed
|
||||
// March 1, 2025 — non-leap year; the Feb 29 birthday is treated as passed
|
||||
var referenceDate = new DateOnly(2025, 3, 1);
|
||||
Assert.Equal(13, student.GetAge(referenceDate));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user