diff --git a/.env.example b/.env.example index f64ce1c..de9f6d4 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ COSMOSDB_CONNECTIONSTRING= # Required. CLOUDFLARE_AUDIENCE= +# Development-only: when true, selection GET/PUT endpoints use in-memory mock state. +# This is ignored outside Development environment. +DEV_MOCK_CURRENT_SELECTIONS=true + # --- Cloudflare Tunnel (Optional) --- # Used for the 'cloud' profile to expose the service publicly. # TUNNEL_TOKEN= diff --git a/F1Competition.sln b/F1Competition.sln index 91d35ab..22b2ad7 100644 --- a/F1Competition.sln +++ b/F1Competition.sln @@ -13,16 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Infrastructure", "src\F1 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Services", "src\F1.Services\F1.Services.csproj", "{4AE3B62E-A936-4170-8E5F-17C9B23A02FB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Config", "src\PopulateF1Database.Config\PopulateF1Database.Config.csproj", "{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.DataAccess", "src\PopulateF1Database.DataAccess\PopulateF1Database.DataAccess.csproj", "{A8236C71-91ED-406F-AB4C-7601A5F070F5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Models", "src\PopulateF1Database.Models\PopulateF1Database.Models.csproj", "{32C40E51-3FF3-42BB-BD62-122D02ADD66F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database", "src\PopulateF1Database\PopulateF1Database.csproj", "{04789E1D-BF87-4DBE-896E-3568B67D71E2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Services", "src\PopulateF1Database.Services\PopulateF1Database.Services.csproj", "{512973AF-5DBE-481A-9819-1C52B041429F}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F238F935-2CD0-4D1E-8DCB-C42A0E241EFA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Web", "src\F1.Web\F1.Web.csproj", "{F53769FD-1C8E-405A-9602-B3AD895025EF}" @@ -56,26 +46,6 @@ Global {4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Debug|Any CPU.Build.0 = Debug|Any CPU {4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Release|Any CPU.Build.0 = Release|Any CPU - {DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Release|Any CPU.Build.0 = Release|Any CPU - {A8236C71-91ED-406F-AB4C-7601A5F070F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A8236C71-91ED-406F-AB4C-7601A5F070F5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A8236C71-91ED-406F-AB4C-7601A5F070F5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A8236C71-91ED-406F-AB4C-7601A5F070F5}.Release|Any CPU.Build.0 = Release|Any CPU - {32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Release|Any CPU.Build.0 = Release|Any CPU - {04789E1D-BF87-4DBE-896E-3568B67D71E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {04789E1D-BF87-4DBE-896E-3568B67D71E2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {04789E1D-BF87-4DBE-896E-3568B67D71E2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {04789E1D-BF87-4DBE-896E-3568B67D71E2}.Release|Any CPU.Build.0 = Release|Any CPU - {512973AF-5DBE-481A-9819-1C52B041429F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {512973AF-5DBE-481A-9819-1C52B041429F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {512973AF-5DBE-481A-9819-1C52B041429F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {512973AF-5DBE-481A-9819-1C52B041429F}.Release|Any CPU.Build.0 = Release|Any CPU {F53769FD-1C8E-405A-9602-B3AD895025EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F53769FD-1C8E-405A-9602-B3AD895025EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {F53769FD-1C8E-405A-9602-B3AD895025EF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -94,11 +64,6 @@ Global {BB36B68F-9369-4AFB-A449-4591D74E64D3} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} {0151CA68-EF87-44B3-BDAB-FA7046C5A6EE} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} {4AE3B62E-A936-4170-8E5F-17C9B23A02FB} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} - {DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} - {A8236C71-91ED-406F-AB4C-7601A5F070F5} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} - {32C40E51-3FF3-42BB-BD62-122D02ADD66F} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} - {04789E1D-BF87-4DBE-896E-3568B67D71E2} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} - {512973AF-5DBE-481A-9819-1C52B041429F} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} {F53769FD-1C8E-405A-9602-B3AD895025EF} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4} {7C50243B-A8CF-4D46-AFD9-10B3CEC57863} = {F238F935-2CD0-4D1E-8DCB-C42A0E241EFA} {CEE8247D-AF0A-45FB-9CE6-A78E3FCCC245} = {F238F935-2CD0-4D1E-8DCB-C42A0E241EFA} diff --git a/README.md b/README.md index 38bc549..a7a82d7 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ Required API values in `.env`: - `COSMOSDB_CONNECTIONSTRING`: mapped to `CosmosDb__ConnectionString` for `f1-api`. - `CLOUDFLARE_AUDIENCE`: mapped to `CloudflareAccess__Audience` for `f1-api`. +Optional development toggle in `.env`: + +- `DEV_MOCK_CURRENT_SELECTIONS`: mapped to `DevSettings__MockCurrentSelections` for `f1-api`. When `true` in Development, selection GET/PUT endpoints use an in-memory mock store so the UI can be validated without Cosmos data. + Notes: - `CloudflareAccess__Issuer` is currently set in `docker-compose.yml`. diff --git a/docker-compose.yml b/docker-compose.yml index 13f008e..c03ced6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - CosmosDb__ConnectionString=${COSMOSDB_CONNECTIONSTRING} - CloudflareAccess__Audience=${CLOUDFLARE_AUDIENCE:-CloudflareAccessAudienceNotSet} - CloudflareAccess__Issuer=https://f1-team.cloudflareaccess.com + - DevSettings__MockCurrentSelections=${DEV_MOCK_CURRENT_SELECTIONS:-false} image: ghcr.io/philipwoulfe/f1competition:${TAG:-latest} build: context: . diff --git a/src/F1.Api/Controllers/SelectionsController.cs b/src/F1.Api/Controllers/SelectionsController.cs index 5b28202..0dd114d 100644 --- a/src/F1.Api/Controllers/SelectionsController.cs +++ b/src/F1.Api/Controllers/SelectionsController.cs @@ -1,7 +1,11 @@ using F1.Core.Dtos; using F1.Core.Interfaces; +using F1.Core.Models; using F1.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using System.Collections.Concurrent; using System.Security.Claims; namespace F1.Api.Controllers; @@ -10,11 +14,19 @@ namespace F1.Api.Controllers; [Route("selections")] public class SelectionsController : ControllerBase { + private static readonly ConcurrentDictionary MockSelections = new(StringComparer.OrdinalIgnoreCase); private readonly ISelectionService _selectionService; + private readonly IConfiguration _configuration; + private readonly IHostEnvironment _hostEnvironment; - public SelectionsController(ISelectionService selectionService) + public SelectionsController( + ISelectionService selectionService, + IConfiguration configuration, + IHostEnvironment hostEnvironment) { _selectionService = selectionService; + _configuration = configuration; + _hostEnvironment = hostEnvironment; } [HttpGet("{raceId}/config")] @@ -38,6 +50,11 @@ public async Task GetMine(string raceId) return Unauthorized(); } + if (ShouldUseMockCurrentSelections()) + { + return Ok(GetOrCreateMockSelection(raceId, userId)); + } + var selection = await _selectionService.GetSelectionAsync(raceId, userId); if (selection is null) { @@ -47,6 +64,25 @@ public async Task GetMine(string raceId) return Ok(selection); } + [HttpGet("current")] + public async Task GetCurrent() + { + var userId = ResolveUserId(); + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + if (ShouldUseMockCurrentSelections()) + { + var selection = GetOrCreateMockSelection(SelectionService.AustraliaRaceId2026, userId); + return Ok(MapCurrentSelections(selection)); + } + + var selections = await _selectionService.GetCurrentSelectionsAsync(userId); + return Ok(selections); + } + [HttpPut("{raceId}/mine")] public async Task UpsertMine(string raceId, [FromBody] SelectionSubmissionDto submission) { @@ -56,6 +92,18 @@ public async Task UpsertMine(string raceId, [FromBody] SelectionS return Unauthorized(); } + if (ShouldUseMockCurrentSelections()) + { + var validationMessage = ValidateMockSubmission(submission); + if (validationMessage is not null) + { + return BadRequest(new { message = validationMessage }); + } + + var selection = UpsertMockSelection(raceId, userId, submission); + return Ok(selection); + } + try { var selection = await _selectionService.UpsertSelectionAsync(raceId, userId, submission); @@ -76,4 +124,138 @@ public async Task UpsertMine(string raceId, [FromBody] SelectionS return User.FindFirstValue(ClaimTypes.Email) ?? Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault(); } + + private bool ShouldUseMockCurrentSelections() + { + return _hostEnvironment.IsDevelopment() + && _configuration.GetValue("DevSettings:MockCurrentSelections"); + } + + private static Selection GetOrCreateMockSelection(string raceId, string userId) + { + var key = BuildMockSelectionKey(raceId, userId); + return MockSelections.GetOrAdd(key, _ => BuildDefaultMockSelection(raceId, userId)); + } + + private static Selection UpsertMockSelection(string raceId, string userId, SelectionSubmissionDto submission) + { + var orderedSelections = submission.OrderedSelections; + var key = BuildMockSelectionKey(raceId, userId); + var updated = new Selection + { + Id = MockSelections.TryGetValue(key, out var existing) ? existing.Id : Guid.NewGuid(), + RaceId = raceId, + UserId = userId, + OrderedSelections = orderedSelections, + BetType = submission.BetType, + SubmittedAtUtc = DateTime.UtcNow, + IsLocked = false + }; + + MockSelections[key] = updated; + return updated; + } + + private static Selection BuildDefaultMockSelection(string raceId, string userId) + { + return new Selection + { + Id = Guid.NewGuid(), + RaceId = raceId, + UserId = userId, + BetType = BetType.Regular, + SubmittedAtUtc = new DateTime(2026, 3, 6, 9, 0, 0, DateTimeKind.Utc), + IsLocked = false, + OrderedSelections = + [ + new SelectionPosition { Position = 1, DriverId = "max_verstappen" }, + new SelectionPosition { Position = 2, DriverId = "lando_norris" }, + new SelectionPosition { Position = 3, DriverId = "charles_leclerc" }, + new SelectionPosition { Position = 4, DriverId = "oscar_piastri" }, + new SelectionPosition { Position = 5, DriverId = "lewis_hamilton" } + ] + }; + } + + private static IReadOnlyList MapCurrentSelections(Selection selection) + { + var userName = selection.UserId.Split('@')[0]; + var orderedSelections = selection.OrderedSelections; + var rows = new List(orderedSelections.Count); + + foreach (var selectionItem in orderedSelections) + { + var driverId = selectionItem.DriverId; + if (string.IsNullOrWhiteSpace(driverId)) + { + continue; + } + + rows.Add(new CurrentSelectionDto + { + Position = selectionItem.Position, + UserId = selection.UserId, + UserName = userName, + DriverId = driverId, + DriverName = ResolveMockDriverName(driverId), + SelectionType = selection.BetType.ToString(), + Timestamp = selection.SubmittedAtUtc + }); + } + + return rows; + } + + private static string ResolveMockDriverName(string driverId) + { + return driverId switch + { + "max_verstappen" => "Max Verstappen", + "lando_norris" => "Lando Norris", + "charles_leclerc" => "Charles Leclerc", + "oscar_piastri" => "Oscar Piastri", + "lewis_hamilton" => "Lewis Hamilton", + "leclerc" => "Charles Leclerc", + "norris" => "Lando Norris", + "hamilton" => "Lewis Hamilton", + "piastri" => "Oscar Piastri", + _ => driverId + }; + } + + private static string? ValidateMockSubmission(SelectionSubmissionDto submission) + { + var validSelections = submission.OrderedSelections + .Where(item => !string.IsNullOrWhiteSpace(item.DriverId)) + .ToList(); + + var distinctPositions = validSelections + .Select(item => item.Position) + .Distinct() + .Count(); + + var distinctCount = submission.OrderedSelections + .Where(item => !string.IsNullOrWhiteSpace(item.DriverId)) + .Select(item => item.DriverId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + + var totalCount = submission.OrderedSelections.Count; + if (totalCount != 5 || validSelections.Count != 5 || distinctCount != 5 || distinctPositions != 5) + { + return "Exactly 5 unique drivers must be selected."; + } + + if (validSelections.Any(item => item.Position < 1 || item.Position > 5)) + { + return "Selection positions must be between 1 and 5."; + } + + return null; + } + + private static string BuildMockSelectionKey(string raceId, string userId) + { + return $"{raceId}::{userId}"; + } } diff --git a/src/F1.Api/appsettings.Development.json b/src/F1.Api/appsettings.Development.json index 1c7e17d..e5ecdcc 100644 --- a/src/F1.Api/appsettings.Development.json +++ b/src/F1.Api/appsettings.Development.json @@ -15,7 +15,8 @@ }, "DevSettings": { "SimulateCloudflare": true, - "MockEmail": "dev-user@example.com" + "MockEmail": "dev-user@example.com", + "MockCurrentSelections": true } } diff --git a/src/F1.Core/Dtos/CurrentSelectionDto.cs b/src/F1.Core/Dtos/CurrentSelectionDto.cs new file mode 100644 index 0000000..75ab855 --- /dev/null +++ b/src/F1.Core/Dtos/CurrentSelectionDto.cs @@ -0,0 +1,12 @@ +namespace F1.Core.Dtos; + +public class CurrentSelectionDto +{ + public int Position { get; set; } + public string UserId { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string DriverId { get; set; } = string.Empty; + public string DriverName { get; set; } = string.Empty; + public string SelectionType { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/F1.Core/Dtos/SelectionSubmissionDto.cs b/src/F1.Core/Dtos/SelectionSubmissionDto.cs index 260235c..b343a10 100644 --- a/src/F1.Core/Dtos/SelectionSubmissionDto.cs +++ b/src/F1.Core/Dtos/SelectionSubmissionDto.cs @@ -4,6 +4,7 @@ namespace F1.Core.Dtos; public class SelectionSubmissionDto { - public List Selections { get; set; } = []; + public List OrderedSelections { get; set; } = []; + public BetType BetType { get; set; } } diff --git a/src/F1.Core/Interfaces/ISelectionService.cs b/src/F1.Core/Interfaces/ISelectionService.cs index 6992713..acf852a 100644 --- a/src/F1.Core/Interfaces/ISelectionService.cs +++ b/src/F1.Core/Interfaces/ISelectionService.cs @@ -6,6 +6,7 @@ namespace F1.Core.Interfaces; public interface ISelectionService { Task GetSelectionAsync(string raceId, string userId); + Task> GetCurrentSelectionsAsync(string userId); Task UpsertSelectionAsync(string raceId, string userId, SelectionSubmissionDto submission); int CalculateScore(BetType betType, bool isPerfectTopFive, int basePoints, bool submittedBeforePreQualyDeadline); RaceConfigDto? GetRaceConfig(string raceId); diff --git a/src/F1.Core/Models/Selection.cs b/src/F1.Core/Models/Selection.cs index 5ba12fe..bf7a3ac 100644 --- a/src/F1.Core/Models/Selection.cs +++ b/src/F1.Core/Models/Selection.cs @@ -9,8 +9,14 @@ public class Selection public string UserId { get; set; } = string.Empty; public string RaceId { get; set; } = string.Empty; - public List Selections { get; set; } = []; + public List OrderedSelections { get; set; } = []; public BetType BetType { get; set; } public DateTime SubmittedAtUtc { get; set; } public bool IsLocked { get; set; } } + +public class SelectionPosition +{ + public int Position { get; set; } + public string DriverId { get; set; } = string.Empty; +} diff --git a/src/F1.Infrastructure/Repositories/CosmosSelectionRepository.cs b/src/F1.Infrastructure/Repositories/CosmosSelectionRepository.cs index 164b677..efd065d 100644 --- a/src/F1.Infrastructure/Repositories/CosmosSelectionRepository.cs +++ b/src/F1.Infrastructure/Repositories/CosmosSelectionRepository.cs @@ -18,7 +18,7 @@ public CosmosSelectionRepository(CosmosClient cosmosClient, IConfiguration confi public async Task GetSelectionAsync(string raceId, string userId) { var queryDefinition = new QueryDefinition( - "SELECT TOP 1 * FROM c WHERE c.raceId = @raceId AND c.userId = @userId") + "SELECT TOP 1 * FROM c WHERE c.raceId = @raceId AND c.userId = @userId ORDER BY c._ts DESC") .WithParameter("@raceId", raceId) .WithParameter("@userId", userId); diff --git a/src/F1.Services/SelectionService.cs b/src/F1.Services/SelectionService.cs index 6255bab..0734af2 100644 --- a/src/F1.Services/SelectionService.cs +++ b/src/F1.Services/SelectionService.cs @@ -11,11 +11,16 @@ public class SelectionService : ISelectionService public const string AustraliaRaceId2026 = "2026-australia"; private readonly ISelectionRepository _selectionRepository; + private readonly IDriverRepository _driverRepository; private readonly IDateTimeProvider _dateTimeProvider; - public SelectionService(ISelectionRepository selectionRepository, IDateTimeProvider dateTimeProvider) + public SelectionService( + ISelectionRepository selectionRepository, + IDriverRepository driverRepository, + IDateTimeProvider dateTimeProvider) { _selectionRepository = selectionRepository; + _driverRepository = driverRepository; _dateTimeProvider = dateTimeProvider; } @@ -34,7 +39,8 @@ public SelectionService(ISelectionRepository selectionRepository, IDateTimeProvi public async Task UpsertSelectionAsync(string raceId, string userId, SelectionSubmissionDto submission) { - ValidateSelections(submission.Selections); + var orderedSelections = submission.OrderedSelections; + ValidateSelections(orderedSelections); var nowUtc = _dateTimeProvider.UtcNow; var existingSelection = await _selectionRepository.GetSelectionAsync(raceId, userId); @@ -58,7 +64,7 @@ public async Task UpsertSelectionAsync(string raceId, string userId, selection.Id = existingSelection?.Id ?? Guid.Empty; selection.RaceId = raceId; selection.UserId = userId; - selection.Selections = submission.Selections; + selection.OrderedSelections = orderedSelections; selection.BetType = submission.BetType; selection.SubmittedAtUtc = nowUtc; selection.IsLocked = false; @@ -66,6 +72,46 @@ public async Task UpsertSelectionAsync(string raceId, string userId, return await _selectionRepository.UpsertSelectionAsync(selection); } + public async Task> GetCurrentSelectionsAsync(string userId) + { + var selection = await _selectionRepository.GetSelectionAsync(AustraliaRaceId2026, userId); + if (selection is null) + { + return []; + } + + var orderedSelections = selection.OrderedSelections + .OrderBy(item => item.Position) + .ToList(); + + var drivers = await _driverRepository.GetDriversAsync(); + var driverLookup = drivers + .Where(driver => !string.IsNullOrWhiteSpace(driver.DriverId)) + .ToDictionary(driver => driver.DriverId!, driver => driver.FullName ?? driver.DriverId!, StringComparer.OrdinalIgnoreCase); + + var rows = new List(orderedSelections.Count); + foreach (var selectionItem in orderedSelections) + { + if (string.IsNullOrWhiteSpace(selectionItem.DriverId)) + { + continue; + } + + rows.Add(new CurrentSelectionDto + { + Position = selectionItem.Position, + UserId = selection.UserId, + UserName = selection.UserId, + DriverId = selectionItem.DriverId, + DriverName = driverLookup.GetValueOrDefault(selectionItem.DriverId, selectionItem.DriverId), + SelectionType = selection.BetType.ToString(), + Timestamp = selection.SubmittedAtUtc + }); + } + + return rows; + } + public RaceConfigDto? GetRaceConfig(string raceId) { if (raceId == AustraliaRaceId2026) @@ -96,17 +142,33 @@ public int CalculateScore(BetType betType, bool isPerfectTopFive, int basePoints return basePoints; } - private static void ValidateSelections(List selections) + private static void ValidateSelections(List selections) { + var validSelections = selections + .Where(item => !string.IsNullOrWhiteSpace(item.DriverId)) + .ToList(); + + var distinctPositions = validSelections + .Select(item => item.Position) + .Distinct() + .Count(); + var distinctCount = selections - .Where(driverId => !string.IsNullOrWhiteSpace(driverId)) + .Where(item => !string.IsNullOrWhiteSpace(item.DriverId)) + .Select(item => item.DriverId) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); - if (selections.Count != 5 || distinctCount != 5) + var totalCount = selections.Count; + if (totalCount != 5 || validSelections.Count != 5 || distinctCount != 5 || distinctPositions != 5) { throw new SelectionValidationException("Exactly 5 unique drivers must be selected."); } + + if (validSelections.Any(item => item.Position < 1 || item.Position > 5)) + { + throw new SelectionValidationException("Selection positions must be between 1 and 5."); + } } private static bool IsPreQualyLocked(Selection selection, DateTime nowUtc) diff --git a/src/F1.Web/Models/CurrentSelectionItem.cs b/src/F1.Web/Models/CurrentSelectionItem.cs new file mode 100644 index 0000000..aa17460 --- /dev/null +++ b/src/F1.Web/Models/CurrentSelectionItem.cs @@ -0,0 +1,12 @@ +namespace F1.Web.Models; + +public class CurrentSelectionItem +{ + public int Position { get; set; } + public string UserId { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string DriverId { get; set; } = string.Empty; + public string DriverName { get; set; } = string.Empty; + public string SelectionType { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/F1.Web/Models/Selection.cs b/src/F1.Web/Models/Selection.cs index a3d8b51..17fcf2d 100644 --- a/src/F1.Web/Models/Selection.cs +++ b/src/F1.Web/Models/Selection.cs @@ -5,6 +5,8 @@ public class Selection public Guid Id { get; set; } public string UserId { get; set; } = string.Empty; public string RaceId { get; set; } = string.Empty; + public List OrderedSelections { get; set; } = []; + // Legacy flat list retained temporarily for backward compatibility. public List Selections { get; set; } = []; public BetType BetType { get; set; } public DateTime SubmittedAtUtc { get; set; } @@ -13,6 +15,14 @@ public class Selection public class SelectionSubmission { + public List OrderedSelections { get; set; } = []; + // Legacy flat list retained temporarily for backward compatibility. public List Selections { get; set; } = []; public BetType BetType { get; set; } } + +public class SelectionPosition +{ + public int Position { get; set; } + public string DriverId { get; set; } = string.Empty; +} diff --git a/src/F1.Web/Pages/AustraliaSelection.razor b/src/F1.Web/Pages/AustraliaSelection.razor index c8f4c0d..7b4db4a 100644 --- a/src/F1.Web/Pages/AustraliaSelection.razor +++ b/src/F1.Web/Pages/AustraliaSelection.razor @@ -126,6 +126,7 @@ else drivers = await Http.GetFromJsonAsync("drivers") ?? System.Array.Empty(); raceConfig = await Http.GetFromJsonAsync($"selections/{RaceId}/config"); await LoadExistingSelectionAsync(); + await LoadCurrentSelectionsAsync(); } catch (Exception ex) { @@ -150,7 +151,17 @@ else } selectedBetType = existing.BetType; - var selections = existing.Selections ?? []; + var selections = (existing.OrderedSelections ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.DriverId)) + .OrderBy(item => item.Position) + .Select(item => item.DriverId) + .ToList(); + + if (selections.Count == 0) + { + selections = existing.Selections ?? []; + } + for (var i = 0; i < selectedDriverIds.Count; i++) { selectedDriverIds[i] = i < selections.Count ? selections[i] : ""; @@ -159,6 +170,39 @@ else isReadOnly = existing.IsLocked; } + private async Task LoadCurrentSelectionsAsync() + { + var snapshot = await Http.GetFromJsonAsync("selections/current") ?? []; + + if (snapshot.Length == 0) + { + return; + } + + var rankedDrivers = snapshot + .Where(row => !string.IsNullOrWhiteSpace(row.DriverId)) + .OrderBy(row => row.Position <= 0 ? int.MaxValue : row.Position) + .ThenBy(row => row.Timestamp) + .Select(row => row.DriverId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(selectedDriverIds.Count) + .ToList(); + + for (var i = 0; i < selectedDriverIds.Count; i++) + { + selectedDriverIds[i] = i < rankedDrivers.Count ? rankedDrivers[i] : string.Empty; + } + + var selectionType = snapshot + .Select(row => row.SelectionType) + .FirstOrDefault(type => !string.IsNullOrWhiteSpace(type)); + + if (Enum.TryParse(selectionType, ignoreCase: true, out var mappedBetType)) + { + selectedBetType = mappedBetType; + } + } + private async Task SaveSelectionAsync() { errorMessage = null; @@ -167,6 +211,9 @@ else var submission = new SelectionSubmission { BetType = selectedBetType, + OrderedSelections = selectedDriverIds + .Select((driverId, index) => new SelectionPosition { Position = index + 1, DriverId = driverId }) + .ToList(), Selections = selectedDriverIds.ToList() }; @@ -177,6 +224,7 @@ else { successMessage = "Selection saved successfully."; await LoadExistingSelectionAsync(); + await LoadCurrentSelectionsAsync(); return; } diff --git a/src/PopulateF1Database.Config/PopulateF1Database.Config.csproj b/src/PopulateF1Database.Config/PopulateF1Database.Config.csproj.bak similarity index 100% rename from src/PopulateF1Database.Config/PopulateF1Database.Config.csproj rename to src/PopulateF1Database.Config/PopulateF1Database.Config.csproj.bak diff --git a/src/PopulateF1Database.DataAccess/PopulateF1Database.DataAccess.csproj b/src/PopulateF1Database.DataAccess/PopulateF1Database.DataAccess.csproj.bak similarity index 100% rename from src/PopulateF1Database.DataAccess/PopulateF1Database.DataAccess.csproj rename to src/PopulateF1Database.DataAccess/PopulateF1Database.DataAccess.csproj.bak diff --git a/src/PopulateF1Database.Models/PopulateF1Database.Models.csproj b/src/PopulateF1Database.Models/PopulateF1Database.Models.csproj.bak similarity index 100% rename from src/PopulateF1Database.Models/PopulateF1Database.Models.csproj rename to src/PopulateF1Database.Models/PopulateF1Database.Models.csproj.bak diff --git a/src/PopulateF1Database.Services/PopulateF1Database.Services.csproj b/src/PopulateF1Database.Services/PopulateF1Database.Services.csproj.bak similarity index 100% rename from src/PopulateF1Database.Services/PopulateF1Database.Services.csproj rename to src/PopulateF1Database.Services/PopulateF1Database.Services.csproj.bak diff --git a/src/PopulateF1Database/PopulateF1Database.csproj b/src/PopulateF1Database/PopulateF1Database.csproj.bak similarity index 100% rename from src/PopulateF1Database/PopulateF1Database.csproj rename to src/PopulateF1Database/PopulateF1Database.csproj.bak diff --git a/tests/F1.Api.Tests/Controllers/SelectionsControllerTests.cs b/tests/F1.Api.Tests/Controllers/SelectionsControllerTests.cs new file mode 100644 index 0000000..59b1874 --- /dev/null +++ b/tests/F1.Api.Tests/Controllers/SelectionsControllerTests.cs @@ -0,0 +1,151 @@ +using F1.Api.Controllers; +using F1.Core.Dtos; +using F1.Core.Interfaces; +using F1.Core.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Moq; +using System.Security.Claims; + +namespace F1.Api.Tests.Controllers; + +public class SelectionsControllerTests +{ + [Fact] + public async Task GetCurrent_ShouldReturnUnauthorized_WhenUserCannotBeResolved() + { + var serviceMock = new Mock(); + var controller = CreateController(serviceMock); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + + var result = await controller.GetCurrent(); + + Assert.IsType(result); + } + + [Fact] + public async Task GetCurrent_ShouldReturnOk_WithSelectionRows() + { + var serviceMock = new Mock(); + serviceMock + .Setup(service => service.GetCurrentSelectionsAsync("user@example.com")) + .ReturnsAsync([ + new CurrentSelectionDto + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "Regular", + Timestamp = new DateTime(2026, 3, 6, 9, 0, 0, DateTimeKind.Utc) + } + ]); + + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Email, "user@example.com")], + "TestAuth")); + + var controller = CreateController(serviceMock); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.GetCurrent(); + + var ok = Assert.IsType(result); + var payload = Assert.IsAssignableFrom>(ok.Value); + Assert.Single(payload); + Assert.Equal(1, payload[0].Position); + Assert.Equal("norris", payload[0].DriverId); + } + + [Fact] + public async Task GetCurrent_ShouldReturnMockRows_InDevelopmentWhenEnabled() + { + var serviceMock = new Mock(); + const string userId = "mock-current@example.com"; + + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Email, userId)], + "TestAuth")); + + var controller = CreateController(serviceMock, mockCurrentSelections: true, environmentName: Environments.Development); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.GetCurrent(); + + var ok = Assert.IsType(result); + var payload = Assert.IsAssignableFrom>(ok.Value); + Assert.NotEmpty(payload); + Assert.Equal(1, payload[0].Position); + Assert.Equal("max_verstappen", payload[0].DriverId); + + serviceMock.Verify(service => service.GetCurrentSelectionsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetMine_ShouldReturnPersistedMockSelection_InDevelopmentWhenEnabled() + { + var serviceMock = new Mock(); + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Email, "user@example.com")], + "TestAuth")); + + var controller = CreateController(serviceMock, mockCurrentSelections: true, environmentName: Environments.Development); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + await controller.UpsertMine("2026-australia", new SelectionSubmissionDto + { + BetType = F1.Core.Models.BetType.PreQualy, + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } + }); + + var result = await controller.GetMine("2026-australia"); + + var ok = Assert.IsType(result); + var payload = Assert.IsType(ok.Value); + Assert.Equal(F1.Core.Models.BetType.PreQualy, payload.BetType); + Assert.Equal("norris", payload.OrderedSelections[0].DriverId); + } + + private static SelectionsController CreateController( + Mock serviceMock, + bool mockCurrentSelections = false, + string environmentName = "Production") + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "DevSettings:MockCurrentSelections", mockCurrentSelections.ToString() } + }) + .Build(); + + var hostEnvironment = new Mock(); + hostEnvironment.SetupGet(env => env.EnvironmentName).Returns(environmentName); + + return new SelectionsController(serviceMock.Object, configuration, hostEnvironment.Object); + } +} diff --git a/tests/F1.Api.Tests/Integration/CosmosSelectionRepositoryTests.cs b/tests/F1.Api.Tests/Integration/CosmosSelectionRepositoryTests.cs index 8129ae1..2c74d2b 100644 --- a/tests/F1.Api.Tests/Integration/CosmosSelectionRepositoryTests.cs +++ b/tests/F1.Api.Tests/Integration/CosmosSelectionRepositoryTests.cs @@ -27,7 +27,14 @@ public async Task UpsertSelectionAsync_ShouldReuseExistingId_ForSameRaceAndUser( RaceId = raceId, UserId = userId, BetType = BetType.Regular, - Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } }; var feedResponse = new Mock>(); @@ -37,8 +44,11 @@ public async Task UpsertSelectionAsync_ShouldReuseExistingId_ForSameRaceAndUser( feedIterator.SetupSequence(i => i.HasMoreResults).Returns(true).Returns(false); feedIterator.Setup(i => i.ReadNextAsync(It.IsAny())).ReturnsAsync(feedResponse.Object); + QueryDefinition? capturedQueryDefinition = null; + mockContainer .Setup(c => c.GetItemQueryIterator(It.IsAny(), null, It.IsAny())) + .Callback((queryDefinition, _, _) => capturedQueryDefinition = queryDefinition) .Returns(feedIterator.Object); var itemResponse = new Mock>(); @@ -62,7 +72,14 @@ public async Task UpsertSelectionAsync_ShouldReuseExistingId_ForSameRaceAndUser( RaceId = raceId, UserId = userId, BetType = BetType.PreQualy, - Selections = ["leclerc", "norris", "hamilton", "piastri", "verstappen"], + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "leclerc" }, + new SelectionPosition { Position = 2, DriverId = "norris" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + }, SubmittedAtUtc = DateTime.UtcNow }; @@ -73,9 +90,14 @@ public async Task UpsertSelectionAsync_ShouldReuseExistingId_ForSameRaceAndUser( s.Id == existingId && s.RaceId == raceId && s.UserId == userId && - s.Selections[0] == "leclerc"), + s.OrderedSelections[0].DriverId == "leclerc"), It.Is(pk => pk.HasValue && pk.Value.Equals(new PartitionKey(raceId))), null, It.IsAny()), Times.Once); + + Assert.NotNull(capturedQueryDefinition); + Assert.Contains("c.raceId", capturedQueryDefinition!.QueryText, StringComparison.Ordinal); + Assert.Contains("c.userId", capturedQueryDefinition.QueryText, StringComparison.Ordinal); + Assert.Contains("ORDER BY c._ts DESC", capturedQueryDefinition.QueryText, StringComparison.Ordinal); } } diff --git a/tests/F1.Api.Tests/Integration/CurrentSelectionsEndpointTests.cs b/tests/F1.Api.Tests/Integration/CurrentSelectionsEndpointTests.cs new file mode 100644 index 0000000..935f39d --- /dev/null +++ b/tests/F1.Api.Tests/Integration/CurrentSelectionsEndpointTests.cs @@ -0,0 +1,70 @@ +using F1.Core.Dtos; +using F1.Core.Interfaces; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using System.Net; +using System.Net.Http.Json; + +namespace F1.Api.Tests.Integration; + +public class CurrentSelectionsEndpointTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public CurrentSelectionsEndpointTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetCurrentSelections_ShouldReturnOk_WithJsonArray() + { + var serviceMock = new Mock(); + serviceMock + .Setup(service => service.GetCurrentSelectionsAsync("user@example.com")) + .ReturnsAsync([ + new CurrentSelectionDto + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "Regular", + Timestamp = new DateTime(2026, 3, 6, 9, 0, 0, DateTimeKind.Utc) + } + ]); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { "DevSettings:SimulateCloudflare", "true" }, + { "DevSettings:MockEmail", "user@example.com" }, + { "DevSettings:MockCurrentSelections", "false" } + }); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddScoped(_ => serviceMock.Object); + }); + }).CreateClient(); + + var response = await client.GetAsync("/selections/current"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(payload); + Assert.Single(payload!); + Assert.Equal(1, payload[0].Position); + Assert.Equal("norris", payload[0].DriverId); + } +} diff --git a/tests/F1.Api.Tests/SelectionServiceTests.cs b/tests/F1.Api.Tests/SelectionServiceTests.cs index 18d9071..8553576 100644 --- a/tests/F1.Api.Tests/SelectionServiceTests.cs +++ b/tests/F1.Api.Tests/SelectionServiceTests.cs @@ -9,8 +9,32 @@ namespace F1.Api.Tests; public class SelectionServiceTests { private readonly Mock _selectionRepositoryMock = new(); + private readonly Mock _driverRepositoryMock = new(); private readonly Mock _dateTimeProviderMock = new(); + [Fact] + public async Task UpsertSelectionAsync_ShouldReject_WhenMoreThanFiveSelectionsSubmitted() + { + var service = CreateServiceAt(new DateTime(2026, 3, 7, 0, 0, 0, DateTimeKind.Utc)); + + var submission = new SelectionSubmissionDto + { + BetType = BetType.Regular, + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" }, + new SelectionPosition { Position = 0, DriverId = "" } + } + }; + + await Assert.ThrowsAsync(() => + service.UpsertSelectionAsync("2026-australia", "user@example.com", submission)); + } + [Fact] public async Task UpsertSelectionAsync_ShouldRejectPreQualyBet_AfterDeadline() { @@ -22,7 +46,14 @@ public async Task UpsertSelectionAsync_ShouldRejectPreQualyBet_AfterDeadline() var submission = new SelectionSubmissionDto { BetType = BetType.PreQualy, - Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } }; await Assert.ThrowsAsync(() => @@ -41,7 +72,14 @@ public async Task UpsertSelectionAsync_ShouldAllowRegularUpdate_AfterPreQualyDea RaceId = "2026-australia", UserId = "user@example.com", BetType = BetType.Regular, - Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } }; _selectionRepositoryMock @@ -55,14 +93,21 @@ public async Task UpsertSelectionAsync_ShouldAllowRegularUpdate_AfterPreQualyDea var submission = new SelectionSubmissionDto { BetType = BetType.Regular, - Selections = ["leclerc", "norris", "hamilton", "piastri", "verstappen"] + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } }; var updated = await service.UpsertSelectionAsync("2026-australia", "user@example.com", submission); Assert.Equal(BetType.Regular, updated.BetType); Assert.Equal(nowUtc, updated.SubmittedAtUtc); - Assert.Equal("leclerc", updated.Selections[0]); + Assert.Equal("norris", updated.OrderedSelections[0].DriverId); } [Fact] public void GetRaceConfig_ShouldReturnConfig_ForAustraliaRace() @@ -98,7 +143,14 @@ public async Task GetSelectionAsync_ShouldReturnIsLocked_AfterFinalSubmissionDea RaceId = "2026-australia", UserId = "user@example.com", BetType = BetType.Regular, - Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 4, DriverId = "piastri" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" } + } }; _selectionRepositoryMock @@ -125,9 +177,111 @@ public void CalculateScore_ShouldNotApplyPreQualyMultiplier_ForAllOrNothing() Assert.Equal(200, score); } + [Fact] + public async Task GetCurrentSelectionsAsync_ShouldReturnMappedRows_WhenSelectionExists() + { + var service = CreateServiceAt(new DateTime(2026, 3, 6, 12, 0, 0, DateTimeKind.Utc)); + + _selectionRepositoryMock + .Setup(repo => repo.GetSelectionAsync("2026-australia", "user@example.com")) + .ReturnsAsync(new Selection + { + Id = Guid.NewGuid(), + RaceId = "2026-australia", + UserId = "user@example.com", + BetType = BetType.PreQualy, + SubmittedAtUtc = new DateTime(2026, 3, 6, 10, 0, 0, DateTimeKind.Utc), + OrderedSelections = new List + { + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" } + } + }); + + _driverRepositoryMock + .Setup(repo => repo.GetDriversAsync()) + .ReturnsAsync([ + new Driver { DriverId = "norris", FullName = "Lando Norris" }, + new Driver { DriverId = "leclerc", FullName = "Charles Leclerc" } + ]); + + var rows = await service.GetCurrentSelectionsAsync("user@example.com"); + + Assert.Equal(2, rows.Count); + Assert.Equal(1, rows[0].Position); + Assert.Equal("user@example.com", rows[0].UserId); + Assert.Equal("Lando Norris", rows[0].DriverName); + Assert.Equal("PreQualy", rows[0].SelectionType); + Assert.Equal(2, rows[1].Position); + } + + [Fact] + public async Task GetCurrentSelectionsAsync_ShouldReturnRowsSortedByPosition_WhenSelectionsAreOutOfOrder() + { + var service = CreateServiceAt(new DateTime(2026, 3, 6, 12, 0, 0, DateTimeKind.Utc)); + + _selectionRepositoryMock + .Setup(repo => repo.GetSelectionAsync("2026-australia", "user@example.com")) + .ReturnsAsync(new Selection + { + Id = Guid.NewGuid(), + RaceId = "2026-australia", + UserId = "user@example.com", + BetType = BetType.Regular, + SubmittedAtUtc = new DateTime(2026, 3, 6, 10, 0, 0, DateTimeKind.Utc), + OrderedSelections = new List + { + new SelectionPosition { Position = 3, DriverId = "hamilton" }, + new SelectionPosition { Position = 1, DriverId = "norris" }, + new SelectionPosition { Position = 5, DriverId = "verstappen" }, + new SelectionPosition { Position = 2, DriverId = "leclerc" }, + new SelectionPosition { Position = 4, DriverId = "piastri" } + } + }); + + _driverRepositoryMock + .Setup(repo => repo.GetDriversAsync()) + .ReturnsAsync([ + new Driver { DriverId = "norris", FullName = "Lando Norris" }, + new Driver { DriverId = "leclerc", FullName = "Charles Leclerc" }, + new Driver { DriverId = "hamilton", FullName = "Lewis Hamilton" }, + new Driver { DriverId = "piastri", FullName = "Oscar Piastri" }, + new Driver { DriverId = "verstappen", FullName = "Max Verstappen" } + ]); + + var rows = await service.GetCurrentSelectionsAsync("user@example.com"); + + Assert.Equal(5, rows.Count); + Assert.Equal(1, rows[0].Position); + Assert.Equal("norris", rows[0].DriverId); + Assert.Equal(2, rows[1].Position); + Assert.Equal("leclerc", rows[1].DriverId); + Assert.Equal(3, rows[2].Position); + Assert.Equal("hamilton", rows[2].DriverId); + Assert.Equal(4, rows[3].Position); + Assert.Equal("piastri", rows[3].DriverId); + Assert.Equal(5, rows[4].Position); + Assert.Equal("verstappen", rows[4].DriverId); + } + + [Fact] + public async Task GetCurrentSelectionsAsync_ShouldReturnEmpty_WhenNoSelectionExists() + { + var service = CreateServiceAt(new DateTime(2026, 3, 6, 12, 0, 0, DateTimeKind.Utc)); + + _selectionRepositoryMock + .Setup(repo => repo.GetSelectionAsync("2026-australia", "user@example.com")) + .ReturnsAsync((Selection?)null); + + var rows = await service.GetCurrentSelectionsAsync("user@example.com"); + + Assert.Empty(rows); + _driverRepositoryMock.Verify(repo => repo.GetDriversAsync(), Times.Never); + } + private SelectionService CreateServiceAt(DateTime utcNow) { _dateTimeProviderMock.Setup(clock => clock.UtcNow).Returns(utcNow); - return new SelectionService(_selectionRepositoryMock.Object, _dateTimeProviderMock.Object); + return new SelectionService(_selectionRepositoryMock.Object, _driverRepositoryMock.Object, _dateTimeProviderMock.Object); } } diff --git a/tests/F1.Web.Tests/AustraliaSelectionTests.cs b/tests/F1.Web.Tests/AustraliaSelectionTests.cs index 43273cb..1a82d3e 100644 --- a/tests/F1.Web.Tests/AustraliaSelectionTests.cs +++ b/tests/F1.Web.Tests/AustraliaSelectionTests.cs @@ -30,6 +30,7 @@ public void AustraliaSelection_ShouldRenderWarningAndCountdown_WhenLoadedWithNoE })); handler.EnqueueResponse(CreateJsonResponse(DefaultRaceConfig)); handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + handler.EnqueueResponse(CreateJsonResponse(Array.Empty())); Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); @@ -37,6 +38,7 @@ public void AustraliaSelection_ShouldRenderWarningAndCountdown_WhenLoadedWithNoE cut.WaitForAssertion(() => Assert.Contains("Locking for Pre-Qualy gives +50% points", cut.Markup)); Assert.Contains("Countdown:", cut.Markup); + Assert.Equal(string.Empty, cut.FindAll("select")[0].GetAttribute("value")); } [Fact] @@ -57,9 +59,32 @@ public void AustraliaSelection_ShouldRenderLockedState_WhenExistingSelectionIsLo Id = Guid.NewGuid(), RaceId = "2026-australia", UserId = "user@example.com", - BetType = BetType.PreQualy, + BetType = BetType.Regular, IsLocked = true, - Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] + Selections = ["leclerc", "norris", "hamilton", "piastri", "verstappen"] + })); + handler.EnqueueResponse(CreateJsonResponse(new[] + { + new CurrentSelectionItem + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "PreQualy", + Timestamp = new DateTime(2026, 3, 6, 10, 0, 0, DateTimeKind.Utc) + }, + new CurrentSelectionItem + { + Position = 2, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "leclerc", + DriverName = "Charles Leclerc", + SelectionType = "PreQualy", + Timestamp = new DateTime(2026, 3, 6, 10, 1, 0, DateTimeKind.Utc) + } })); Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); @@ -68,6 +93,9 @@ public void AustraliaSelection_ShouldRenderLockedState_WhenExistingSelectionIsLo cut.WaitForAssertion(() => Assert.Contains("This pre-qualy selection is locked.", cut.Markup)); Assert.True(cut.Find("button[type='submit']").HasAttribute("disabled")); + Assert.Equal("norris", cut.FindAll("select")[0].GetAttribute("value")); + Assert.Equal("leclerc", cut.FindAll("select")[1].GetAttribute("value")); + Assert.True(cut.Find("#strategy-prequaly").HasAttribute("checked")); } [Fact] @@ -84,6 +112,7 @@ public void AustraliaSelection_ShouldSaveSelection_WhenSubmitSucceeds() })); handler.EnqueueResponse(CreateJsonResponse(DefaultRaceConfig)); handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + handler.EnqueueResponse(CreateJsonResponse(Array.Empty())); handler.EnqueueResponse(CreateJsonResponse(new Selection { Id = Guid.NewGuid(), @@ -93,6 +122,30 @@ public void AustraliaSelection_ShouldSaveSelection_WhenSubmitSucceeds() IsLocked = false, Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] })); + handler.EnqueueResponse(CreateJsonResponse(new[] + { + new CurrentSelectionItem + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "Regular", + Timestamp = new DateTime(2026, 3, 6, 10, 0, 0, DateTimeKind.Utc) + }, + new CurrentSelectionItem + { + Position = 2, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "leclerc", + DriverName = "Charles Leclerc", + SelectionType = "Regular", + Timestamp = new DateTime(2026, 3, 6, 10, 1, 0, DateTimeKind.Utc) + } + })); + handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.OK)); handler.EnqueueResponse(CreateJsonResponse(new Selection { Id = Guid.NewGuid(), @@ -102,6 +155,19 @@ public void AustraliaSelection_ShouldSaveSelection_WhenSubmitSucceeds() IsLocked = false, Selections = ["norris", "leclerc", "hamilton", "piastri", "verstappen"] })); + handler.EnqueueResponse(CreateJsonResponse(new[] + { + new CurrentSelectionItem + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "Regular", + Timestamp = new DateTime(2026, 3, 6, 11, 0, 0, DateTimeKind.Utc) + } + })); Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); @@ -118,6 +184,7 @@ public void AustraliaSelection_ShouldSaveSelection_WhenSubmitSucceeds() cut.WaitForAssertion(() => Assert.Contains("Selection saved successfully.", cut.Markup)); Assert.Contains(handler.Requests, req => req.Method == HttpMethod.Put && req.RequestUri?.AbsolutePath.EndsWith("/selections/2026-australia/mine") == true); + Assert.Contains(handler.Requests, req => req.Method == HttpMethod.Get && req.RequestUri?.AbsolutePath.EndsWith("/selections/current") == true); } [Fact] @@ -134,6 +201,7 @@ public void AustraliaSelection_ShouldShowApiErrorMessage_WhenSaveFails() })); handler.EnqueueResponse(CreateJsonResponse(DefaultRaceConfig)); handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + handler.EnqueueResponse(CreateJsonResponse(Array.Empty())); handler.EnqueueResponse(CreateJsonResponse(new { message = "Exactly 5 unique drivers must be selected." }, HttpStatusCode.BadRequest)); Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); @@ -156,6 +224,7 @@ public async Task AustraliaSelection_DisposeAsync_ShouldBeIdempotent() })); handler.EnqueueResponse(CreateJsonResponse(DefaultRaceConfig)); handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + handler.EnqueueResponse(CreateJsonResponse(Array.Empty())); Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); @@ -168,6 +237,50 @@ public async Task AustraliaSelection_DisposeAsync_ShouldBeIdempotent() await component.DisposeAsync(); } + [Fact] + public void AustraliaSelection_ShouldPopulateControls_FromCurrentSelectionsSnapshot() + { + var handler = new QueueHttpMessageHandler(); + handler.EnqueueResponse(CreateJsonResponse(new[] + { + new Driver { DriverId = "norris", FullName = "Lando Norris" }, + new Driver { DriverId = "leclerc", FullName = "Charles Leclerc" } + })); + handler.EnqueueResponse(CreateJsonResponse(DefaultRaceConfig)); + handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + handler.EnqueueResponse(CreateJsonResponse(new[] + { + new CurrentSelectionItem + { + Position = 1, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "norris", + DriverName = "Lando Norris", + SelectionType = "PreQualy", + Timestamp = new DateTime(2026, 3, 6, 10, 0, 0, DateTimeKind.Utc) + }, + new CurrentSelectionItem + { + Position = 2, + UserId = "user@example.com", + UserName = "user@example.com", + DriverId = "leclerc", + DriverName = "Charles Leclerc", + SelectionType = "PreQualy", + Timestamp = new DateTime(2026, 3, 6, 10, 1, 0, DateTimeKind.Utc) + } + })); + + Services.AddSingleton(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Equal("norris", cut.FindAll("select")[0].GetAttribute("value"))); + Assert.Equal("leclerc", cut.FindAll("select")[1].GetAttribute("value")); + Assert.True(cut.Find("#strategy-prequaly").HasAttribute("checked")); + } + private static HttpResponseMessage CreateJsonResponse(T payload, HttpStatusCode statusCode = HttpStatusCode.OK) { return new HttpResponseMessage(statusCode)