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:
2026-08-11 08:30:53 -04:00
parent 78c526ee35
commit 810f721e48
93 changed files with 3251 additions and 1606 deletions

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