diff --git a/README.md b/README.md index 021dd07..f9773ae 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,28 @@ Notes: - `/api/users/debug/me` returns sanitized post-auth claims, groups, and role resolution data only when `DEV_ENABLE_DEBUG_ENDPOINTS=true` and the API is running in `Development` or `Test`. - `TAG` applies to API, Web, and Data Sync worker images. Use `TAG=test` for the test host, `TAG=stable` for production, and `TAG=sha-` for rollback or pinning a specific build. +Web post-login landing config: + +- `src/F1.Web/wwwroot/appsettings.json` contains the `PostLoginRouting` section used by the web client after authentication. +- `AdminLandingPath` defaults to `/admin/migration-runs`. +- `AuthenticatedUserLandingPath` defaults to `/results`. +- `FallbackPath` is used when the user session is missing or a role-specific landing path is blank, and defaults to `/results`. +- Environment-specific overrides can be added in `src/F1.Web/wwwroot/appsettings.Development.json`, `src/F1.Web/wwwroot/appsettings.Test.json`, or `src/F1.Web/wwwroot/appsettings.Production.json`. + +Web competition context config: + +- `src/F1.Web/wwwroot/appsettings.json` also contains the `SelectionContext` section used by the race-selection workspace selector. +- Each entry defines `CompetitionSlug`, `CompetitionLabel`, `Season`, and `DefaultRound`. +- Visiting `/selection` restores the last-used competition and season from browser storage when the saved context is still configured; otherwise the client falls back to the default configured context. +- Removing a context from configuration automatically causes stored stale selections to fall back to a valid configured context on the next visit. + +Competition leaderboard config: + +- `src/F1.Api/appsettings.json` contains the `CompetitionLeaderboard` section used by the standings endpoint. +- Each context can be backed by a completed migration run or marked unavailable until a leaderboard source is approved. +- For migration-backed contexts, `MigrationSourcePathContains` selects the latest completed run used for leaderboard totals. +- Official leaderboard totals currently use imported legacy scores for approved migrated contexts; admins can request recalculated comparison mode from the API/UI. + #### B. Data Sync Worker (`src/F1.DataSyncWorker/appsettings*.json`) The worker reads `ConnectionStrings:Postgres` and the `DataSyncWorker` section. Default config includes the three baseline competitions for this epic: diff --git a/docs/epics/gh-286-post-login-user-journey-and-competition-workspace/epic-post-login-user-journey-and-competition-workspace.md b/docs/epics/gh-286-post-login-user-journey-and-competition-workspace/epic-post-login-user-journey-and-competition-workspace.md index 0471867..365f587 100644 --- a/docs/epics/gh-286-post-login-user-journey-and-competition-workspace/epic-post-login-user-journey-and-competition-workspace.md +++ b/docs/epics/gh-286-post-login-user-journey-and-competition-workspace/epic-post-login-user-journey-and-competition-workspace.md @@ -108,11 +108,28 @@ Test notes: - Add automated accessibility checks for key pages and components. - Add keyboard-navigation E2E tests for primary review workflow paths. +### Story E8: Separate migration storage from main domain data +As an engineer, I want migration-prefixed tables isolated from the main application data model so admin migration workflows do not leak into product read paths and the final data is persisted in the correct canonical tables. + +Acceptance criteria: +- Any table or entity whose name starts with `Migration` or `MigrationImport` is only used by migration/admin workflows, import staging, reconciliation, or audit views. +- Core product features read from canonical domain tables only and do not depend on migration-prefixed tables for runtime behavior. +- The migration pipeline explicitly moves or materializes the needed data into the proper canonical tables before the app or UI consumes it. +- If the implementation chooses a separate schema or separate database, the boundary is documented and enforced so migration storage and canonical storage cannot be mixed accidentally. +- The storage model includes an explicit mapping from migration tables to their canonical target tables for every persisted data type that needs to survive import. +- Idempotent re-run behavior is preserved so imports can be repeated without duplicating canonical data or leaving orphaned migration records. + +Test notes: +- Add architecture-level tests or static checks that fail if non-admin code paths reference `Migration*` tables directly. +- Add import/integration tests proving the required data lands in canonical tables after migration completes. +- Add rerun/idempotency tests for the migration-to-canonical handoff. + ## Delivery Plan 1. Finalize role-based post-login destinations and context contract 2. Implement competition-season selection and remembered context 3. Deliver leaderboard and participant drilldown with score-source clarity 4. Add deep-linking, admin migration visibility, and accessibility coverage +5. Define and enforce the migration-storage boundary between admin/import tables and canonical application data ## Risks and Mitigations - Risk: Users lose context when navigating between leaderboard and participant detail. @@ -121,8 +138,12 @@ Test notes: - Risk: Score-source labels are present but still misunderstood. - Mitigation: Use consistent labels, helper copy, and link to scoring truth contract. +- Risk: Migration-prefixed tables become accidental runtime dependencies. +- Mitigation: Keep migration data isolated to admin/import workflows, enforce a canonical target mapping, and consider a dedicated schema or database boundary if table-level separation is not enough. + ## Definition of Done - Post-login flow is role-based and deterministic. - Competition-season context is selectable, persisted, and deep-linkable. - Leaderboard and participant drilldowns show clear score-source semantics. - Accessibility and keyboard flow coverage exists for primary review tasks. +- Migration-prefixed tables are isolated from main product reads and only feed canonical domain tables through explicit import steps. diff --git a/src/F1.Api/Configuration/CompetitionLeaderboardOptions.cs b/src/F1.Api/Configuration/CompetitionLeaderboardOptions.cs new file mode 100644 index 0000000..83abacf --- /dev/null +++ b/src/F1.Api/Configuration/CompetitionLeaderboardOptions.cs @@ -0,0 +1,25 @@ +namespace F1.Api.Configuration; + +public sealed class CompetitionLeaderboardOptions +{ + public const string SectionName = "CompetitionLeaderboard"; + + public List Contexts { get; set; } = []; +} + +public sealed class CompetitionLeaderboardContextOption +{ + public string CompetitionSlug { get; set; } = string.Empty; + + public int Season { get; set; } + + public string DisplayName { get; set; } = string.Empty; + + public string SourceType { get; set; } = "Unavailable"; + + public string ActiveScoreSource { get; set; } = "ImportedLegacy"; + + public string? MigrationSourcePathContains { get; set; } + + public string? UnavailableMessage { get; set; } +} \ No newline at end of file diff --git a/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs b/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs new file mode 100644 index 0000000..6d0d2b0 --- /dev/null +++ b/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs @@ -0,0 +1,46 @@ +namespace F1.Api.Dtos; + +public sealed record CompetitionLeaderboardResponseDto( + string CompetitionSlug, + int Season, + string DisplayName, + string ActiveScoreSource, + string ScoreView, + string ScoreSourceLabel, + string ScoreSourceHelperText, + bool IsComparisonAvailable, + bool IsDataAvailable, + string? EmptyStateMessage, + Guid? SourceRunId, + IReadOnlyList Items); + +public sealed record CompetitionLeaderboardEntryDto( + int Position, + string ParticipantName, + int DisplayPoints, + int ImportedPoints, + int RecalculatedPoints); + +public sealed record CompetitionParticipantDetailResponseDto( + string CompetitionSlug, + int Season, + string DisplayName, + string ParticipantName, + CompetitionParticipantSectionSummaryDto RacePicks, + CompetitionParticipantSectionSummaryDto Preseason, + CompetitionParticipantSectionSummaryDto H2h); + +public sealed record CompetitionParticipantSectionSummaryDto( + string Title, + int ImportedTotalPoints, + int RecalculatedTotalPoints, + IReadOnlyList Items); + +public sealed record CompetitionParticipantDetailItemDto( + string Label, + string Description, + int? ImportedPoints, + int CalculatedPoints, + int DeltaPoints, + string? ReasonCode, + string? Explanation); \ No newline at end of file diff --git a/src/F1.Api/Program.cs b/src/F1.Api/Program.cs index 4af252e..11b8602 100644 --- a/src/F1.Api/Program.cs +++ b/src/F1.Api/Program.cs @@ -1,5 +1,6 @@ using F1.Api.Middleware; using F1.Api.Infrastructure; +using F1.Api.Configuration; using Serilog; using Serilog.Formatting.Compact; using F1.Api.Services; @@ -7,6 +8,7 @@ using F1.Infrastructure.Data; using F1.Infrastructure.Repositories; using F1.Services; +using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi; @@ -45,6 +47,8 @@ builder.Services.AddControllers(); // Add this line to register controller services builder.Services.AddSwaggerGen(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection(CompetitionLeaderboardOptions.SectionName)); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -137,10 +141,44 @@ app.MapControllers(); // This line is crucial for mapping your controllers -app.MapGet("/races/results", (IRaceService raceService) => +app.MapGet("/races/results", async ( + string competition, + int season, + string? view, + ClaimsPrincipal user, + ICompetitionLeaderboardService leaderboardService, + CancellationToken cancellationToken) => { - var results = raceService.GetMockResults(); - return Results.Ok(results); + var normalizedView = string.IsNullOrWhiteSpace(view) ? "active" : view.Trim().ToLowerInvariant(); + if ((normalizedView == "recalculated" || normalizedView == "imported") && !user.IsInRole("Admin")) + { + return Results.Forbid(); + } + + var leaderboard = await leaderboardService.GetLeaderboardAsync( + competition, + season, + normalizedView, + user.IsInRole("Admin"), + cancellationToken); + + return Results.Ok(leaderboard); +}).RequireAuthorization(); + +app.MapGet("/races/results/participants/{participantName}", async ( + string competition, + int season, + string participantName, + ICompetitionLeaderboardService leaderboardService, + CancellationToken cancellationToken) => +{ + var detail = await leaderboardService.GetParticipantDetailAsync( + competition, + season, + participantName, + cancellationToken); + + return Results.Ok(detail); }).RequireAuthorization(); app.Run(); diff --git a/src/F1.Api/Services/CompetitionLeaderboardService.cs b/src/F1.Api/Services/CompetitionLeaderboardService.cs new file mode 100644 index 0000000..fdf2bb7 --- /dev/null +++ b/src/F1.Api/Services/CompetitionLeaderboardService.cs @@ -0,0 +1,363 @@ +using F1.Api.Configuration; +using F1.Api.Dtos; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace F1.Api.Services; + +public interface ICompetitionLeaderboardService +{ + Task GetLeaderboardAsync(string competitionSlug, int season, string scoreView, bool isAdmin, CancellationToken cancellationToken = default); + + Task GetParticipantDetailAsync(string competitionSlug, int season, string participantName, CancellationToken cancellationToken = default); +} + +public sealed class CompetitionLeaderboardService(F1DbContext dbContext, IOptions options) : ICompetitionLeaderboardService +{ + private const string SourceTypeMigrationRun = "MigrationRun"; + private const string SourceTypeUnavailable = "Unavailable"; + private const string ViewActive = "active"; + private const string ViewImported = "imported"; + private const string ViewRecalculated = "recalculated"; + private const string ActiveScoreSourceImportedLegacy = "ImportedLegacy"; + + public async Task GetLeaderboardAsync(string competitionSlug, int season, string scoreView, bool isAdmin, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(competitionSlug); + if (season <= 0) + { + throw new ArgumentOutOfRangeException(nameof(season)); + } + + var normalizedCompetitionSlug = competitionSlug.Trim().ToLowerInvariant(); + var normalizedScoreView = NormalizeScoreView(scoreView); + var context = ResolveContextOption(normalizedCompetitionSlug, season); + var displayName = GetDisplayName(context, normalizedCompetitionSlug, season); + + if (context is null || string.Equals(context.SourceType, SourceTypeUnavailable, StringComparison.OrdinalIgnoreCase)) + { + return CreateUnavailableResponse( + normalizedCompetitionSlug, + season, + displayName, + normalizedScoreView, + isAdmin, + context?.UnavailableMessage ?? "Leaderboard data is not available for this competition yet."); + } + + if (!string.Equals(context.SourceType, SourceTypeMigrationRun, StringComparison.OrdinalIgnoreCase)) + { + return CreateUnavailableResponse( + normalizedCompetitionSlug, + season, + displayName, + normalizedScoreView, + isAdmin, + "Leaderboard data source is not supported for this competition."); + } + + var sourceRun = await GetLatestCompletedRunAsync(context, cancellationToken); + + if (sourceRun is null) + { + return CreateUnavailableResponse( + normalizedCompetitionSlug, + season, + displayName, + normalizedScoreView, + isAdmin, + "No approved leaderboard run is available for this competition yet."); + } + + var raceTotals = await dbContext.MigrationImportParticipantDeltaSummaries + .AsNoTracking() + .Where(row => row.ImportRunId == sourceRun.Id) + .ToListAsync(cancellationToken); + + var preseasonTotals = await dbContext.MigrationImportPreseasonParticipantDeltaSummaries + .AsNoTracking() + .Where(row => row.ImportRunId == sourceRun.Id) + .ToListAsync(cancellationToken); + + var combined = raceTotals + .GroupBy(row => row.Subject, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => new ScoreTotals( + ImportedPoints: group.Sum(item => item.ImportedTotalPoints), + RecalculatedPoints: group.Sum(item => item.CalculatedTotalPoints)), + StringComparer.OrdinalIgnoreCase); + + foreach (var preseasonRow in preseasonTotals) + { + if (combined.TryGetValue(preseasonRow.Subject, out var existingTotals)) + { + combined[preseasonRow.Subject] = existingTotals with + { + ImportedPoints = existingTotals.ImportedPoints + preseasonRow.ImportedTotalPoints, + RecalculatedPoints = existingTotals.RecalculatedPoints + preseasonRow.CalculatedTotalPoints + }; + } + else + { + combined[preseasonRow.Subject] = new ScoreTotals(preseasonRow.ImportedTotalPoints, preseasonRow.CalculatedTotalPoints); + } + } + + var effectiveView = normalizedScoreView == ViewActive || isAdmin + ? normalizedScoreView + : ViewActive; + + var leaderboardItems = combined + .Select(entry => new CompetitionLeaderboardEntryDto( + Position: 0, + ParticipantName: entry.Key, + DisplayPoints: ResolveDisplayPoints(entry.Value, effectiveView, context.ActiveScoreSource), + ImportedPoints: entry.Value.ImportedPoints, + RecalculatedPoints: entry.Value.RecalculatedPoints)) + .OrderByDescending(item => item.DisplayPoints) + .ThenBy(item => item.ParticipantName, StringComparer.Ordinal) + .Select((item, index) => item with { Position = index + 1 }) + .ToArray(); + + var (scoreSourceLabel, scoreSourceHelperText) = CreateScoreSourceText(context.ActiveScoreSource, effectiveView, displayName); + + return new CompetitionLeaderboardResponseDto( + CompetitionSlug: normalizedCompetitionSlug, + Season: season, + DisplayName: displayName, + ActiveScoreSource: context.ActiveScoreSource, + ScoreView: effectiveView, + ScoreSourceLabel: scoreSourceLabel, + ScoreSourceHelperText: scoreSourceHelperText, + IsComparisonAvailable: isAdmin, + IsDataAvailable: leaderboardItems.Length > 0, + EmptyStateMessage: leaderboardItems.Length > 0 ? null : "No participant totals are available for this competition yet.", + SourceRunId: sourceRun.Id, + Items: leaderboardItems); + } + + public async Task GetParticipantDetailAsync(string competitionSlug, int season, string participantName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(competitionSlug); + ArgumentException.ThrowIfNullOrWhiteSpace(participantName); + if (season <= 0) + { + throw new ArgumentOutOfRangeException(nameof(season)); + } + + var normalizedCompetitionSlug = competitionSlug.Trim().ToLowerInvariant(); + var normalizedParticipantName = participantName.Trim(); + var context = ResolveContextOption(normalizedCompetitionSlug, season); + var displayName = GetDisplayName(context, normalizedCompetitionSlug, season); + + var sourceRun = context is not null + ? await GetLatestCompletedRunAsync(context, cancellationToken) + : null; + + var racePickItems = sourceRun is null + ? [] + : await dbContext.MigrationImportPickDiffs + .AsNoTracking() + .Where(item => item.ImportRunId == sourceRun.Id && item.Subject == normalizedParticipantName) + .OrderBy(item => item.RaceCode) + .ThenBy(item => item.PickType) + .Select(item => new CompetitionParticipantDetailItemDto( + item.RaceCode, + item.PickType, + item.ImportedPoints, + item.CalculatedPoints ?? 0, + item.DeltaPoints, + item.ReasonCode, + item.Explanation)) + .ToArrayAsync(cancellationToken); + + var preseasonItems = sourceRun is null + ? [] + : await dbContext.MigrationImportPreseasonQuestionDiffs + .AsNoTracking() + .Where(item => item.ImportRunId == sourceRun.Id && item.Subject == normalizedParticipantName) + .OrderBy(item => item.RowNumber) + .Select(item => new CompetitionParticipantDetailItemDto( + item.QuestionKey, + item.QuestionText, + item.ImportedPoints, + item.CalculatedPoints ?? 0, + item.DeltaPoints, + item.ReasonCode, + item.Explanation)) + .ToArrayAsync(cancellationToken); + + var h2hItems = await BuildH2hItemsAsync(displayName, season, normalizedParticipantName, cancellationToken); + + return new CompetitionParticipantDetailResponseDto( + CompetitionSlug: normalizedCompetitionSlug, + Season: season, + DisplayName: displayName, + ParticipantName: normalizedParticipantName, + RacePicks: BuildSection("Race Picks", racePickItems), + Preseason: BuildSection("Preseason Questions", preseasonItems), + H2h: BuildSection("H2H Questions", h2hItems)); + } + + private static CompetitionLeaderboardResponseDto CreateUnavailableResponse( + string competitionSlug, + int season, + string displayName, + string scoreView, + bool isAdmin, + string message) + { + var (scoreSourceLabel, scoreSourceHelperText) = CreateScoreSourceText(ActiveScoreSourceImportedLegacy, scoreView, displayName); + + return new CompetitionLeaderboardResponseDto( + CompetitionSlug: competitionSlug, + Season: season, + DisplayName: displayName, + ActiveScoreSource: ActiveScoreSourceImportedLegacy, + ScoreView: scoreView, + ScoreSourceLabel: scoreSourceLabel, + ScoreSourceHelperText: scoreSourceHelperText, + IsComparisonAvailable: isAdmin, + IsDataAvailable: false, + EmptyStateMessage: message, + SourceRunId: null, + Items: []); + } + + private static int ResolveDisplayPoints(ScoreTotals totals, string scoreView, string activeScoreSource) + { + return scoreView switch + { + ViewImported => totals.ImportedPoints, + ViewRecalculated => totals.RecalculatedPoints, + _ when string.Equals(activeScoreSource, ActiveScoreSourceImportedLegacy, StringComparison.OrdinalIgnoreCase) => totals.ImportedPoints, + _ => totals.RecalculatedPoints + }; + } + + private static string NormalizeScoreView(string scoreView) + { + return scoreView.Trim().ToLowerInvariant() switch + { + ViewImported => ViewImported, + ViewRecalculated => ViewRecalculated, + _ => ViewActive + }; + } + + private static (string Label, string HelperText) CreateScoreSourceText(string activeScoreSource, string scoreView, string displayName) + { + var officialLabel = string.Equals(activeScoreSource, ActiveScoreSourceImportedLegacy, StringComparison.OrdinalIgnoreCase) + ? "Official Source: Imported legacy scores" + : "Official Source: Recalculated scores"; + + if (string.Equals(scoreView, ViewRecalculated, StringComparison.Ordinal)) + { + return ( + Label: "Compare Mode: Recalculated scores", + HelperText: $"Admin compare mode is showing recalculated totals. Official standings for {displayName} still use imported legacy scores."); + } + + if (string.Equals(scoreView, ViewImported, StringComparison.Ordinal)) + { + return ( + Label: "Compare Mode: Imported legacy scores", + HelperText: $"Admin compare mode is showing imported legacy totals, which also match the current official standings for {displayName}."); + } + + return ( + Label: officialLabel, + HelperText: $"Official standings for {displayName} use imported legacy totals after leaderboard approval."); + } + + private static string ToDisplayName(string value) + { + return string.Join(' ', value.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..])); + } + + private CompetitionLeaderboardContextOption? ResolveContextOption(string competitionSlug, int season) + { + return options.Value.Contexts.FirstOrDefault(option => + string.Equals(option.CompetitionSlug, competitionSlug, StringComparison.OrdinalIgnoreCase) + && option.Season == season); + } + + private static string GetDisplayName(CompetitionLeaderboardContextOption? context, string competitionSlug, int season) + { + var displayName = context?.DisplayName?.Trim(); + return string.IsNullOrWhiteSpace(displayName) + ? $"{ToDisplayName(competitionSlug)} {season}" + : displayName; + } + + private async Task GetLatestCompletedRunAsync(CompetitionLeaderboardContextOption context, CancellationToken cancellationToken) + { + var completedRuns = dbContext.MigrationImportRuns + .AsNoTracking() + .Where(run => run.Status == "Completed" || run.Status == "completed"); + + if (!string.IsNullOrWhiteSpace(context.MigrationSourcePathContains)) + { + var sourcePathToken = context.MigrationSourcePathContains.Trim().ToLowerInvariant(); + completedRuns = completedRuns.Where(run => run.SourceFilePath.ToLower().Contains(sourcePathToken)); + } + + return await completedRuns + .OrderByDescending(run => run.FinishedAtUtc ?? run.StartedAtUtc) + .ThenByDescending(run => run.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + private async Task BuildH2hItemsAsync(string competitionDisplayName, int season, string participantName, CancellationToken cancellationToken) + { + var competition = await dbContext.Competitions + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Name == competitionDisplayName && item.Year == season, cancellationToken); + + if (competition is null) + { + return []; + } + + var rows = await dbContext.QuestionScores + .AsNoTracking() + .Where(score => score.ParticipantId == participantName) + .Join( + dbContext.QuestionTemplates.AsNoTracking().Where(template => template.CompetitionId == competition.Id && template.Season == season && template.Category == F1.Core.Models.QuestionCategory.H2H), + score => score.QuestionTemplateId, + template => template.Id, + (score, template) => new + { + template.QuestionId, + template.Prompt, + score.ImportedPoints, + score.CalculatedPoints, + score.DeltaPoints + }) + .OrderBy(item => item.QuestionId) + .Select(item => new CompetitionParticipantDetailItemDto( + item.QuestionId, + item.Prompt, + item.ImportedPoints, + item.CalculatedPoints, + item.DeltaPoints, + null, + null)) + .ToArrayAsync(cancellationToken); + + return rows; + } + + private static CompetitionParticipantSectionSummaryDto BuildSection(string title, IReadOnlyList items) + { + return new CompetitionParticipantSectionSummaryDto( + Title: title, + ImportedTotalPoints: items.Sum(item => item.ImportedPoints ?? 0), + RecalculatedTotalPoints: items.Sum(item => item.CalculatedPoints), + Items: items); + } + + private sealed record ScoreTotals(int ImportedPoints, int RecalculatedPoints); +} \ No newline at end of file diff --git a/src/F1.Api/appsettings.json b/src/F1.Api/appsettings.json index 0a017e4..c8a75e3 100644 --- a/src/F1.Api/appsettings.json +++ b/src/F1.Api/appsettings.json @@ -23,5 +23,31 @@ }, "Database": { "AutoMigrate": false + }, + "CompetitionLeaderboard": { + "Contexts": [ + { + "CompetitionSlug": "philip", + "Season": 2025, + "DisplayName": "Philip 2025", + "SourceType": "MigrationRun", + "ActiveScoreSource": "ImportedLegacy", + "MigrationSourcePathContains": "phil-2025" + }, + { + "CompetitionSlug": "david", + "Season": 2025, + "DisplayName": "David 2025", + "SourceType": "Unavailable", + "UnavailableMessage": "Leaderboard data is not available for this competition yet." + }, + { + "CompetitionSlug": "main", + "Season": 2026, + "DisplayName": "Main 2026", + "SourceType": "Unavailable", + "UnavailableMessage": "Leaderboard data is not available for this competition yet." + } + ] } } diff --git a/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs index cd0e91b..b37e9cb 100644 --- a/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs +++ b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs @@ -411,7 +411,7 @@ public async Task ParseAndPersistAsync(Guid r return null; } - var templateKeys = questionRows.Select(row => ResolveQuestionId(row.RowNumber, row.RawPayload)).ToArray(); + var templateKeys = questionRows.Select(row => ResolveQuestionId(row.RowNumber, row.RawPayload, usePhil2025Contract)).ToArray(); var existingTemplateIds = await dbContext.QuestionTemplates .Where(x => x.CompetitionId == competitionId.Value && x.Season == _importOptions.Season && templateKeys.Contains(x.QuestionId)) .ToDictionaryAsync(x => x.QuestionId, x => x.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); @@ -435,8 +435,8 @@ public async Task ParseAndPersistAsync(Guid r continue; } - var questionId = ResolveQuestionId(row.RowNumber, row.RawPayload); - var category = ResolveQuestionCategory(row.RawPayload); + var questionId = ResolveQuestionId(row.RowNumber, row.RawPayload, usePhil2025Contract); + var category = ResolveQuestionCategory(row.RowNumber, row.RawPayload, usePhil2025Contract); var optionsJson = category == QuestionCategory.H2H ? BuildH2hOptionsJson(questionText, columns, participants, usePhil2025Contract, driverIdByCode) : null; @@ -662,7 +662,7 @@ private static List ParsePreseasonQuestion var preseasonRows = stagedRows .Where(x => string.Equals(x.SectionType, SectionTypeSeasonQuestionPrediction, StringComparison.Ordinal)) - .Where(x => ResolveQuestionCategory(x.RawPayload) == QuestionCategory.Preseason) + .Where(x => ResolveQuestionCategory(x.RowNumber, x.RawPayload, usePhil2025Contract) == QuestionCategory.Preseason) .OrderBy(x => x.RowNumber) .ToList(); @@ -946,7 +946,7 @@ private static PreseasonNormalizationResult NormalizeH2hAnswer(string? rawAnswer return new PreseasonNormalizationResult(normalized, ["H2H_UNSUPPORTED_TOKEN_SHAPE_PRESERVED"]); } - private static QuestionCategory ResolveQuestionCategory(string rawPayload) + private static QuestionCategory ResolveQuestionCategory(int rowNumber, string rawPayload, bool usePhil2025Contract) { var columns = CsvLineParser.Parse(rawPayload); if (columns.Count == 0) @@ -965,6 +965,13 @@ private static QuestionCategory ResolveQuestionCategory(string rawPayload) return QuestionCategory.H2H; } + if (usePhil2025Contract && + rowNumber >= MigrationPhil2025CsvContractPolicy.PreseasonQuestionStartRow && + rowNumber <= MigrationPhil2025CsvContractPolicy.PreseasonQuestionEndRow) + { + return QuestionCategory.Preseason; + } + if (RaceBonusPromptRegex().IsMatch(prompt)) { return QuestionCategory.RaceBonus; @@ -973,9 +980,9 @@ private static QuestionCategory ResolveQuestionCategory(string rawPayload) return QuestionCategory.Preseason; } - private static string ResolveQuestionId(int rowNumber, string rawPayload) + private static string ResolveQuestionId(int rowNumber, string rawPayload, bool usePhil2025Contract) { - var category = ResolveQuestionCategory(rawPayload); + var category = ResolveQuestionCategory(rowNumber, rawPayload, usePhil2025Contract); return category switch { QuestionCategory.H2H => $"H2H-{rowNumber:D3}", diff --git a/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs index 3af935e..b335c11 100644 --- a/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs +++ b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs @@ -172,23 +172,26 @@ public async Task RecalculateAndPersistAsync( }) .ToList(); - var preseasonCalculatedScores = questionScoreComputations.Count == 0 - ? CalculatePreseasonScores(runId, preseasonAnswers, preseasonPolicy?.PointsPerQuestion) - : questionScoreComputations - .Where(x => x.Category == QuestionCategory.Preseason) - .Select(computation => new MigrationImportPreseasonCalculatedScoreEntity - { - ImportRunId = runId, - RowNumber = computation.SortOrder, - QuestionKey = computation.QuestionId, - QuestionText = computation.Prompt, - Subject = computation.ParticipantId, - PredictedValue = computation.PredictedAnswer, - ActualValue = computation.ActualAnswer, - Points = computation.CalculatedPoints, - ReasonCode = computation.ReasonCode - }) - .ToList(); + var fallbackPreseasonCalculatedScores = CalculatePreseasonScores(runId, preseasonAnswers, preseasonPolicy?.PointsPerQuestion); + var genericPreseasonCalculatedScores = questionScoreComputations + .Where(x => x.Category == QuestionCategory.Preseason) + .Select(computation => new MigrationImportPreseasonCalculatedScoreEntity + { + ImportRunId = runId, + RowNumber = computation.SortOrder, + QuestionKey = computation.QuestionId, + QuestionText = computation.Prompt, + Subject = computation.ParticipantId, + PredictedValue = computation.PredictedAnswer, + ActualValue = computation.ActualAnswer, + Points = computation.CalculatedPoints, + ReasonCode = computation.ReasonCode + }) + .ToList(); + + var preseasonCalculatedScores = MergePreseasonCalculatedScores( + fallbackPreseasonCalculatedScores, + genericPreseasonCalculatedScores); var preseasonCalculatedTotals = preseasonCalculatedScores .GroupBy(x => x.Subject, StringComparer.OrdinalIgnoreCase) .Select(group => new MigrationImportPreseasonCalculatedTotalEntity @@ -389,6 +392,40 @@ private static List CalculatePres return calculated; } + private static List MergePreseasonCalculatedScores( + IReadOnlyCollection fallback, + IReadOnlyCollection preferred) + { + if (fallback.Count == 0) + { + return preferred.ToList(); + } + + if (preferred.Count == 0) + { + return fallback.ToList(); + } + + var merged = preferred.ToDictionary( + x => (QuestionKey: x.QuestionKey?.Trim() ?? string.Empty, Subject: x.Subject?.Trim() ?? string.Empty), + new QuestionParticipantKeyComparer()); + + foreach (var score in fallback) + { + var key = (QuestionKey: score.QuestionKey?.Trim() ?? string.Empty, Subject: score.Subject?.Trim() ?? string.Empty); + if (!merged.ContainsKey(key)) + { + merged[key] = score; + } + } + + return merged.Values + .OrderBy(x => x.RowNumber) + .ThenBy(x => x.QuestionKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Subject, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static (int Points, string ReasonCode) ScorePreseasonAnswer( string? predictedValue, string? actualValue, diff --git a/src/F1.Web/Components/DriverSelectionList.razor b/src/F1.Web/Components/DriverSelectionList.razor index 31b5489..27c2aab 100644 --- a/src/F1.Web/Components/DriverSelectionList.razor +++ b/src/F1.Web/Components/DriverSelectionList.razor @@ -6,7 +6,7 @@ var rank = i;
- @foreach (var driver in Drivers) { diff --git a/src/F1.Web/Configuration/PostLoginRoutingOptions.cs b/src/F1.Web/Configuration/PostLoginRoutingOptions.cs new file mode 100644 index 0000000..6b37543 --- /dev/null +++ b/src/F1.Web/Configuration/PostLoginRoutingOptions.cs @@ -0,0 +1,12 @@ +namespace F1.Web.Configuration; + +public sealed class PostLoginRoutingOptions +{ + public const string SectionName = "PostLoginRouting"; + + public string AdminLandingPath { get; set; } = "/admin/migration-runs"; + + public string AuthenticatedUserLandingPath { get; set; } = "/results"; + + public string FallbackPath { get; set; } = "/results"; +} \ No newline at end of file diff --git a/src/F1.Web/Configuration/SelectionContextOptions.cs b/src/F1.Web/Configuration/SelectionContextOptions.cs new file mode 100644 index 0000000..22f4fb5 --- /dev/null +++ b/src/F1.Web/Configuration/SelectionContextOptions.cs @@ -0,0 +1,28 @@ +namespace F1.Web.Configuration; + +public sealed class SelectionContextOptions +{ + public const string SectionName = "SelectionContext"; + + public List Options { get; set; } = []; +} + +public sealed class SelectionContextOption +{ + public string CompetitionSlug { get; set; } = string.Empty; + + public string CompetitionLabel { get; set; } = string.Empty; + + public int Season { get; set; } + + public int DefaultRound { get; set; } = 1; + + public string ContextKey => $"{CompetitionSlug}:{Season}"; + + public string DisplayLabel => $"{GetCompetitionLabel()} {Season}"; + + public string GetCompetitionLabel() + { + return string.IsNullOrWhiteSpace(CompetitionLabel) ? CompetitionSlug : CompetitionLabel; + } +} \ No newline at end of file diff --git a/src/F1.Web/Layout/NavMenu.razor b/src/F1.Web/Layout/NavMenu.razor index 9c38dd6..53b6d5a 100644 --- a/src/F1.Web/Layout/NavMenu.razor +++ b/src/F1.Web/Layout/NavMenu.razor @@ -37,7 +37,7 @@
diff --git a/src/F1.Web/Models/CompetitionLeaderboardResponse.cs b/src/F1.Web/Models/CompetitionLeaderboardResponse.cs new file mode 100644 index 0000000..e92a4eb --- /dev/null +++ b/src/F1.Web/Models/CompetitionLeaderboardResponse.cs @@ -0,0 +1,22 @@ +namespace F1.Web.Models; + +public sealed record CompetitionLeaderboardResponse( + string CompetitionSlug, + int Season, + string DisplayName, + string ActiveScoreSource, + string ScoreView, + string ScoreSourceLabel, + string ScoreSourceHelperText, + bool IsComparisonAvailable, + bool IsDataAvailable, + string? EmptyStateMessage, + Guid? SourceRunId, + IReadOnlyList Items); + +public sealed record CompetitionLeaderboardEntry( + int Position, + string ParticipantName, + int DisplayPoints, + int ImportedPoints, + int RecalculatedPoints); \ No newline at end of file diff --git a/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs b/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs new file mode 100644 index 0000000..450f307 --- /dev/null +++ b/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs @@ -0,0 +1,25 @@ +namespace F1.Web.Models; + +public sealed record CompetitionParticipantDetailResponse( + string CompetitionSlug, + int Season, + string DisplayName, + string ParticipantName, + CompetitionParticipantSectionSummary RacePicks, + CompetitionParticipantSectionSummary Preseason, + CompetitionParticipantSectionSummary H2h); + +public sealed record CompetitionParticipantSectionSummary( + string Title, + int ImportedTotalPoints, + int RecalculatedTotalPoints, + IReadOnlyList Items); + +public sealed record CompetitionParticipantDetailItem( + string Label, + string Description, + int? ImportedPoints, + int CalculatedPoints, + int DeltaPoints, + string? ReasonCode, + string? Explanation); \ No newline at end of file diff --git a/src/F1.Web/Pages/Home.razor b/src/F1.Web/Pages/Home.razor index a67a634..f585354 100644 --- a/src/F1.Web/Pages/Home.razor +++ b/src/F1.Web/Pages/Home.razor @@ -1,13 +1,45 @@ @page "/" @inject F1.Web.Services.IUserSession UserSession +@inject NavigationManager NavigationManager +@inject IPostLoginLandingResolver PostLoginLandingResolver -Home +Competition Workspace -

Hello, @DisplayName!

+@if (resolvedPath == "/") +{ +

Hello, @DisplayName!

-Welcome to your new app. +

Welcome to your competition workspace.

+} +else +{ +

Redirecting...

+ +

Loading the right workspace for your account.

+} @code { + private string resolvedPath = "/"; + + protected override void OnInitialized() + { + resolvedPath = PostLoginLandingResolver.Resolve(UserSession.User); + } + + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender || string.IsNullOrWhiteSpace(resolvedPath)) + { + return; + } + + var currentPath = new Uri(NavigationManager.Uri).AbsolutePath; + if (!string.Equals(currentPath, resolvedPath, StringComparison.OrdinalIgnoreCase)) + { + NavigationManager.NavigateTo(resolvedPath, replace: true); + } + } + private string DisplayName { get diff --git a/src/F1.Web/Pages/RaceSelection.razor b/src/F1.Web/Pages/RaceSelection.razor index dbf5644..c856dd1 100644 --- a/src/F1.Web/Pages/RaceSelection.razor +++ b/src/F1.Web/Pages/RaceSelection.razor @@ -9,12 +9,45 @@ @inject ISelectionPageService SelectionPageService @inject IRaceContextApiService RaceContextApiService @inject ISelectionCountdownFormatter CountdownFormatter +@inject ISelectionContextService SelectionContextService @inject F1.Web.Services.ITimeProvider TimeProvider @inject F1.Web.Services.IMockDateService MockDateService @inject NavigationManager Navigation

@GetPageTitle()

+@if (availableContexts.Count > 0) +{ +
+
+
+
+ + +
+
+ + +
+
+ @if (GetSelectedContext() is { } selectedContext) + { +
Current context: @selectedContext.DisplayLabel
+ } +
+
+} + @if (!string.IsNullOrWhiteSpace(routeContextErrorMessage)) { @@ -75,9 +108,32 @@ else private string? loadedRaceId; private string? loadedContextKey; private string? routeContextErrorMessage; + private IReadOnlyList availableContexts = []; + private string? selectedCompetitionSlug; + private int? selectedSeason; + + protected override void OnInitialized() + { + availableContexts = SelectionContextService.GetAvailableContexts(); + var defaultContext = SelectionContextService.GetDefaultContext(); + selectedCompetitionSlug = defaultContext.CompetitionSlug; + selectedSeason = defaultContext.Season; + } protected override async Task OnParametersSetAsync() { + if (availableContexts.Count == 0) + { + routeContextErrorMessage = "No competition contexts are configured."; + return; + } + + if (!HasExplicitRouteContext()) + { + await RestoreSelectionContextAsync(); + return; + } + if (!TryResolveRouteContext(out var routeContext)) { loadedContextKey = null; @@ -110,6 +166,12 @@ else routeContextErrorMessage = null; loadedRaceId = activeRaceId; + if (SelectionContextService.ResolveContext(routeContext, activeRaceId) is { } resolvedContext) + { + SetSelectedContext(resolvedContext); + await SelectionContextService.SaveLastUsedAsync(resolvedContext); + } + await MockDateService.RefreshAsync(); await LoadDataAsync(activeRaceId); } @@ -273,6 +335,122 @@ else tickTask = TickCountdownAsync(timerCts.Token); } + private bool HasExplicitRouteContext() + { + return !string.IsNullOrWhiteSpace(RaceId) + || !string.IsNullOrWhiteSpace(Competition) + || Season.HasValue + || Round.HasValue + || !string.IsNullOrWhiteSpace(RaceSlug); + } + + private async Task RestoreSelectionContextAsync() + { + loadedContextKey = null; + loadedRaceId = null; + drivers = null; + raceConfig = null; + raceMetadata = null; + isReadOnly = false; + routeContextErrorMessage = null; + + var restoredContext = await SelectionContextService.GetRestoredOrDefaultAsync(); + SetSelectedContext(restoredContext); + Navigation.NavigateTo(SelectionContextService.BuildSelectionPath(restoredContext), replace: true); + } + + private async Task OnCompetitionChangedAsync(ChangeEventArgs args) + { + var competitionSlug = args.Value?.ToString(); + if (string.IsNullOrWhiteSpace(competitionSlug)) + { + return; + } + + selectedCompetitionSlug = competitionSlug; + var nextContext = GetSelectedContext() ?? GetSeasonOptions().FirstOrDefault(); + if (nextContext is null) + { + return; + } + + SetSelectedContext(nextContext); + await NavigateToContextAsync(nextContext); + } + + private async Task OnSeasonChangedAsync(ChangeEventArgs args) + { + if (!int.TryParse(args.Value?.ToString(), out var season)) + { + return; + } + + selectedSeason = season; + var nextContext = GetSelectedContext(); + if (nextContext is null) + { + return; + } + + await NavigateToContextAsync(nextContext); + } + + private async Task NavigateToContextAsync(SelectionContextOption context) + { + SetSelectedContext(context); + await SelectionContextService.SaveLastUsedAsync(context); + + var targetPath = SelectionContextService.BuildSelectionPath(context); + var currentPath = new Uri(Navigation.Uri).AbsolutePath; + if (!string.Equals(currentPath, targetPath, StringComparison.OrdinalIgnoreCase)) + { + Navigation.NavigateTo(targetPath); + } + } + + private void SetSelectedContext(SelectionContextOption context) + { + selectedCompetitionSlug = context.CompetitionSlug; + selectedSeason = context.Season; + } + + private SelectionContextOption? GetSelectedContext() + { + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug)) + { + return null; + } + + return availableContexts.FirstOrDefault(context => + string.Equals(context.CompetitionSlug, selectedCompetitionSlug, StringComparison.Ordinal) + && context.Season == selectedSeason); + } + + private IEnumerable GetCompetitionOptions() + { + return availableContexts + .GroupBy(context => context.CompetitionSlug, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(context => context.GetCompetitionLabel(), StringComparer.Ordinal); + } + + private IEnumerable GetSeasonOptions() + { + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug)) + { + return Enumerable.Empty(); + } + + return availableContexts + .Where(context => string.Equals(context.CompetitionSlug, selectedCompetitionSlug, StringComparison.Ordinal)) + .OrderByDescending(context => context.Season); + } + + private string GetSelectedSeasonValue() + { + return selectedSeason?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + private bool TryResolveRouteContext(out RaceSelectionContext routeContext) { var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri); diff --git a/src/F1.Web/Pages/Results.razor b/src/F1.Web/Pages/Results.razor index 4243b94..3f23907 100644 --- a/src/F1.Web/Pages/Results.razor +++ b/src/F1.Web/Pages/Results.razor @@ -1,55 +1,478 @@ @page "/results" +@using F1.Web.Configuration @using F1.Web.Models +@using F1.Web.Services +@using F1.Web.Services.Api +@using Microsoft.AspNetCore.Components.Authorization +@inject ISelectionContextService SelectionContextService +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IMigrationRunsApiService MigrationRunsApi +@inject NavigationManager NavigationManager @inject HttpClient Http -Driver Standings +Competition Leaderboard -

Driver Standings

+

Competition Leaderboard

+ +@if (availableContexts.Count > 0) +{ +
+
+
+
+ + +
+
+ + +
+
+ + @if (isAdmin) + { +
+ + +
+ } +
+
+} + +@if (isAdmin && recentMigrationRuns.Count > 0) +{ +
+
+
+

Recent Migration Runs

+ Open migration workspace +
+
+ @foreach (var run in recentMigrationRuns) + { + + @run.Status + @FormatRunId(run.RunId) + @(run.IsDryRun ? "Dry-run" : "Write") + + } +
+
+
+} + +@if (!string.IsNullOrWhiteSpace(deepLinkRecoveryMessage)) +{ +
@deepLinkRecoveryMessage
+} @if (!string.IsNullOrEmpty(error)) {

@error

} -else if (results == null) +else if (leaderboard == null) { -

Loading race results...

+

Loading leaderboard...

} -else +else if (!string.IsNullOrWhiteSpace(leaderboard.ScoreSourceLabel)) { - - - - - - - - - - @foreach (var result in results) - { - - - - +
+
@leaderboard.ScoreSourceLabel
+
@leaderboard.ScoreSourceHelperText
+
+ + if (!leaderboard.IsDataAvailable) + { +
@leaderboard.EmptyStateMessage
+ } + else + { +
PositionDriverPoints
@result.Position@result.DriverId@result.Points
+ + + + + - } - -
PositionParticipantPoints
+ + + @foreach (var entry in leaderboard.Items) + { + + @entry.Position + + + + @entry.DisplayPoints + + } + + + + @if (participantDetail is not null) + { +
+
+
+
+

@participantDetail.ParticipantName

+
@participantDetail.DisplayName leaderboard detail
+
+ +
+ + @RenderSection(participantDetail.RacePicks, "No race pick data is available for this participant.") + @RenderSection(participantDetail.Preseason, "No preseason question data is available for this participant.") + @RenderSection(participantDetail.H2h, "No H2H question data is available for this participant.") +
+
+ } + } +} +else +{ +
Leaderboard data is not available.
} @code { - private List? results; + [SupplyParameterFromQuery(Name = "competition")] + public string? QueryCompetition { get; set; } + + [SupplyParameterFromQuery(Name = "season")] + public int? QuerySeason { get; set; } + + [SupplyParameterFromQuery(Name = "participant")] + public string? QueryParticipant { get; set; } + + [SupplyParameterFromQuery(Name = "view")] + public string? QueryView { get; set; } + + private CompetitionLeaderboardResponse? leaderboard; + private CompetitionParticipantDetailResponse? participantDetail; private string? error; + private string? deepLinkRecoveryMessage; + private IReadOnlyList availableContexts = []; + private string? selectedCompetitionSlug; + private int? selectedSeason; + private string selectedScoreView = "active"; + private bool isAdmin; + private IReadOnlyList recentMigrationRuns = []; protected override async Task OnInitializedAsync() { + availableContexts = SelectionContextService.GetAvailableContexts(); + var restoredContext = await SelectionContextService.GetRestoredOrDefaultAsync(); + selectedCompetitionSlug = restoredContext.CompetitionSlug; + selectedSeason = restoredContext.Season; + + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + isAdmin = authState.User.IsInRole("Admin"); + + ApplyQueryState(restoredContext); + + if (isAdmin) + { + await LoadRecentMigrationRunsAsync(); + } + + await LoadLeaderboardAsync(); + + if (!string.IsNullOrWhiteSpace(QueryParticipant)) + { + await LoadParticipantDetailAsync(QueryParticipant, syncQuery: false); + } + } + + private async Task LoadRecentMigrationRunsAsync() + { + try + { + var response = await MigrationRunsApi.GetRunsAsync(page: 1, pageSize: 3); + recentMigrationRuns = response.Items + .OrderByDescending(item => item.StartedAtUtc) + .Take(3) + .ToArray(); + } + catch + { + recentMigrationRuns = []; + } + } + + private async Task LoadLeaderboardAsync() + { + error = null; + leaderboard = null; + + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug) || !selectedSeason.HasValue) + { + error = "Leaderboard context is missing."; + return; + } + try { - results = await Http.GetFromJsonAsync>("races/results"); + leaderboard = await Http.GetFromJsonAsync($"races/results?competition={selectedCompetitionSlug}&season={selectedSeason.Value}&view={selectedScoreView}"); + participantDetail = null; } catch (HttpRequestException ex) { error = ex.Message; } } + + private async Task OnCompetitionChangedAsync(ChangeEventArgs args) + { + var competitionSlug = args.Value?.ToString(); + if (string.IsNullOrWhiteSpace(competitionSlug)) + { + return; + } + + selectedCompetitionSlug = competitionSlug; + selectedSeason = GetSeasonOptions().FirstOrDefault()?.Season; + await PersistContextAndReloadAsync(); + } + + private async Task OnSeasonChangedAsync(ChangeEventArgs args) + { + if (!int.TryParse(args.Value?.ToString(), out var season)) + { + return; + } + + selectedSeason = season; + await PersistContextAndReloadAsync(); + } + + private async Task ChangeScoreViewAsync(string scoreView) + { + selectedScoreView = scoreView; + SyncQueryString(); + await LoadLeaderboardAsync(); + } + + private async Task LoadParticipantDetailAsync(string participantName) + { + await LoadParticipantDetailAsync(participantName, syncQuery: true); + } + + private async Task LoadParticipantDetailAsync(string participantName, bool syncQuery) + { + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug) || !selectedSeason.HasValue) + { + return; + } + + try + { + participantDetail = await Http.GetFromJsonAsync($"races/results/participants/{Uri.EscapeDataString(participantName)}?competition={selectedCompetitionSlug}&season={selectedSeason.Value}"); + if (syncQuery) + { + SyncQueryString(); + } + } + catch (HttpRequestException ex) + { + error = ex.Message; + participantDetail = null; + } + } + + private void ClearParticipantDetail() + { + participantDetail = null; + SyncQueryString(); + } + + private async Task PersistContextAndReloadAsync() + { + var context = GetSelectedContext(); + if (context is not null) + { + await SelectionContextService.SaveLastUsedAsync(context); + } + + SyncQueryString(); + await LoadLeaderboardAsync(); + } + + private SelectionContextOption? GetSelectedContext() + { + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug) || !selectedSeason.HasValue) + { + return null; + } + + return availableContexts.FirstOrDefault(context => + string.Equals(context.CompetitionSlug, selectedCompetitionSlug, StringComparison.Ordinal) + && context.Season == selectedSeason.Value); + } + + private IEnumerable GetCompetitionOptions() + { + return availableContexts + .GroupBy(context => context.CompetitionSlug, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(context => context.GetCompetitionLabel(), StringComparer.Ordinal); + } + + private IEnumerable GetSeasonOptions() + { + if (string.IsNullOrWhiteSpace(selectedCompetitionSlug)) + { + return Enumerable.Empty(); + } + + return availableContexts + .Where(context => string.Equals(context.CompetitionSlug, selectedCompetitionSlug, StringComparison.Ordinal)) + .OrderByDescending(context => context.Season); + } + + private string GetSelectedSeasonValue() + { + return selectedSeason?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + + private void ApplyQueryState(SelectionContextOption restoredContext) + { + deepLinkRecoveryMessage = null; + + if (!string.IsNullOrWhiteSpace(QueryCompetition) && QuerySeason.HasValue) + { + var queryContext = availableContexts.FirstOrDefault(context => + string.Equals(context.CompetitionSlug, QueryCompetition, StringComparison.OrdinalIgnoreCase) + && context.Season == QuerySeason.Value); + + if (queryContext is not null) + { + selectedCompetitionSlug = queryContext.CompetitionSlug; + selectedSeason = queryContext.Season; + } + else + { + selectedCompetitionSlug = restoredContext.CompetitionSlug; + selectedSeason = restoredContext.Season; + deepLinkRecoveryMessage = "Requested leaderboard context was unavailable. Restored a valid competition and season instead."; + } + } + + if (isAdmin && string.Equals(QueryView, "recalculated", StringComparison.OrdinalIgnoreCase)) + { + selectedScoreView = "recalculated"; + } + else if (!string.IsNullOrWhiteSpace(QueryView) && !string.Equals(QueryView, "active", StringComparison.OrdinalIgnoreCase)) + { + selectedScoreView = "active"; + deepLinkRecoveryMessage ??= "Requested leaderboard view was invalid. Restored the official score view instead."; + } + } + + private void SyncQueryString() + { + var parameters = new Dictionary + { + ["competition"] = selectedCompetitionSlug, + ["season"] = selectedSeason, + ["participant"] = participantDetail?.ParticipantName, + ["view"] = isAdmin && string.Equals(selectedScoreView, "recalculated", StringComparison.Ordinal) ? selectedScoreView : null + }; + + var targetUri = NavigationManager.GetUriWithQueryParameters(parameters); + NavigationManager.NavigateTo(targetUri, replace: true); + } + + private static string GetRunStatusBadgeClass(string status) + { + return status switch + { + "Completed" => "badge bg-success", + "Started" or "Queued" => "badge bg-warning text-dark", + "Failed" => "badge bg-danger", + _ => "badge bg-secondary" + }; + } + + private static string GetRunModeBadgeClass(bool isDryRun) + { + return isDryRun ? "badge bg-secondary" : "badge bg-dark"; + } + + private static string FormatRunId(Guid runId) + { + var runIdText = runId.ToString(); + return $"{runIdText[..8]}...{runIdText[^6..]}"; + } + + private static string GetMigrationRunLink(Guid runId) + { + return $"admin/migration-runs?run={Uri.EscapeDataString(runId.ToString())}&tab=overview"; + } + + private RenderFragment RenderSection(CompetitionParticipantSectionSummary section, string emptyStateMessage) => builder => + { + builder.OpenElement(0, "section"); + builder.AddAttribute(1, "class", "mb-4"); + builder.OpenElement(2, "h3"); + builder.AddAttribute(3, "class", "h5"); + builder.AddContent(4, section.Title); + builder.CloseElement(); + builder.OpenElement(5, "div"); + builder.AddAttribute(6, "class", "text-muted mb-2"); + builder.AddContent(7, $"Imported total: {section.ImportedTotalPoints} | Recalculated total: {section.RecalculatedTotalPoints}"); + builder.CloseElement(); + + if (section.Items.Count == 0) + { + builder.OpenElement(8, "div"); + builder.AddAttribute(9, "class", "alert alert-light"); + builder.AddContent(10, emptyStateMessage); + builder.CloseElement(); + } + else + { + builder.OpenElement(11, "table"); + builder.AddAttribute(12, "class", "table table-sm"); + builder.OpenElement(13, "thead"); + builder.AddMarkupContent(14, "ItemDescriptionImportedRecalculatedDelta"); + builder.CloseElement(); + builder.OpenElement(15, "tbody"); + var seq = 16; + foreach (var item in section.Items) + { + builder.OpenElement(seq++, "tr"); + builder.OpenElement(seq++, "td"); + builder.AddContent(seq++, item.Label); + builder.CloseElement(); + builder.OpenElement(seq++, "td"); + builder.AddContent(seq++, item.Description); + builder.CloseElement(); + builder.OpenElement(seq++, "td"); + builder.AddContent(seq++, item.ImportedPoints?.ToString() ?? string.Empty); + builder.CloseElement(); + builder.OpenElement(seq++, "td"); + builder.AddContent(seq++, item.CalculatedPoints); + builder.CloseElement(); + builder.OpenElement(seq++, "td"); + builder.AddContent(seq++, item.DeltaPoints); + builder.CloseElement(); + builder.CloseElement(); + } + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + }; } \ No newline at end of file diff --git a/src/F1.Web/Program.cs b/src/F1.Web/Program.cs index 1746312..298c154 100644 --- a/src/F1.Web/Program.cs +++ b/src/F1.Web/Program.cs @@ -1,4 +1,5 @@ using F1.Web; +using F1.Web.Configuration; using F1.Web.Services.Api; using F1.Web.Services; using Microsoft.AspNetCore.Components; @@ -83,6 +84,11 @@ void ConfigureApi(IServiceProvider sp, HttpClient client) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection(PostLoginRoutingOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(SelectionContextOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- Auth Services --- builder.Services.AddAuthorizationCore(); diff --git a/src/F1.Web/Services/PostLoginLandingResolver.cs b/src/F1.Web/Services/PostLoginLandingResolver.cs new file mode 100644 index 0000000..fb7f045 --- /dev/null +++ b/src/F1.Web/Services/PostLoginLandingResolver.cs @@ -0,0 +1,54 @@ +using F1.Web.Configuration; +using F1.Web.Models; +using Microsoft.Extensions.Options; + +namespace F1.Web.Services; + +public interface IPostLoginLandingResolver +{ + string Resolve(User? user); +} + +public sealed class PostLoginLandingResolver(IOptions options) : IPostLoginLandingResolver +{ + private const string DefaultFallbackPath = "/results"; + + public string Resolve(User? user) + { + var routingOptions = options.Value; + var fallbackPath = NormalizePath(routingOptions.FallbackPath, DefaultFallbackPath); + + if (user?.IsAdmin == true) + { + return NormalizePath(routingOptions.AdminLandingPath, fallbackPath); + } + + if (user?.IsAuthenticated == true || !string.IsNullOrWhiteSpace(user?.Email)) + { + return NormalizePath(routingOptions.AuthenticatedUserLandingPath, fallbackPath); + } + + return fallbackPath; + } + + private static string NormalizePath(string? configuredPath, string fallbackPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + return fallbackPath; + } + + var normalizedPath = configuredPath.Trim(); + if (!normalizedPath.StartsWith('/')) + { + normalizedPath = $"/{normalizedPath}"; + } + + if (normalizedPath.Length > 1) + { + normalizedPath = normalizedPath.TrimEnd('/'); + } + + return normalizedPath; + } +} \ No newline at end of file diff --git a/src/F1.Web/Services/SelectionContextService.cs b/src/F1.Web/Services/SelectionContextService.cs new file mode 100644 index 0000000..153cd06 --- /dev/null +++ b/src/F1.Web/Services/SelectionContextService.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using F1.Web.Configuration; +using F1.Web.Models; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; + +namespace F1.Web.Services; + +public sealed record StoredSelectionContext(string CompetitionSlug, int Season); + +public interface ISelectionContextStore +{ + Task GetAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(StoredSelectionContext context, CancellationToken cancellationToken = default); +} + +public interface ISelectionContextService +{ + IReadOnlyList GetAvailableContexts(); + + SelectionContextOption GetDefaultContext(); + + Task GetRestoredOrDefaultAsync(CancellationToken cancellationToken = default); + + SelectionContextOption? ResolveContext(RaceSelectionContext routeContext, string? resolvedRaceId = null); + + string BuildSelectionPath(SelectionContextOption context); + + Task SaveLastUsedAsync(SelectionContextOption context, CancellationToken cancellationToken = default); +} + +public sealed class BrowserSelectionContextStore(IJSRuntime jsRuntime) : ISelectionContextStore +{ + private const string StorageKey = "f1.selection.last-context"; + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + try + { + var rawValue = await jsRuntime.InvokeAsync("localStorage.getItem", cancellationToken, StorageKey); + return string.IsNullOrWhiteSpace(rawValue) + ? null + : JsonSerializer.Deserialize(rawValue); + } + catch (JSException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + public async Task SaveAsync(StoredSelectionContext context, CancellationToken cancellationToken = default) + { + try + { + var rawValue = JsonSerializer.Serialize(context); + await jsRuntime.InvokeVoidAsync("localStorage.setItem", cancellationToken, StorageKey, rawValue); + } + catch (JSException) + { + } + catch (InvalidOperationException) + { + } + } +} + +public sealed class SelectionContextService(IOptions options, ISelectionContextStore store) : ISelectionContextService +{ + private readonly IReadOnlyList availableContexts = BuildContexts(options.Value.Options); + + public IReadOnlyList GetAvailableContexts() => availableContexts; + + public SelectionContextOption GetDefaultContext() + { + return availableContexts.FirstOrDefault(context => + string.Equals(context.CompetitionSlug, SelectionDefaults.DefaultCompetitionSlug, StringComparison.Ordinal) + && context.Season == SelectionDefaults.DefaultSeason) + ?? availableContexts.FirstOrDefault() + ?? new SelectionContextOption + { + CompetitionSlug = SelectionDefaults.DefaultCompetitionSlug, + CompetitionLabel = "Main", + Season = SelectionDefaults.DefaultSeason, + DefaultRound = SelectionDefaults.DefaultRound + }; + } + + public async Task GetRestoredOrDefaultAsync(CancellationToken cancellationToken = default) + { + var storedContext = await store.GetAsync(cancellationToken); + if (storedContext is not null) + { + var restoredContext = TryFind(storedContext.CompetitionSlug, storedContext.Season); + if (restoredContext is not null) + { + return restoredContext; + } + } + + return GetDefaultContext(); + } + + public SelectionContextOption? ResolveContext(RaceSelectionContext routeContext, string? resolvedRaceId = null) + { + if (routeContext.Lookup is not null) + { + return TryFind(routeContext.Lookup.CompetitionSlug, routeContext.Lookup.Season); + } + + var raceId = string.IsNullOrWhiteSpace(resolvedRaceId) ? routeContext.RaceId : resolvedRaceId; + if (string.IsNullOrWhiteSpace(raceId)) + { + return null; + } + + return availableContexts.FirstOrDefault(context => + raceId.StartsWith($"{context.CompetitionSlug}-{context.Season}-", StringComparison.Ordinal)); + } + + public string BuildSelectionPath(SelectionContextOption context) + { + return $"/selection/{context.CompetitionSlug}/{context.Season}/round/{context.DefaultRound}"; + } + + public Task SaveLastUsedAsync(SelectionContextOption context, CancellationToken cancellationToken = default) + { + return store.SaveAsync(new StoredSelectionContext(context.CompetitionSlug, context.Season), cancellationToken); + } + + private SelectionContextOption? TryFind(string competitionSlug, int season) + { + return availableContexts.FirstOrDefault(context => + string.Equals(context.CompetitionSlug, competitionSlug, StringComparison.Ordinal) + && context.Season == season); + } + + private static IReadOnlyList BuildContexts(IEnumerable configuredContexts) + { + return configuredContexts + .Where(context => !string.IsNullOrWhiteSpace(context.CompetitionSlug) && context.Season > 0) + .Select(context => new SelectionContextOption + { + CompetitionSlug = context.CompetitionSlug.Trim().ToLowerInvariant(), + CompetitionLabel = context.GetCompetitionLabel().Trim(), + Season = context.Season, + DefaultRound = context.DefaultRound > 0 ? context.DefaultRound : 1 + }) + .GroupBy(context => context.ContextKey, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(context => context.GetCompetitionLabel(), StringComparer.Ordinal) + .ThenByDescending(context => context.Season) + .ToArray(); + } +} \ No newline at end of file diff --git a/src/F1.Web/wwwroot/appsettings.json b/src/F1.Web/wwwroot/appsettings.json index 8ed9dc1..07eabeb 100644 --- a/src/F1.Web/wwwroot/appsettings.json +++ b/src/F1.Web/wwwroot/appsettings.json @@ -1,3 +1,30 @@ { - "F1Api": null + "F1Api": null, + "PostLoginRouting": { + "AdminLandingPath": "/admin/migration-runs", + "AuthenticatedUserLandingPath": "/results", + "FallbackPath": "/results" + }, + "SelectionContext": { + "Options": [ + { + "CompetitionSlug": "david", + "CompetitionLabel": "David", + "Season": 2025, + "DefaultRound": 1 + }, + { + "CompetitionSlug": "main", + "CompetitionLabel": "Main", + "Season": 2026, + "DefaultRound": 1 + }, + { + "CompetitionSlug": "philip", + "CompetitionLabel": "Philip", + "Season": 2025, + "DefaultRound": 1 + } + ] + } } diff --git a/tests/F1.Api.Tests/Integration/CompetitionLeaderboardRouteAccessIntegrationTests.cs b/tests/F1.Api.Tests/Integration/CompetitionLeaderboardRouteAccessIntegrationTests.cs new file mode 100644 index 0000000..e29b496 --- /dev/null +++ b/tests/F1.Api.Tests/Integration/CompetitionLeaderboardRouteAccessIntegrationTests.cs @@ -0,0 +1,132 @@ +using F1.Api.Dtos; +using F1.Api.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using System.Net; +using System.Text.Encodings.Web; + +namespace F1.Api.Tests.Integration; + +public sealed class CompetitionLeaderboardRouteAccessIntegrationTests : IClassFixture> +{ + private const string TestConnectionString = "Host=localhost;Port=5432;Database=f1_tests;Username=f1;Password=f1"; + + private readonly WebApplicationFactory factory; + + public CompetitionLeaderboardRouteAccessIntegrationTests(WebApplicationFactory factory) + { + Environment.SetEnvironmentVariable("ConnectionStrings__Postgres", TestConnectionString); + this.factory = factory; + } + + [Fact] + public async Task LeaderboardRoute_WhenAnonymous_ShouldReturnUnauthorized() + { + var client = CreateClient(null, null); + + var response = await client.GetAsync("/races/results?competition=philip&season=2025&view=active"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task LeaderboardRoute_WhenAuthenticatedNonAdminRequestsComparison_ShouldReturnForbidden() + { + var client = CreateClient("user@example.com", ["F1 Users"]); + + var response = await client.GetAsync("/races/results?competition=philip&season=2025&view=recalculated"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task LeaderboardRoute_WhenAuthenticatedAdminRequestsComparison_ShouldReturnOk() + { + var client = CreateClient("admin@example.com", ["F1 Admins"]); + + var response = await client.GetAsync("/races/results?competition=philip&season=2025&view=recalculated"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private HttpClient CreateClient(string? mockEmail, string[]? mockGroups) + { + var service = new Mock(); + service + .Setup(x => x.GetLeaderboardAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CompetitionLeaderboardResponseDto( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: true, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntryDto(1, "Alice", 25, 25, 20)])); + + return factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + var values = new Dictionary + { + ["ConnectionStrings:Postgres"] = TestConnectionString, + ["Database:AutoMigrate"] = "false", + ["DevSettings:SimulateCloudflare"] = "true", + ["DevSettings:MockEmail"] = string.Empty, + ["CloudflareAccess:AdminGroups:0"] = "F1 Admins" + }; + + if (!string.IsNullOrWhiteSpace(mockEmail)) + { + values["DevSettings:MockEmail"] = mockEmail; + } + + if (mockGroups is not null) + { + for (var i = 0; i < mockGroups.Length; i++) + { + values[$"DevSettings:MockGroups:{i}"] = mockGroups[i]; + } + } + + config.AddInMemoryCollection(values); + }); + + builder.ConfigureServices(services => + { + services.AddAuthentication("IntegrationTest") + .AddScheme("IntegrationTest", _ => { }); + + services.RemoveAll(); + services.AddScoped(_ => service.Object); + }); + }).CreateClient(); + } + + private sealed class IntegrationTestAuthHandler : AuthenticationHandler + { + public IntegrationTestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + } +} \ No newline at end of file diff --git a/tests/F1.Api.Tests/Integration/RacesEndpointTests.cs b/tests/F1.Api.Tests/Integration/RacesEndpointTests.cs index b946f75..a92eb39 100644 --- a/tests/F1.Api.Tests/Integration/RacesEndpointTests.cs +++ b/tests/F1.Api.Tests/Integration/RacesEndpointTests.cs @@ -1,6 +1,12 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using System.Net; +using System.Net.Http.Json; +using F1.Api.Dtos; +using F1.Api.Services; +using Moq; namespace F1.Api.Tests.Integration { @@ -44,6 +50,23 @@ public async Task GetRacesResults_ShouldReturnUnauthorized_WhenSimulateCloudflar public async Task GetRacesResults_ShouldReturnOk_WhenSimulateCloudflareIsTrue() { // Arrange + var leaderboardService = new Mock(); + leaderboardService + .Setup(service => service.GetLeaderboardAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CompetitionLeaderboardResponseDto( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntryDto(1, "Alice", 25, 25, 20)])); + var client = _factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((context, config) => @@ -55,13 +78,23 @@ public async Task GetRacesResults_ShouldReturnOk_WhenSimulateCloudflareIsTrue() { "DevSettings:SimulateCloudflare", "true" } }); }); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddScoped(_ => leaderboardService.Object); + }); }).CreateClient(); // Act - var response = await client.GetAsync("/races/results"); + var response = await client.GetAsync("/races/results?competition=philip&season=2025&view=active"); // Assert response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal("philip", payload!.CompetitionSlug); + Assert.Equal(2025, payload.Season); } } } diff --git a/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs b/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs new file mode 100644 index 0000000..b06cda0 --- /dev/null +++ b/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs @@ -0,0 +1,234 @@ +using F1.Api.Configuration; +using F1.Api.Services; +using F1.Infrastructure.Data; +using F1.Infrastructure.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; + +namespace F1.Api.Tests.Services; + +public sealed class CompetitionLeaderboardServiceTests +{ + [Fact] + public async Task GetLeaderboardAsync_WhenCompletedMigrationRunExists_ReturnsImportedOfficialOrderWithPreseasonTotals() + { + var options = CreateOptions(); + var runId = Guid.NewGuid(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "data/imports/phil-2025/PhilMigratedSelectionsAndScores.csv", + SourceFileChecksum = "abc123", + Status = "Completed", + StartedAtUtc = DateTime.UtcNow.AddMinutes(-5), + FinishedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportParticipantDeltaSummaries.AddRange( + new MigrationImportParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Charlie", ImportedTotalPoints = 20, CalculatedTotalPoints = 18 }, + new MigrationImportParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Alice", ImportedTotalPoints = 20, CalculatedTotalPoints = 17 }, + new MigrationImportParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Bob", ImportedTotalPoints = 10, CalculatedTotalPoints = 25 }); + + dbContext.MigrationImportPreseasonParticipantDeltaSummaries.AddRange( + new MigrationImportPreseasonParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Alice", ImportedTotalPoints = 5, CalculatedTotalPoints = 4 }, + new MigrationImportPreseasonParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Bob", ImportedTotalPoints = 5, CalculatedTotalPoints = 2 }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = CreateService(serviceContext); + + var result = await service.GetLeaderboardAsync("philip", 2025, "active", isAdmin: false, CancellationToken.None); + + Assert.True(result.IsDataAvailable); + Assert.Equal("Official Source: Imported legacy scores", result.ScoreSourceLabel); + Assert.Equal(["Alice", "Charlie", "Bob"], result.Items.Select(item => item.ParticipantName).ToArray()); + Assert.Equal([25, 20, 15], result.Items.Select(item => item.DisplayPoints).ToArray()); + Assert.Equal([1, 2, 3], result.Items.Select(item => item.Position).ToArray()); + } + + [Fact] + public async Task GetLeaderboardAsync_WhenAdminRequestsRecalculated_ReturnsRecalculatedOrdering() + { + var options = CreateOptions(); + var runId = Guid.NewGuid(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "data/imports/phil-2025/PhilMigratedSelectionsAndScores.csv", + SourceFileChecksum = "abc123", + Status = "Completed", + StartedAtUtc = DateTime.UtcNow.AddMinutes(-5), + FinishedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportParticipantDeltaSummaries.AddRange( + new MigrationImportParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Alice", ImportedTotalPoints = 25, CalculatedTotalPoints = 12 }, + new MigrationImportParticipantDeltaSummaryEntity { ImportRunId = runId, Subject = "Bob", ImportedTotalPoints = 15, CalculatedTotalPoints = 30 }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = CreateService(serviceContext); + + var result = await service.GetLeaderboardAsync("philip", 2025, "recalculated", isAdmin: true, CancellationToken.None); + + Assert.Equal("Compare Mode: Recalculated scores", result.ScoreSourceLabel); + Assert.Equal(["Bob", "Alice"], result.Items.Select(item => item.ParticipantName).ToArray()); + Assert.Equal([30, 12], result.Items.Select(item => item.DisplayPoints).ToArray()); + } + + [Fact] + public async Task GetLeaderboardAsync_WhenContextUnavailable_ReturnsEmptyState() + { + var options = CreateOptions(); + + await using var dbContext = new F1DbContext(options); + var service = CreateService(dbContext); + + var result = await service.GetLeaderboardAsync("main", 2026, "active", isAdmin: false, CancellationToken.None); + + Assert.False(result.IsDataAvailable); + Assert.Equal("Leaderboard data is not available for this competition yet.", result.EmptyStateMessage); + Assert.Empty(result.Items); + } + + [Fact] + public async Task GetParticipantDetailAsync_ReturnsRacePreseasonAndH2hSections() + { + var options = CreateOptions(); + var runId = Guid.NewGuid(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.Competitions.Add(new F1.Core.Models.Competition + { + Id = 42, + Name = "Philip 2025", + Year = 2025, + Description = "Philip 2025 season competition" + }); + + dbContext.QuestionTemplates.Add(new QuestionTemplateEntity + { + Id = 100, + CompetitionId = 42, + Season = 2025, + QuestionId = "h2h-aus-1", + Category = F1.Core.Models.QuestionCategory.H2H, + Prompt = "Who finishes higher?", + Status = F1.Core.Models.QuestionTemplateStatus.Published, + SortOrder = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionScores.Add(new QuestionScoreEntity + { + QuestionTemplateId = 100, + ParticipantId = "Alice", + ImportedPoints = 1, + CalculatedPoints = 2, + DeltaPoints = 1, + RecordedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "data/imports/phil-2025/PhilMigratedSelectionsAndScores.csv", + SourceFileChecksum = "abc123", + Status = "Completed", + StartedAtUtc = DateTime.UtcNow.AddMinutes(-5), + FinishedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportPickDiffs.Add(new MigrationImportPickDiffEntity + { + ImportRunId = runId, + RaceCode = "AUS", + PickType = "1", + Subject = "Alice", + ImportedPoints = 3, + CalculatedPoints = 5, + DeltaPoints = 2, + ReasonCode = "RACE_CORRECT", + Explanation = "Exact pick" + }); + + dbContext.MigrationImportPreseasonQuestionDiffs.Add(new MigrationImportPreseasonQuestionDiffEntity + { + ImportRunId = runId, + RowNumber = 1, + QuestionKey = "WDC", + QuestionText = "Who wins the championship?", + Subject = "Alice", + ImportedPoints = 4, + CalculatedPoints = 6, + DeltaPoints = 2, + ReasonCode = "PRESEASON_CORRECT", + Explanation = "Matched answer" + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = CreateService(serviceContext); + + var result = await service.GetParticipantDetailAsync("philip", 2025, "Alice", CancellationToken.None); + + Assert.Single(result.RacePicks.Items); + Assert.Single(result.Preseason.Items); + Assert.Single(result.H2h.Items); + Assert.Equal(3, result.RacePicks.ImportedTotalPoints); + Assert.Equal(6, result.Preseason.RecalculatedTotalPoints); + Assert.Equal("Who finishes higher?", result.H2h.Items[0].Description); + } + + private static CompetitionLeaderboardService CreateService(F1DbContext dbContext) + { + var options = Options.Create(new CompetitionLeaderboardOptions + { + Contexts = + [ + new CompetitionLeaderboardContextOption + { + CompetitionSlug = "philip", + Season = 2025, + DisplayName = "Philip 2025", + SourceType = "MigrationRun", + ActiveScoreSource = "ImportedLegacy", + MigrationSourcePathContains = "phil-2025" + }, + new CompetitionLeaderboardContextOption + { + CompetitionSlug = "main", + Season = 2026, + DisplayName = "Main 2026", + SourceType = "Unavailable", + UnavailableMessage = "Leaderboard data is not available for this competition yet." + } + ] + }); + + return new CompetitionLeaderboardService(dbContext, options); + } + + private static DbContextOptions CreateOptions() + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + } +} \ No newline at end of file diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs index 355532c..a593206 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs @@ -169,6 +169,50 @@ public async Task ParseAndPersistAsync_WhenPreseasonQuestionRowsExist_PersistsPa Assert.Contains(genericActuals, x => x.ImportedAnswer == "norris | max_verstappen | piastri"); } + [Fact] + public async Task ParseAndPersistAsync_WhenPhilPreseasonPromptContainsBonus_ClassifiesAsPreseason() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + await using var dbContext = new F1DbContext(options); + + SeedCompetition(dbContext, 1, 2025); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = $"/tmp/{MigrationPhil2025CsvContractPolicy.SourceFileName}", + SourceFileChecksum = "abc", + IsDryRun = true, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportRawRows.Add(new MigrationImportRawRowEntity + { + ImportRunId = runId, + RowNumber = 10, + SectionType = "SeasonQuestionPrediction", + RawPayload = "On at least one occasion teammates earn bonus points together?,Y,N,Y,Y,N,Y,Y,Y,N,Y,N" + }); + + await dbContext.SaveChangesAsync(); + + var parser = new MigrationRaceSelectionParser(new TestDbContextFactory(options)); + var result = await parser.ParseAndPersistAsync(runId, CancellationToken.None); + + Assert.True(result.PreseasonAnswerCount > 0); + + var template = await dbContext.QuestionTemplates.SingleAsync(); + Assert.Equal("PRE-010", template.QuestionId); + Assert.Equal(QuestionCategory.Preseason, template.Category); + + var preseasonAnswers = await dbContext.MigrationImportPreseasonAnswers + .Where(x => x.ImportRunId == runId && x.QuestionKey == "PRE-010") + .ToListAsync(); + Assert.NotEmpty(preseasonAnswers); + } + [Fact] public async Task ParseAndPersistAsync_WhenPhilContractAndMultipleSeasonCompetitions_UsesPhilipCompetitionForGenericQuestions() { diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs index ecf68a0..fd2a391 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs @@ -388,6 +388,80 @@ public async Task RecalculateAndPersistAsync_WhenGenericPreseasonQuestionsPresen AssertPreseasonScore(legacyScore, 20, "PRESEASON_EXACT"); } + [Fact] + public async Task RecalculateAndPersistAsync_WhenGenericPreseasonDataIsPartial_PreservesRunDerivedPreseasonScores() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + await using var dbContext = new F1DbContext(options); + + SeedCompetition(dbContext, 1, 2025); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = true, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportPreseasonPolicies.Add(new MigrationImportPreseasonPolicyEntity + { + ImportRunId = runId, + RowNumber = 10, + ColumnIndex = 12, + CellReference = "M10", + RawPointsPerQuestion = "20", + PointsPerQuestion = 20 + }); + + dbContext.MigrationImportPreseasonAnswers.AddRange( + PreseasonAnswer(runId, 10, "PRE-010", "Q10", "ACTUAL", "Y", isActual: true), + PreseasonAnswer(runId, 10, "PRE-010", "Q10", "Dave", "Y")); + + // Seed unrelated generic preseason data to force generic scoring path. + dbContext.QuestionTemplates.Add(new QuestionTemplateEntity + { + Id = 101, + CompetitionId = 1, + Season = 2025, + QuestionId = "PRE-002", + Category = QuestionCategory.Preseason, + Prompt = "Q1", + Status = QuestionTemplateStatus.Published, + SortOrder = 2, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionAnswers.Add(new QuestionAnswerEntity + { + QuestionTemplateId = 101, + ParticipantId = "Philip", + ImportedAnswer = "VER", + RecordedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionActuals.Add(new QuestionActualEntity + { + QuestionTemplateId = 101, + ImportedAnswer = "VER", + RecordedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + + var recalculator = new MigrationScoreRecalculator(new TestDbContextFactory(options)); + await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); + + var daveScore = await dbContext.MigrationImportPreseasonCalculatedScores + .SingleAsync(x => x.ImportRunId == runId && x.QuestionKey == "PRE-010" && x.Subject == "Dave"); + + AssertPreseasonScore(daveScore, 20, "PRESEASON_EXACT"); + } + [Fact] public async Task RecalculateAndPersistAsync_WhenCategoryStrategyMissing_PersistsZeroPointFallbackReason() { diff --git a/tests/F1.Web.Tests/AppTests.cs b/tests/F1.Web.Tests/AppTests.cs new file mode 100644 index 0000000..3722f7f --- /dev/null +++ b/tests/F1.Web.Tests/AppTests.cs @@ -0,0 +1,71 @@ +using Bunit.TestDoubles; +using F1.Web; +using F1.Web.Models; +using F1.Web.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; + +namespace F1.Web.Tests; + +public class AppTests : BunitContext +{ + [Fact] + public void App_ShouldShowAccessDenied_WhenNonAdminNavigatesToAdminLandingRoute() + { + var auth = this.AddAuthorization(); + auth.SetAuthorized("user@example.com"); + + ConfigureCommonServices(); + + var navigation = Services.GetRequiredService(); + navigation.NavigateTo("/admin/migration-runs"); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Access Denied", cut.Markup)); + } + + private void ConfigureCommonServices() + { + var userSession = new Mock(); + userSession.SetupGet(session => session.User).Returns(new User { Email = "user@example.com" }); + userSession.Setup(session => session.InitializeAsync()).Returns(Task.CompletedTask); + + var appInfoService = new Mock(); + appInfoService.Setup(service => service.GetShortVersionAsync(It.IsAny())).ReturnsAsync("abcdef1"); + + Services.AddSingleton(userSession.Object); + Services.AddSingleton(appInfoService.Object); + Services.AddSingleton(new TestHostEnvironment("Test")); + Services.AddSingleton(CreateMockHttpClient()); + Services.AddSingleton(); + } + + private static HttpClient CreateMockHttpClient() + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(request => request.RequestUri!.ToString().Contains("admin/mock-date")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent("{\"mockDate\":null}", System.Text.Encoding.UTF8, "application/json") + }); + + return new HttpClient(handler.Object) { BaseAddress = new Uri("http://localhost") }; + } + + private sealed class TestHostEnvironment(string environment) : IWebAssemblyHostEnvironment + { + public string Environment { get; } = environment; + public string ApplicationName => "F1.Web.Tests"; + public string BaseAddress => "http://localhost/"; + } +} \ No newline at end of file diff --git a/tests/F1.Web.Tests/HomeTests.cs b/tests/F1.Web.Tests/HomeTests.cs new file mode 100644 index 0000000..2506fc5 --- /dev/null +++ b/tests/F1.Web.Tests/HomeTests.cs @@ -0,0 +1,94 @@ +using F1.Web.Configuration; +using F1.Web.Models; +using F1.Web.Pages; +using F1.Web.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace F1.Web.Tests.Pages; + +public class HomeTests : BunitContext +{ + [Fact] + public void Home_ShouldRedirectAdminUsers_ToConfiguredAdminLandingPath() + { + var userSession = CreateUserSession(new User + { + Email = "admin@example.com", + IsAdmin = true, + IsAuthenticated = true + }); + + ConfigureServices(userSession.Object, options => + { + options.AdminLandingPath = "/admin/migration-runs"; + options.AuthenticatedUserLandingPath = "/results"; + options.FallbackPath = "/results"; + }); + + Render(); + + var navigation = Services.GetRequiredService(); + Assert.Equal("http://localhost/admin/migration-runs", navigation.Uri); + } + + [Fact] + public void Home_ShouldRedirectAuthenticatedNonAdminUsers_ToConfiguredUserLandingPath() + { + var userSession = CreateUserSession(new User + { + Email = "user@example.com", + IsAuthenticated = true + }); + + ConfigureServices(userSession.Object, options => + { + options.AdminLandingPath = "/admin/migration-runs"; + options.AuthenticatedUserLandingPath = "/results"; + options.FallbackPath = "/drivers"; + }); + + Render(); + + var navigation = Services.GetRequiredService(); + Assert.Equal("http://localhost/results", navigation.Uri); + } + + [Fact] + public void Home_ShouldFallback_WhenRoleSpecificLandingPathIsBlank() + { + var userSession = CreateUserSession(new User + { + Email = "user@example.com", + IsAuthenticated = true + }); + + ConfigureServices(userSession.Object, options => + { + options.AdminLandingPath = "/admin/migration-runs"; + options.AuthenticatedUserLandingPath = " "; + options.FallbackPath = "/drivers"; + }); + + Render(); + + var navigation = Services.GetRequiredService(); + Assert.Equal("http://localhost/drivers", navigation.Uri); + } + + private void ConfigureServices(IUserSession userSession, Action configureOptions) + { + Services.AddSingleton(userSession); + Services.AddScoped(); + Services.Configure(configureOptions); + } + + private static Mock CreateUserSession(User? user) + { + var userSession = new Mock(); + userSession.SetupGet(session => session.User).Returns(user); + userSession.Setup(session => session.InitializeAsync()).Returns(Task.CompletedTask); + return userSession; + } +} \ No newline at end of file diff --git a/tests/F1.Web.Tests/NavMenuTests.cs b/tests/F1.Web.Tests/NavMenuTests.cs index ce729f3..0ff0b84 100644 --- a/tests/F1.Web.Tests/NavMenuTests.cs +++ b/tests/F1.Web.Tests/NavMenuTests.cs @@ -1,5 +1,4 @@ using Bunit.TestDoubles; -using F1.Web.Configuration; using F1.Web.Layout; namespace F1.Web.Tests.Layout; @@ -31,8 +30,7 @@ public void NavMenu_ShouldShowAuthorizedLinks_WhenAuthenticated() Assert.Contains("Race Selection", cut.Markup); Assert.Contains("Drivers", cut.Markup); - var selectionHref = $"selection/{SelectionDefaults.DefaultCompetitionSlug}/{SelectionDefaults.DefaultSeason}/round/{SelectionDefaults.DefaultRound}"; - Assert.Contains($"href=\"{selectionHref}\"", cut.Markup); + Assert.Contains("href=\"selection\"", cut.Markup); } [Fact] diff --git a/tests/F1.Web.Tests/RaceSelectionTests.cs b/tests/F1.Web.Tests/RaceSelectionTests.cs index b7bb330..665973f 100644 --- a/tests/F1.Web.Tests/RaceSelectionTests.cs +++ b/tests/F1.Web.Tests/RaceSelectionTests.cs @@ -1,3 +1,4 @@ +using AngleSharp.Dom; using F1.Web.Configuration; using F1.Web.Models; using F1.Web.Pages; @@ -12,12 +13,25 @@ namespace F1.Web.Tests.Pages; public class RaceSelectionTests : BunitContext { + private readonly InMemorySelectionContextStore _selectionContextStore = new(); + public RaceSelectionTests() { Services.AddSingleton(new FrozenTimeProvider(new DateTime(2025, 12, 6, 9, 0, 0, DateTimeKind.Utc))); Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(_selectionContextStore); + Services.AddSingleton(); + Services.Configure(options => + { + options.Options = + [ + new SelectionContextOption { CompetitionSlug = "david", CompetitionLabel = "David", Season = 2025, DefaultRound = 1 }, + new SelectionContextOption { CompetitionSlug = "main", CompetitionLabel = "Main", Season = 2026, DefaultRound = 1 }, + new SelectionContextOption { CompetitionSlug = "philip", CompetitionLabel = "Philip", Season = 2025, DefaultRound = 1 } + ]; + }); } private static readonly RaceConfig DefaultRaceConfig = new() @@ -102,7 +116,7 @@ public void RaceSelection_ShouldRenderWarningAndCountdown_WhenLoadedWithNoExisti Assert.Contains("Race Selection", cut.Markup); Assert.Contains("07 Dec 2025 04:30 UTC", cut.Markup); Assert.Contains("Countdown:", cut.Markup); - Assert.Equal(string.Empty, cut.FindAll("select")[0].GetAttribute("value")); + Assert.Equal(string.Empty, GetDriverSelects(cut)[0].GetAttribute("value")); }); } @@ -208,8 +222,8 @@ public void RaceSelection_ShouldRenderLockedState_WhenExistingSelectionIsLocked( cut.WaitForAssertion(() => Assert.Contains("This pre-qualy selection is locked.", cut.Markup)); Assert.True(cut.Find("button[type='submit']").HasAttribute("disabled")); // Snapshot overrides drivers: norris P1, leclerc P2 - Assert.Equal("norris", cut.FindAll("select")[0].GetAttribute("value")); - Assert.Equal("leclerc", cut.FindAll("select")[1].GetAttribute("value")); + Assert.Equal("norris", GetDriverSelects(cut)[0].GetAttribute("value")); + Assert.Equal("leclerc", GetDriverSelects(cut)[1].GetAttribute("value")); Assert.True(cut.Find("#strategy-prequaly").HasAttribute("checked")); } @@ -246,7 +260,7 @@ public void RaceSelection_ShouldSaveSelection_WhenSubmitSucceeds() .ReturnsAsync(savedSelection); var cut = RenderForRace(raceId); - cut.WaitForAssertion(() => Assert.Equal(5, cut.FindAll("select").Count)); + cut.WaitForAssertion(() => Assert.Equal(5, GetDriverSelects(cut).Count)); ChangeSelect(cut, 0, "norris"); ChangeSelect(cut, 1, "leclerc"); @@ -257,11 +271,11 @@ public void RaceSelection_ShouldSaveSelection_WhenSubmitSucceeds() cut.Find("button[type='submit']").Click(); cut.WaitForAssertion(() => Assert.Contains("Selection saved successfully.", cut.Markup)); - Assert.Equal("norris", cut.FindAll("select")[0].GetAttribute("value")); - Assert.Equal("leclerc", cut.FindAll("select")[1].GetAttribute("value")); - Assert.Equal("hamilton", cut.FindAll("select")[2].GetAttribute("value")); - Assert.Equal("piastri", cut.FindAll("select")[3].GetAttribute("value")); - Assert.Equal("verstappen", cut.FindAll("select")[4].GetAttribute("value")); + Assert.Equal("norris", GetDriverSelects(cut)[0].GetAttribute("value")); + Assert.Equal("leclerc", GetDriverSelects(cut)[1].GetAttribute("value")); + Assert.Equal("hamilton", GetDriverSelects(cut)[2].GetAttribute("value")); + Assert.Equal("piastri", GetDriverSelects(cut)[3].GetAttribute("value")); + Assert.Equal("verstappen", GetDriverSelects(cut)[4].GetAttribute("value")); selectionMock.Verify( s => s.SaveMineAsync(raceId, It.IsAny(), It.IsAny()), Times.Once); @@ -280,7 +294,7 @@ public void RaceSelection_ShouldShowApiErrorMessage_WhenSaveFails() .ThrowsAsync(new ApiServiceException(new ApiError(HttpStatusCode.BadRequest, "Exactly 5 unique drivers must be selected."))); var cut = RenderForRace(DefaultRaceConfig.RaceId); - cut.WaitForAssertion(() => Assert.Equal(5, cut.FindAll("select").Count)); + cut.WaitForAssertion(() => Assert.Equal(5, GetDriverSelects(cut).Count)); cut.Find("button[type='submit']").Click(); @@ -301,7 +315,7 @@ public void RaceSelection_ShouldRenderThreeDriverSlots_ForTopThreeCompetition() var cut = RenderForRace("philip-2025-24-abu-dhabi-grand-prix"); - cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("select").Count)); + cut.WaitForAssertion(() => Assert.Equal(3, GetDriverSelects(cut).Count)); Assert.Contains("Top 3 Driver Predictions", cut.Markup); } @@ -317,7 +331,7 @@ public void RaceSelection_ShouldShowFriendlyAuthorizationMessage_WhenSaveIsUnaut .ThrowsAsync(new ApiServiceException(new ApiError(statusCode, $"Saving race selection failed with status code {(int)statusCode}."))); var cut = RenderForRace(DefaultRaceConfig.RaceId); - cut.WaitForAssertion(() => Assert.Equal(5, cut.FindAll("select").Count)); + cut.WaitForAssertion(() => Assert.Equal(5, GetDriverSelects(cut).Count)); cut.Find("button[type='submit']").Click(); @@ -365,8 +379,8 @@ public void RaceSelection_ShouldPopulateControls_FromCurrentSelectionsSnapshot() var cut = RenderForRace(DefaultRaceConfig.RaceId); - cut.WaitForAssertion(() => Assert.Equal("norris", cut.FindAll("select")[0].GetAttribute("value"))); - Assert.Equal("leclerc", cut.FindAll("select")[1].GetAttribute("value")); + cut.WaitForAssertion(() => Assert.Equal("norris", GetDriverSelects(cut)[0].GetAttribute("value"))); + Assert.Equal("leclerc", GetDriverSelects(cut)[1].GetAttribute("value")); Assert.True(cut.Find("#strategy-prequaly").HasAttribute("checked")); } @@ -388,7 +402,7 @@ public void RaceSelection_ShouldRenderLockedState_WhenPastFinalDeadline() cut.WaitForAssertion(() => Assert.Contains("All deadlines have passed.", cut.Markup)); Assert.True(cut.Find("button[type='submit']").HasAttribute("disabled")); - Assert.True(cut.FindAll("select").All(s => s.HasAttribute("disabled"))); + Assert.True(GetDriverSelects(cut).All(s => s.HasAttribute("disabled"))); } [Fact] @@ -484,9 +498,57 @@ public void RaceSelection_ShouldShowControlledError_WhenRaceContextMissing() { RegisterDefaultMocks(); + _selectionContextStore.StoredContext = new StoredSelectionContext("philip", 2025); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.EndsWith("/selection/philip/2025/round/1", Services.GetRequiredService().Uri, StringComparison.Ordinal)); + } + + [Fact] + public void RaceSelection_ShouldFallbackToDefaultContext_WhenStoredContextIsUnavailable() + { + RegisterDefaultMocks(); + + _selectionContextStore.StoredContext = new StoredSelectionContext("removed", 2030); + var cut = Render(); - cut.WaitForAssertion(() => Assert.Contains("Race context is missing.", cut.Markup)); + cut.WaitForAssertion(() => Assert.EndsWith("/selection/main/2026/round/1", Services.GetRequiredService().Uri, StringComparison.Ordinal)); + } + + [Fact] + public void RaceSelection_ShouldPersistAndDisplayCurrentCompetitionContext_WhenRouteIsResolved() + { + var resolved = new RaceContextResolution + { + RaceId = "main-2026-2-australian-grand-prix", + CompetitionSlug = "main", + Season = 2026, + Round = 2, + RaceSlug = "australian-grand-prix" + }; + + RegisterDefaultMocks( + config: new RaceConfig + { + RaceId = resolved.RaceId, + PreQualyDeadlineUtc = new DateTime(2026, 3, 13, 4, 30, 0, DateTimeKind.Utc), + FinalDeadlineUtc = new DateTime(2026, 3, 14, 3, 30, 0, DateTimeKind.Utc), + BetOptions = DefaultRaceConfig.BetOptions + }, + resolvedContext: resolved); + + NavigateTo("selection/main/2026/round/2"); + var cut = Render(parameters => parameters + .Add(p => p.Competition, "main") + .Add(p => p.Season, 2026) + .Add(p => p.Round, 2)); + + cut.WaitForAssertion(() => Assert.Contains("Current context: Main 2026", cut.Markup)); + Assert.Equal("main", cut.Find("#selection-context-competition").GetAttribute("value")); + Assert.Equal("2026", cut.Find("#selection-context-season").GetAttribute("value")); + Assert.Equal(new StoredSelectionContext("main", 2026), _selectionContextStore.StoredContext); } [Fact] @@ -569,7 +631,12 @@ public void RaceSelection_ShouldDisableUnavailableEarlyLockBet_ForNonYasRace() private static void ChangeSelect(IRenderedComponent cut, int index, string value) { - cut.FindAll("select")[index].Change(value); + GetDriverSelects(cut)[index].Change(value); + } + + private static IReadOnlyList GetDriverSelects(IRenderedComponent cut) + { + return cut.FindAll("select.driver-selection"); } private IRenderedComponent RenderForRace(string raceId) @@ -595,6 +662,22 @@ private sealed class TestMockDateService : IMockDateService public Task RefreshAsync() => Task.CompletedTask; public Task SetMockDateAsync(DateTime? dateUtc) => Task.CompletedTask; } + + private sealed class InMemorySelectionContextStore : ISelectionContextStore + { + public StoredSelectionContext? StoredContext { get; set; } + + public Task GetAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(StoredContext); + } + + public Task SaveAsync(StoredSelectionContext context, CancellationToken cancellationToken = default) + { + StoredContext = context; + return Task.CompletedTask; + } + } } diff --git a/tests/F1.Web.Tests/ResultsTests.cs b/tests/F1.Web.Tests/ResultsTests.cs index 405d4b2..9605224 100644 --- a/tests/F1.Web.Tests/ResultsTests.cs +++ b/tests/F1.Web.Tests/ResultsTests.cs @@ -1,5 +1,10 @@ +using Bunit.TestDoubles; +using F1.Web.Configuration; using F1.Web.Models; using F1.Web.Pages; +using F1.Web.Services; +using F1.Web.Services.Api; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Moq; using Moq.Protected; @@ -12,15 +17,52 @@ public class ResultsTests : BunitContext { private readonly Mock _handlerMock; private readonly HttpClient _httpClient; + private readonly InMemorySelectionContextStore _selectionContextStore = new(); + private readonly Mock _migrationRunsApiMock = new(); public ResultsTests() { + var auth = this.AddAuthorization(); + auth.SetAuthorized("user@example.com"); + _handlerMock = new Mock(); _httpClient = new HttpClient(_handlerMock.Object) { BaseAddress = new Uri("http://localhost") }; Services.AddSingleton(_httpClient); + _migrationRunsApiMock + .Setup(api => api.GetRunsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AdminMigrationRunListResponse(1, 3, 1, + [ + new AdminMigrationRunListItem( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "Completed", + true, + "/tmp/import.csv", + "abc123", + new DateTime(2026, 7, 7, 10, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 7, 7, 10, 5, 0, DateTimeKind.Utc), + 10, + 0, + 0, + 0, + 0, + 0, + null) + ])); + Services.AddSingleton(_migrationRunsApiMock.Object); + Services.AddSingleton(_selectionContextStore); + Services.AddSingleton(); + Services.Configure(options => + { + options.Options = + [ + new SelectionContextOption { CompetitionSlug = "main", CompetitionLabel = "Main", Season = 2026, DefaultRound = 1 }, + new SelectionContextOption { CompetitionSlug = "philip", CompetitionLabel = "Philip", Season = 2025, DefaultRound = 1 } + ]; + }); + _selectionContextStore.StoredContext = new StoredSelectionContext("philip", 2025); } [Fact] @@ -40,7 +82,7 @@ public void Results_ShouldRenderLoading_WhenDataIsBeingFetched() var cut = Render(); // Assert - Assert.Contains("Loading race results...", cut.Markup); + Assert.Contains("Loading leaderboard...", cut.Markup); // Cleanup tcs.SetResult(new HttpResponseMessage @@ -54,11 +96,23 @@ public void Results_ShouldRenderLoading_WhenDataIsBeingFetched() public void Results_ShouldRenderTable_WhenApiReturnsData() { // Arrange - var mockResults = new List - { - new() { DriverId = "verstappen", Position = 1, Points = 25 }, - new() { DriverId = "norris", Position = 2, Points = 18 } - }; + var mockResults = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: + [ + new CompetitionLeaderboardEntry(1, "Alice", 25, 25, 20), + new CompetitionLeaderboardEntry(2, "Bob", 18, 18, 24) + ]); var response = new HttpResponseMessage { @@ -81,19 +135,32 @@ public void Results_ShouldRenderTable_WhenApiReturnsData() cut.WaitForState(() => cut.FindAll("tbody tr").Count > 0); var rows = cut.FindAll("tbody tr"); Assert.Equal(2, rows.Count); - Assert.Contains("verstappen", rows[0].InnerHtml); - Assert.Contains("norris", rows[1].InnerHtml); + Assert.Contains("Alice", rows[0].InnerHtml); + Assert.Contains("Bob", rows[1].InnerHtml); + Assert.Contains("Official Source: Imported legacy scores", cut.Markup); } [Fact] public void Results_ShouldNotBeEmpty_WhenApiReturnsData() { // Arrange - var mockResults = new List - { - new() { DriverId = "verstappen", Position = 1, Points = 25 }, - new() { DriverId = "norris", Position = 2, Points = 18 } - }; + var mockResults = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: + [ + new CompetitionLeaderboardEntry(1, "Alice", 25, 25, 20), + new CompetitionLeaderboardEntry(2, "Bob", 18, 18, 24) + ]); var response = new HttpResponseMessage { @@ -139,14 +206,21 @@ public void Results_ShouldShowError_WhenApiCallFails() } [Fact] - public void Results_ShouldCallOnInitializedAsync_WhenComponentIsRendered() + public void Results_ShouldShowEmptyState_WhenLeaderboardIsUnavailable() { - // Arrange - var mockResults = new List - { - new() { DriverId = "verstappen", Position = 1, Points = 25 }, - new() { DriverId = "norris", Position = 2, Points = 18 } - }; + var mockResults = new CompetitionLeaderboardResponse( + CompetitionSlug: "main", + Season: 2026, + DisplayName: "Main 2026", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: false, + EmptyStateMessage: "Leaderboard data is not available for this competition yet.", + SourceRunId: null, + Items: []); var response = new HttpResponseMessage { @@ -165,11 +239,220 @@ public void Results_ShouldCallOnInitializedAsync_WhenComponentIsRendered() // Act var cut = Render(); - // Assert - _handlerMock.Protected().Verify( - "SendAsync", - Times.Once(), - ItExpr.IsAny(), - ItExpr.IsAny()); + cut.WaitForAssertion(() => Assert.Contains("Leaderboard data is not available for this competition yet.", cut.Markup)); + } + + [Fact] + public void Results_ShouldShowAdminCompareToggle_WhenAdmin() + { + var auth = this.AddAuthorization(); + auth.SetAuthorized("admin@example.com"); + auth.SetRoles("Admin"); + + var mockResults = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: true, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntry(1, "Alice", 25, 25, 20)]); + + _handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(mockResults)) + }); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Official", cut.Markup)); + Assert.Contains("Recalculated", cut.Markup); + Assert.Contains("Recent Migration Runs", cut.Markup); + Assert.Contains("Completed", cut.Markup); + } + + [Fact] + public void Results_ShouldRenderParticipantDrilldown_WhenParticipantIsSelected() + { + var leaderboardResponse = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntry(1, "Alice", 25, 25, 20)]); + + var detailResponse = new CompetitionParticipantDetailResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ParticipantName: "Alice", + RacePicks: new CompetitionParticipantSectionSummary( + "Race Picks", + 3, + 5, + [new CompetitionParticipantDetailItem("AUS", "1", 3, 5, 2, "RACE_CORRECT", "Exact pick")]), + Preseason: new CompetitionParticipantSectionSummary( + "Preseason Questions", + 4, + 6, + [new CompetitionParticipantDetailItem("WDC", "Who wins the championship?", 4, 6, 2, "PRESEASON_CORRECT", "Matched answer")]), + H2h: new CompetitionParticipantSectionSummary( + "H2H Questions", + 0, + 0, + [])); + + _handlerMock + .Protected() + .SetupSequence>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(leaderboardResponse)) + }) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(detailResponse)) + }); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Alice", cut.Markup)); + cut.Find("button.participant-drilldown").Click(); + + cut.WaitForAssertion(() => Assert.Contains("Race Picks", cut.Markup)); + Assert.Contains("Preseason Questions", cut.Markup); + Assert.Contains("No H2H question data is available for this participant.", cut.Markup); + } + + [Fact] + public void Results_ShouldRestoreParticipantAndView_FromQueryString() + { + var auth = this.AddAuthorization(); + auth.SetAuthorized("admin@example.com"); + auth.SetRoles("Admin"); + + var leaderboardResponse = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "recalculated", + ScoreSourceLabel: "Compare Mode: Recalculated scores", + ScoreSourceHelperText: "Admin compare mode is showing recalculated totals.", + IsComparisonAvailable: true, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntry(1, "Alice", 20, 25, 20)]); + + var detailResponse = new CompetitionParticipantDetailResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ParticipantName: "Alice", + RacePicks: new CompetitionParticipantSectionSummary("Race Picks", 0, 0, []), + Preseason: new CompetitionParticipantSectionSummary("Preseason Questions", 0, 0, []), + H2h: new CompetitionParticipantSectionSummary("H2H Questions", 0, 0, [])); + + _handlerMock + .Protected() + .SetupSequence>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(leaderboardResponse)) + }) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(detailResponse)) + }); + + Services.GetRequiredService().NavigateTo("results?competition=philip&season=2025&participant=Alice&view=recalculated"); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Compare Mode: Recalculated scores", cut.Markup)); + Assert.Contains("Alice", cut.Markup); + Assert.Contains("participant detail", cut.Markup, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Results_ShouldShowRecoveryMessage_WhenQueryContextIsInvalid() + { + var leaderboardResponse = new CompetitionLeaderboardResponse( + CompetitionSlug: "philip", + Season: 2025, + DisplayName: "Philip 2025", + ActiveScoreSource: "ImportedLegacy", + ScoreView: "active", + ScoreSourceLabel: "Official Source: Imported legacy scores", + ScoreSourceHelperText: "Official standings use imported legacy totals.", + IsComparisonAvailable: false, + IsDataAvailable: true, + EmptyStateMessage: null, + SourceRunId: Guid.NewGuid(), + Items: [new CompetitionLeaderboardEntry(1, "Alice", 25, 25, 20)]); + + _handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(leaderboardResponse)) + }); + + Services.GetRequiredService().NavigateTo("results?competition=unknown&season=2030"); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Requested leaderboard context was unavailable.", cut.Markup)); + } + + private sealed class InMemorySelectionContextStore : ISelectionContextStore + { + public StoredSelectionContext? StoredContext { get; set; } + + public Task GetAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(StoredContext); + } + + public Task SaveAsync(StoredSelectionContext context, CancellationToken cancellationToken = default) + { + StoredContext = context; + return Task.CompletedTask; + } } }