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>
146 lines
5.1 KiB
C#
146 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|