diff --git a/README.md b/README.md index 683115d..7821497 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,15 @@ By default, a test account is created with administrator permissions with the fo Username `test@gmail.com` Password `hunter2` + +# Contributing + +Please make sure all tests pass before submitting a new pull request. + +## Adding a Migration + +New migrations can be added to the database by: + +1. Installing the dotnet ef tools via `dotnet tool install --global dotnet-ef` +2. Running the following command with a command line while inside the `/src/Server` folder + `dotnet ef migrations add {YourMigrationName} -o ./Infrastructure/Migrations --context ApplicationDbContext --project DynamoLeagueBlazor.Server.csproj \ No newline at end of file diff --git a/src/Client/Features/Admin/StartSeason.razor b/src/Client/Features/Admin/StartSeason.razor index b8e38dc..c327103 100644 --- a/src/Client/Features/Admin/StartSeason.razor +++ b/src/Client/Features/Admin/StartSeason.razor @@ -5,7 +5,16 @@ - Clicking the below button will begin a new season - all players who are eligble to be free agents will be set to a free agent status. Proceed with caution. + Clicking the below button will begin a new season. Beginning a new season includes +

+
diff --git a/src/Client/Features/Dashboard/Dashboard.razor b/src/Client/Features/Dashboard/Dashboard.razor index 73e9b48..c89cdf5 100644 --- a/src/Client/Features/Dashboard/Dashboard.razor +++ b/src/Client/Features/Dashboard/Dashboard.razor @@ -1,5 +1,4 @@ @page "/" -@using DynamoLeagueBlazor.Client.Features.Dashboard.TopOffenders @_title @@ -9,4 +8,7 @@ <MudItem xs=12> <TopOffenders /> </MudItem> + <MudItem xs=12> + <TopTeamFines /> + </MudItem> </MudGrid> \ No newline at end of file diff --git a/src/Client/Features/Dashboard/Shared/RankedCard.razor b/src/Client/Features/Dashboard/Shared/RankedCard.razor new file mode 100644 index 0000000..5c806e1 --- /dev/null +++ b/src/Client/Features/Dashboard/Shared/RankedCard.razor @@ -0,0 +1,20 @@ +<MudCard Style=@BackgroundColor Class="ma-1"> + <MudCardHeader> + <CardHeaderAvatar> + <MudAvatar Image=@RankedItem.ImageUrl></MudAvatar> + </CardHeaderAvatar> + <CardHeaderContent> + <MudText Typo="NameTypo">@RankedItem.Name</MudText> + </CardHeaderContent> + <CardHeaderActions> + <MudText Typo="AmountTypo" Color=Color.Success>@RankedItem.Amount</MudText> + </CardHeaderActions> + </MudCardHeader> +</MudCard> + +@code { + [Parameter, EditorRequired] public IRankedItem RankedItem { get; set; } = null!; + [Parameter, EditorRequired] public Typo NameTypo { get; set; } + [Parameter, EditorRequired] public Typo AmountTypo { get; set; } + [Parameter] public string? BackgroundColor { get; set; } +} diff --git a/src/Client/Features/Dashboard/Shared/RankedList.razor b/src/Client/Features/Dashboard/Shared/RankedList.razor new file mode 100644 index 0000000..de0419b --- /dev/null +++ b/src/Client/Features/Dashboard/Shared/RankedList.razor @@ -0,0 +1,30 @@ +@if(RankedItems.Count() > 2) +{ + <MudItem xs=12> + @{ + var first =RankedItems.First(); + var second = RankedItems.Skip(1).First(); + var third = RankedItems.Skip(2).First(); + } + <RankedCard BackgroundColor="background: rgba(167, 149, 70, 1);" RankedItem="@first" NameTypo=Typo.h4 AmountTypo=Typo.h4/> + <RankedCard BackgroundColor="background: rgba(147, 147, 147, 1);" RankedItem="@second" NameTypo=Typo.h5 AmountTypo=Typo.h5/> + <RankedCard BackgroundColor="background: rgba(135, 106, 63, 1);" RankedItem="@third" NameTypo=Typo.h5 AmountTypo=Typo.h5/> + </MudItem> + @foreach(var rankedItem in RankedItems.Skip(3)) + { + <MudItem xs=12 sm=4 Class="mx-auto"> + <RankedCard RankedItem="@rankedItem" NameTypo=Typo.body1 AmountTypo=Typo.h6 /> + </MudItem> + } +} +else +{ + <MudAlert Severity=Severity.Normal> + @NotEnoughItemsContent + </MudAlert> +} + +@code { + [Parameter] public IEnumerable<IRankedItem> RankedItems { get; set; } = Array.Empty<IRankedItem>(); + [Parameter, EditorRequired] public RenderFragment NotEnoughItemsContent { get; set; } = null!; +} diff --git a/src/Client/Features/Dashboard/TopOffenders.razor b/src/Client/Features/Dashboard/TopOffenders.razor new file mode 100644 index 0000000..79649f9 --- /dev/null +++ b/src/Client/Features/Dashboard/TopOffenders.razor @@ -0,0 +1,38 @@ +@if(_result is null) +{ + <MudSkeleton SkeletonType="SkeletonType.Text" Width="100%" Height="50px"/> + @for(int i = 0; i < 3; i++) + { + <MudItem xs=12> + <MudCard Class="ma-1"> + <MudCardHeader> + <CardHeaderAvatar> + <MudSkeleton SkeletonType=SkeletonType.Circle Width="50px" Height="50px"></MudSkeleton> + </CardHeaderAvatar> + <CardHeaderContent> + <MudSkeleton SkeletonType=SkeletonType.Text Width="200px" Height="40px"></MudSkeleton> + </CardHeaderContent> + <CardHeaderActions> + <MudSkeleton SkeletonType=SkeletonType.Text Width="40px" Height="40px"></MudSkeleton> + </CardHeaderActions> + </MudCardHeader> + </MudCard> + </MudItem> + } +} +else +{ + <MudGrid> + <MudItem xs=12> + <PageHeader> + Top Offenders + </PageHeader> + </MudItem> + + <RankedList RankedItems="_result.Players"> + <NotEnoughItemsContent> + There aren't enough players with fines quite yet. Check back later! + </NotEnoughItemsContent> + </RankedList> + </MudGrid> +} \ No newline at end of file diff --git a/src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor.cs b/src/Client/Features/Dashboard/TopOffenders.razor.cs similarity index 92% rename from src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor.cs rename to src/Client/Features/Dashboard/TopOffenders.razor.cs index 14e5d1b..9043ea2 100644 --- a/src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor.cs +++ b/src/Client/Features/Dashboard/TopOffenders.razor.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Authentication; using System.Net.Http.Json; -namespace DynamoLeagueBlazor.Client.Features.Dashboard.TopOffenders; +namespace DynamoLeagueBlazor.Client.Features.Dashboard; public sealed partial class TopOffenders : IDisposable { diff --git a/src/Client/Features/Dashboard/TopOffenders/PlayerCard.razor b/src/Client/Features/Dashboard/TopOffenders/PlayerCard.razor deleted file mode 100644 index 128051f..0000000 --- a/src/Client/Features/Dashboard/TopOffenders/PlayerCard.razor +++ /dev/null @@ -1,22 +0,0 @@ -@using static DynamoLeagueBlazor.Shared.Features.Dashboard.TopOffendersResult - -<MudCard Style=@BackgroundColor Class="ma-1"> - <MudCardHeader> - <CardHeaderAvatar> - <MudAvatar Image=@Player.HeadShotUrl></MudAvatar> - </CardHeaderAvatar> - <CardHeaderContent> - <MudText Typo="NameTypo">@Player.Name</MudText> - </CardHeaderContent> - <CardHeaderActions> - <MudText Typo="FineAmountTypo" Color=Color.Success>@Player.TotalFineAmount</MudText> - </CardHeaderActions> - </MudCardHeader> -</MudCard> - -@code { - [Parameter, EditorRequired] public PlayerItem Player { get; set; } = null!; - [Parameter, EditorRequired] public Typo NameTypo { get; set; } - [Parameter, EditorRequired] public Typo FineAmountTypo { get; set; } - [Parameter] public string? BackgroundColor { get; set; } -} diff --git a/src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor b/src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor deleted file mode 100644 index 027db93..0000000 --- a/src/Client/Features/Dashboard/TopOffenders/TopOffenders.razor +++ /dev/null @@ -1,58 +0,0 @@ -@if(_result is null) -{ - <MudSkeleton SkeletonType="SkeletonType.Text" Width="100%" Height="50px"/> - @for(int i = 0; i < 3; i++) - { - <MudItem xs=12> - <MudCard Class="ma-1"> - <MudCardHeader> - <CardHeaderAvatar> - <MudSkeleton SkeletonType=SkeletonType.Circle Width="50px" Height="50px"></MudSkeleton> - </CardHeaderAvatar> - <CardHeaderContent> - <MudSkeleton SkeletonType=SkeletonType.Text Width="200px" Height="40px"></MudSkeleton> - </CardHeaderContent> - <CardHeaderActions> - <MudSkeleton SkeletonType=SkeletonType.Text Width="40px" Height="40px"></MudSkeleton> - </CardHeaderActions> - </MudCardHeader> - </MudCard> - </MudItem> - } -} -else -{ - <MudGrid> - <MudItem xs=12> - <PageHeader> - Top Offenders - </PageHeader> - </MudItem> - - @if(_result.Players.Count() > 2) - { - <MudItem xs=12> - @{ - var firstPlayer = _result.Players.First(); - var secondPlayer = _result.Players.Skip(1).First(); - var thirdPlayer = _result.Players.Skip(2).First(); - } - <PlayerCard BackgroundColor="background: rgba(167, 149, 70, 1);" Player="@firstPlayer" NameTypo=Typo.h4 FineAmountTypo=Typo.h4/> - <PlayerCard BackgroundColor="background: rgba(147, 147, 147, 1);" Player="@secondPlayer" NameTypo=Typo.h5 FineAmountTypo=Typo.h5/> - <PlayerCard BackgroundColor="background: rgba(135, 106, 63, 1);" Player="@thirdPlayer" NameTypo=Typo.h5 FineAmountTypo=Typo.h5/> - </MudItem> - @foreach(var player in _result.Players.Skip(3)) - { - <MudItem xs=12 sm=4> - <PlayerCard Player="@player" NameTypo=Typo.body1 FineAmountTypo=Typo.h6/> - </MudItem> - } - } - else - { - <MudAlert Severity=Severity.Normal> - There aren't enough players with fines quite yet. Check back later! - </MudAlert> - } - </MudGrid> -} diff --git a/src/Client/Features/Dashboard/TopTeamFines.razor b/src/Client/Features/Dashboard/TopTeamFines.razor new file mode 100644 index 0000000..cfb4c88 --- /dev/null +++ b/src/Client/Features/Dashboard/TopTeamFines.razor @@ -0,0 +1,38 @@ +@if(_result is null) +{ + <MudSkeleton SkeletonType="SkeletonType.Text" Width="100%" Height="50px"/> + @for(int i = 0; i < 3; i++) + { + <MudItem xs=12> + <MudCard Class="ma-1"> + <MudCardHeader> + <CardHeaderAvatar> + <MudSkeleton SkeletonType=SkeletonType.Circle Width="50px" Height="50px"></MudSkeleton> + </CardHeaderAvatar> + <CardHeaderContent> + <MudSkeleton SkeletonType=SkeletonType.Text Width="200px" Height="40px"></MudSkeleton> + </CardHeaderContent> + <CardHeaderActions> + <MudSkeleton SkeletonType=SkeletonType.Text Width="40px" Height="40px"></MudSkeleton> + </CardHeaderActions> + </MudCardHeader> + </MudCard> + </MudItem> + } +} +else +{ + <MudGrid> + <MudItem xs=12> + <PageHeader> + Top Team Fines + </PageHeader> + </MudItem> + + <RankedList RankedItems="_result.Teams"> + <NotEnoughItemsContent> + There aren't enough teams with fines quite yet. Check back later! + </NotEnoughItemsContent> + </RankedList> + </MudGrid> +} \ No newline at end of file diff --git a/src/Client/Features/Dashboard/TopTeamFines.razor.cs b/src/Client/Features/Dashboard/TopTeamFines.razor.cs new file mode 100644 index 0000000..87fbe7d --- /dev/null +++ b/src/Client/Features/Dashboard/TopTeamFines.razor.cs @@ -0,0 +1,32 @@ +using DynamoLeagueBlazor.Shared.Features.Dashboard; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; +using System.Net.Http.Json; + +namespace DynamoLeagueBlazor.Client.Features.Dashboard; + +public sealed partial class TopTeamFines : IDisposable +{ + [Inject] private HttpClient HttpClient { get; set; } = null!; + + private TopTeamFinesResult? _result; + private readonly CancellationTokenSource _cts = new(); + + protected override async Task OnInitializedAsync() + { + try + { + _result = await HttpClient.GetFromJsonAsync<TopTeamFinesResult>(TopTeamFinesRouteFactory.Uri, _cts.Token); + } + catch (AccessTokenNotAvailableException exception) + { + exception.Redirect(); + } + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Client/Features/Dashboard/_Imports.razor b/src/Client/Features/Dashboard/_Imports.razor new file mode 100644 index 0000000..e1c236a --- /dev/null +++ b/src/Client/Features/Dashboard/_Imports.razor @@ -0,0 +1,2 @@ +@using DynamoLeagueBlazor.Client.Features.Dashboard.Shared +@using DynamoLeagueBlazor.Shared.Features.Dashboard.Shared diff --git a/src/Client/Features/Fines/List.razor b/src/Client/Features/Fines/List.razor index 4e8a218..645439f 100644 --- a/src/Client/Features/Fines/List.razor +++ b/src/Client/Features/Fines/List.razor @@ -21,6 +21,7 @@ </Authorized> </AuthorizeView> <MudTh><MudTableSortLabel SortBy="new Func<FineItem,object>(x=> x.PlayerName)">Name</MudTableSortLabel></MudTh> + <MudTh><MudTableSortLabel SortBy="new Func<FineItem,object>(x=> x.TeamName)">Team</MudTableSortLabel></MudTh> <MudTh>Reason</MudTh> <MudTh><MudTableSortLabel SortBy="new Func<FineItem,object>(x=> x.Amount)">Amount</MudTableSortLabel></MudTh> <MudTh><MudTableSortLabel SortBy="new Func<FineItem,object>(x=> x.Status)">Status</MudTableSortLabel></MudTh> @@ -34,7 +35,10 @@ </Authorized> </AuthorizeView> <MudTd DataLabel=Name> - <PlayerNameWithHeadShot Name="@fineItem.PlayerName" HeadShotUrl="@fineItem.PlayerHeadShotUrl" /> + <NameWithImage Name="@fineItem.PlayerName" ImageUrl="@fineItem.PlayerHeadShotUrl" /> + </MudTd> + <MudTd DataLabel=Team> + <NameWithImage Name="@fineItem.TeamName" ImageUrl="@fineItem.TeamLogoUrl" /> </MudTd> <MudTd DataLabel=Reason>@fineItem.Reason</MudTd> <MudTd DataLabel=Amount>@fineItem.Amount</MudTd> diff --git a/src/Client/Features/Fines/List.razor.cs b/src/Client/Features/Fines/List.razor.cs index 2b15112..9f8d701 100644 --- a/src/Client/Features/Fines/List.razor.cs +++ b/src/Client/Features/Fines/List.razor.cs @@ -56,7 +56,11 @@ private async void OpenManageFineDialog(int fineId) var result = await dialog.Result; - if (!result.Cancelled) await LoadDataAsync(); + if (!result.Cancelled) + { + await LoadDataAsync(); + StateHasChanged(); + } } public void Dispose() diff --git a/src/Client/Features/FreeAgents/List.razor b/src/Client/Features/FreeAgents/List.razor index b29659d..37ef0c0 100644 --- a/src/Client/Features/FreeAgents/List.razor +++ b/src/Client/Features/FreeAgents/List.razor @@ -36,7 +36,7 @@ <MudIconButton Icon="@Icons.Material.Filled.AttachMoney" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small" Class="ma-2" Link="@href"/> </MudTd> <MudTd DataLabel="Player Name"> - <PlayerNameWithHeadShot Name="@context.Name" HeadShotUrl="@context.HeadShotUrl" /> + <NameWithImage Name="@context.Name" ImageUrl="@context.HeadShotUrl" /> </MudTd> <MudTd DataLabel="Player Position">@context.Position</MudTd> <MudTd DataLabel="Player Team">@context.Team</MudTd> diff --git a/src/Client/Features/OfferMatching/List.razor b/src/Client/Features/OfferMatching/List.razor index ef82d9e..c162c8e 100644 --- a/src/Client/Features/OfferMatching/List.razor +++ b/src/Client/Features/OfferMatching/List.razor @@ -23,7 +23,7 @@ <MudIconButton Icon="@Icons.Filled.Handshake" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small" Class="ma-2" /> </MudTd> <MudTd DataLabel="Player Name"> - <PlayerNameWithHeadShot Name="@context.Name" HeadShotUrl="@context.HeadShotUrl" /> + <NameWithImage Name="@context.Name" ImageUrl="@context.HeadShotUrl" /> </MudTd> <MudTd DataLabel="Player Position">@context.Position</MudTd> <MudTd DataLabel="Offering Team">@context.OfferingTeam</MudTd> diff --git a/src/Client/Features/Players/List.razor b/src/Client/Features/Players/List.razor index 81215a3..7c9f801 100644 --- a/src/Client/Features/Players/List.razor +++ b/src/Client/Features/Players/List.razor @@ -40,7 +40,7 @@ <MudIconButton Icon="@Icons.Material.Filled.MoneyOff" Color="Color.Error" Variant="Variant.Outlined" Size="Size.Small" Class="ma-2" OnClick="(e) => OpenAddFineDialog(context.Id)" /> </MudTd> <MudTd DataLabel=@nameof(PlayerItem.Name)> - <PlayerNameWithHeadShot Name="@context.Name" HeadShotUrl="@context.HeadShotUrl" /> + <NameWithImage Name="@context.Name" ImageUrl="@context.HeadShotUrl" /> </MudTd> <MudTd DataLabel=@nameof(PlayerItem.Position)>@context.Position</MudTd> <MudTd DataLabel=@nameof(PlayerItem.Team)>@context.Team</MudTd> diff --git a/src/Client/Features/Teams/List.razor b/src/Client/Features/Teams/List.razor index 1d68b6d..e043d4a 100644 --- a/src/Client/Features/Teams/List.razor +++ b/src/Client/Features/Teams/List.razor @@ -41,7 +41,7 @@ else @foreach(var team in _result.Teams) { var detailHref = $"/teams/{team.Id}"; - <MudItem lg=4 xs=12 Class=grow> + <MudItem lg=4 xs=12 Class="grow mx-auto"> <a href=@detailHref> <MudCard> <MudCardHeader Class="mud-card-header-override"> diff --git a/src/Client/Shared/Components/NameWithImage.razor b/src/Client/Shared/Components/NameWithImage.razor new file mode 100644 index 0000000..4a30093 --- /dev/null +++ b/src/Client/Shared/Components/NameWithImage.razor @@ -0,0 +1,9 @@ +<div class="d-flex align-center"> + <MudAvatar Image=@ImageUrl Class="mr-1"></MudAvatar> + @Name +</div> + +@code { + [Parameter, EditorRequired] public string Name { get; set; } = string.Empty; + [Parameter, EditorRequired] public string ImageUrl { get; set; } = string.Empty; +} diff --git a/src/Client/Shared/Components/PlayerNameWithHeadShot.razor b/src/Client/Shared/Components/PlayerNameWithHeadShot.razor deleted file mode 100644 index d3c5d4c..0000000 --- a/src/Client/Shared/Components/PlayerNameWithHeadShot.razor +++ /dev/null @@ -1,9 +0,0 @@ -<div class="d-flex align-center"> - <MudAvatar Image=@HeadShotUrl Class="mr-1"></MudAvatar> - @Name -</div> - -@code { - [Parameter, EditorRequired] public string Name { get; set; } = string.Empty; - [Parameter, EditorRequired] public string HeadShotUrl { get; set; } = string.Empty; -} diff --git a/src/Server/Features/Admin/StartSeason.cs b/src/Server/Features/Admin/StartSeason.cs index 54ac5b0..6bb6e8b 100644 --- a/src/Server/Features/Admin/StartSeason.cs +++ b/src/Server/Features/Admin/StartSeason.cs @@ -82,6 +82,10 @@ public async Task<Unit> Handle(StartSeasonCommand request, CancellationToken can player.SetToFreeAgent(date); } + var startOfTheCurrentYear = new DateTime(DateTime.Today.Year, 1, 1); + var fines = _dbContext.Fines.Where(f => f.CreatedOn < startOfTheCurrentYear); + _dbContext.Fines.RemoveRange(fines); + await _dbContext.SaveChangesAsync(cancellationToken); return Unit.Value; diff --git a/src/Server/Features/Dashboard/TopOffenders.cs b/src/Server/Features/Dashboard/TopOffenders.cs index 873afff..08e2cbf 100644 --- a/src/Server/Features/Dashboard/TopOffenders.cs +++ b/src/Server/Features/Dashboard/TopOffenders.cs @@ -43,7 +43,6 @@ public TopOffendersHandler(ApplicationDbContext dbContext, IMapper mapper) public async Task<TopOffendersResult> Handle(TopOffendersQuery request, CancellationToken cancellationToken) { var players = await _dbContext.Players - .Where(p => p.Fines.Any(f => f.Status)) .OrderByDescending(p => p.Fines.Sum(f => f.Amount)) .Take(10) .ProjectTo<TopOffendersResult.PlayerItem>(_mapper.ConfigurationProvider) @@ -61,6 +60,7 @@ public class TopOffendersMappingProfile : Profile public TopOffendersMappingProfile() { CreateMap<Player, TopOffendersResult.PlayerItem>() - .ForMember(d => d.TotalFineAmount, mo => mo.MapFrom(s => s.Fines.Sum(f => f.Amount).ToString("C0"))); + .ForMember(d => d.Amount, mo => mo.MapFrom(s => s.Fines.Sum(f => f.Amount).ToString("C0"))) + .ForMember(d => d.ImageUrl, mo => mo.MapFrom(s => s.HeadShotUrl)); } } diff --git a/src/Server/Features/Dashboard/TopTeamFines.cs b/src/Server/Features/Dashboard/TopTeamFines.cs new file mode 100644 index 0000000..ef202d4 --- /dev/null +++ b/src/Server/Features/Dashboard/TopTeamFines.cs @@ -0,0 +1,65 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using DynamoLeagueBlazor.Server.Infrastructure; +using DynamoLeagueBlazor.Server.Models; +using DynamoLeagueBlazor.Shared.Features.Dashboard; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DynamoLeagueBlazor.Server.Features.Fines; + +[ApiController] +[Route(TopTeamFinesRouteFactory.Uri)] +public class TopTeamFinesController : ControllerBase +{ + private readonly IMediator _mediator; + + public TopTeamFinesController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet] + public async Task<TopTeamFinesResult> GetAsync(CancellationToken cancellationToken) + { + return await _mediator.Send(new TopTeamFinesQuery(), cancellationToken); + } +} + +public class TopTeamFinesQuery : IRequest<TopTeamFinesResult> { } + +public class TopTeamFinesHandler : IRequestHandler<TopTeamFinesQuery, TopTeamFinesResult> +{ + private readonly ApplicationDbContext _dbContext; + private readonly IMapper _mapper; + + public TopTeamFinesHandler(ApplicationDbContext dbContext, IMapper mapper) + { + _dbContext = dbContext; + _mapper = mapper; + } + + public async Task<TopTeamFinesResult> Handle(TopTeamFinesQuery request, CancellationToken cancellationToken) + { + var teams = await _dbContext.Teams + .OrderByDescending(p => p.Fines.Sum(f => f.Amount)) + .ProjectTo<TopTeamFinesResult.TeamItem>(_mapper.ConfigurationProvider) + .ToListAsync(cancellationToken); + + return new TopTeamFinesResult + { + Teams = teams + }; + } +} + +public class TopTeamFinesMappingProfile : Profile +{ + public TopTeamFinesMappingProfile() + { + CreateMap<Team, TopTeamFinesResult.TeamItem>() + .ForMember(d => d.Amount, mo => mo.MapFrom(s => s.Fines.Sum(f => f.Amount).ToString("C0"))) + .ForMember(d => d.ImageUrl, mo => mo.MapFrom(s => s.LogoUrl)); + } +} diff --git a/src/Server/Features/Fines/List.cs b/src/Server/Features/Fines/List.cs index c405b85..706a7b1 100644 --- a/src/Server/Features/Fines/List.cs +++ b/src/Server/Features/Fines/List.cs @@ -43,7 +43,6 @@ public ListHandler(ApplicationDbContext dbContext, IMapper mapper) public async Task<FineListResult> Handle(ListQuery request, CancellationToken cancellationToken) { var fines = await _dbContext.Fines - .Include(p => p.Player) .OrderBy(f => f.Status) .ProjectTo<FineListResult.FineItem>(_mapper.ConfigurationProvider) .ToListAsync(cancellationToken); @@ -63,6 +62,8 @@ public ListMappingProfile() .ForMember(d => d.Status, mo => mo.MapFrom(s => s.Status ? "Approved" : "Pending")) .ForMember(d => d.PlayerName, mo => mo.MapFrom(s => s.Player.Name)) .ForMember(d => d.PlayerHeadShotUrl, mo => mo.MapFrom(s => s.Player.HeadShotUrl)) + .ForMember(d => d.TeamName, mo => mo.MapFrom(s => s.Team.Name)) + .ForMember(d => d.TeamLogoUrl, mo => mo.MapFrom(s => s.Team.LogoUrl)) .ForMember(d => d.Amount, mo => mo.MapFrom(s => s.Amount.ToString("C2"))); } } diff --git a/src/Server/Features/Fines/ManageFine.cs b/src/Server/Features/Fines/ManageFine.cs index bd3f34d..065198e 100644 --- a/src/Server/Features/Fines/ManageFine.cs +++ b/src/Server/Features/Fines/ManageFine.cs @@ -51,8 +51,8 @@ public async Task<Unit> Handle(ManageFineCommand request, CancellationToken canc .AsTracking() .SingleAsync(f => f.Id == request.FineId, cancellationToken); - if (!request.Approved) _dbContext.Fines.Remove(fine!); - else fine!.Status = request.Approved; + if (!request.Approved) _dbContext.Fines.Remove(fine); + else fine.Status = request.Approved; await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.Designer.cs b/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.Designer.cs new file mode 100644 index 0000000..24b7734 --- /dev/null +++ b/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.Designer.cs @@ -0,0 +1,632 @@ +// <auto-generated /> +using System; +using DynamoLeagueBlazor.Server.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DynamoLeagueBlazor.Server.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20220518022436_AddTeamIdToFine")] + partial class AddTeamIdToFine + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("Duende.IdentityServer.EntityFramework.Entities.DeviceFlowCodes", b => + { + b.Property<string>("UserCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<string>("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<DateTime>("CreationTime") + .HasColumnType("datetime2"); + + b.Property<string>("Data") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<string>("DeviceCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<DateTime?>("Expiration") + .IsRequired() + .HasColumnType("datetime2"); + + b.Property<string>("SessionId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property<string>("SubjectId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("UserCode"); + + b.HasIndex("DeviceCode") + .IsUnique(); + + b.HasIndex("Expiration"); + + b.ToTable("DeviceCodes", (string)null); + }); + + modelBuilder.Entity("Duende.IdentityServer.EntityFramework.Entities.Key", b => + { + b.Property<string>("Id") + .HasColumnType("nvarchar(450)"); + + b.Property<string>("Algorithm") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property<DateTime>("Created") + .HasColumnType("datetime2"); + + b.Property<string>("Data") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<bool>("DataProtected") + .HasColumnType("bit"); + + b.Property<bool>("IsX509Certificate") + .HasColumnType("bit"); + + b.Property<string>("Use") + .HasColumnType("nvarchar(450)"); + + b.Property<int>("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Use"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("Duende.IdentityServer.EntityFramework.Entities.PersistedGrant", b => + { + b.Property<string>("Key") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<string>("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<DateTime?>("ConsumedTime") + .HasColumnType("datetime2"); + + b.Property<DateTime>("CreationTime") + .HasColumnType("datetime2"); + + b.Property<string>("Data") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Description") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<DateTime?>("Expiration") + .HasColumnType("datetime2"); + + b.Property<string>("SessionId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property<string>("SubjectId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Key"); + + b.HasIndex("ConsumedTime"); + + b.HasIndex("Expiration"); + + b.HasIndex("SubjectId", "ClientId", "Type"); + + b.HasIndex("SubjectId", "SessionId", "Type"); + + b.ToTable("PersistedGrants", (string)null); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationRole", b => + { + b.Property<string>("Id") + .HasColumnType("nvarchar(450)"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", b => + { + b.Property<string>("Id") + .HasColumnType("nvarchar(450)"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("int"); + + b.Property<bool>("Approved") + .HasColumnType("bit"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("bit"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("bit"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property<string>("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property<int>("TeamId") + .HasColumnType("int"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("TeamId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Bid", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<int>("Amount") + .HasColumnType("int"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("datetime2"); + + b.Property<int>("PlayerId") + .HasColumnType("int"); + + b.Property<int>("TeamId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Bids"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Fine", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<decimal>("Amount") + .HasColumnType("decimal(18,0)"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("datetime2"); + + b.Property<int>("PlayerId") + .HasColumnType("int"); + + b.Property<string>("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<bool>("Status") + .HasColumnType("bit"); + + b.Property<int>("TeamId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Fines"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Player", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<int>("ContractValue") + .HasColumnType("int"); + + b.Property<DateTime?>("EndOfFreeAgency") + .HasColumnType("datetime2"); + + b.Property<string>("HeadShotUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Position") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<bool>("Rostered") + .HasColumnType("bit"); + + b.Property<int?>("TeamId") + .HasColumnType("int"); + + b.Property<int>("YearAcquired") + .HasColumnType("int"); + + b.Property<int?>("YearContractExpires") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Team", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<string>("LogoUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<string>("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"), 1L, 1); + + b.Property<string>("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.Property<string>("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property<string>("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property<string>("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property<string>("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property<string>("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property<string>("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Bid", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Models.Player", "Player") + .WithMany("Bids") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DynamoLeagueBlazor.Server.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Fine", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Models.Player", "Player") + .WithMany("Fines") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DynamoLeagueBlazor.Server.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Player", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Models.Team", "Team") + .WithMany("Players") + .HasForeignKey("TeamId"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.HasOne("DynamoLeagueBlazor.Server.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Player", b => + { + b.Navigation("Bids"); + + b.Navigation("Fines"); + }); + + modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Team", b => + { + b.Navigation("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.cs b/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.cs new file mode 100644 index 0000000..0a0914e --- /dev/null +++ b/src/Server/Infrastructure/Migrations/20220518022436_AddTeamIdToFine.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DynamoLeagueBlazor.Server.Infrastructure.Migrations +{ + public partial class AddTeamIdToFine : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<int>( + name: "TeamId", + table: "Fines", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Fines_TeamId", + table: "Fines", + column: "TeamId"); + + migrationBuilder.AddForeignKey( + name: "FK_Fines_Teams_TeamId", + table: "Fines", + column: "TeamId", + principalTable: "Teams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Fines_Teams_TeamId", + table: "Fines"); + + migrationBuilder.DropIndex( + name: "IX_Fines_TeamId", + table: "Fines"); + + migrationBuilder.DropColumn( + name: "TeamId", + table: "Fines"); + } + } +} diff --git a/src/Server/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Server/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 2f157cf..9247e63 100644 --- a/src/Server/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Server/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -316,10 +316,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<bool>("Status") .HasColumnType("bit"); + b.Property<int>("TeamId") + .HasColumnType("int"); + b.HasKey("Id"); b.HasIndex("PlayerId"); + b.HasIndex("TeamId"); + b.ToTable("Fines"); }); @@ -537,7 +542,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("DynamoLeagueBlazor.Server.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Player"); + + b.Navigation("Team"); }); modelBuilder.Entity("DynamoLeagueBlazor.Server.Models.Player", b => diff --git a/src/Server/Models/Fine.cs b/src/Server/Models/Fine.cs index 0a3cc8c..8196df9 100644 --- a/src/Server/Models/Fine.cs +++ b/src/Server/Models/Fine.cs @@ -4,11 +4,12 @@ namespace DynamoLeagueBlazor.Server.Models; public record Fine : BaseEntity { - public Fine(decimal amount, string reason, int playerId) + public Fine(decimal amount, string reason, int playerId, int teamId) { Amount = amount; Reason = reason; PlayerId = playerId; + TeamId = teamId; } [Column(TypeName = "decimal(18,0)")] @@ -17,6 +18,8 @@ public Fine(decimal amount, string reason, int playerId) public DateTime CreatedOn { get; set; } = DateTime.Now; public string Reason { get; set; } public int PlayerId { get; set; } + public int TeamId { get; set; } public Player Player { get; private set; } = null!; + public Team Team { get; private set; } = null!; } diff --git a/src/Server/Models/Player.cs b/src/Server/Models/Player.cs index d288f40..1ca2989 100644 --- a/src/Server/Models/Player.cs +++ b/src/Server/Models/Player.cs @@ -77,20 +77,12 @@ public Bid AddBid(int amount, int teamIdOfBidder) return bid; } - public Player AddPlayer(string Name, string Position, string Headshot, int TeamId, int ContractValue) - { - var player = new Player(Name, Position, Headshot) - { - ContractValue = ContractValue, - TeamId = TeamId - }; - player.SetToUnsigned(); - return player; - } - public Fine AddFine(decimal amount, string reason) { - var fine = new Fine(amount, reason, Id); + if (TeamId is null) + throw new InvalidOperationException("A player must first be assigned to a team to add a fine."); + + var fine = new Fine(amount, reason, Id, TeamId!.Value); Fines.Add(fine); diff --git a/src/Server/Models/Team.cs b/src/Server/Models/Team.cs index 47bc2f6..6a5c55c 100644 --- a/src/Server/Models/Team.cs +++ b/src/Server/Models/Team.cs @@ -12,6 +12,7 @@ public Team(string name, string logoUrl) public string LogoUrl { get; private set; } public ICollection<Player> Players { get; private set; } = new HashSet<Player>(); + public ICollection<Fine> Fines { get; private set; } = new HashSet<Fine>(); public int CapSpace() => Players.Sum(p => p.ContractValue); } diff --git a/src/Shared/Features/Dashboard/Shared/IRankedItem.cs b/src/Shared/Features/Dashboard/Shared/IRankedItem.cs new file mode 100644 index 0000000..2938304 --- /dev/null +++ b/src/Shared/Features/Dashboard/Shared/IRankedItem.cs @@ -0,0 +1,8 @@ +namespace DynamoLeagueBlazor.Shared.Features.Dashboard.Shared; + +public interface IRankedItem +{ + public string ImageUrl { get; set; } + public string Name { get; set; } + public string Amount { get; set; } +} diff --git a/src/Shared/Features/Dashboard/TopOffenders.cs b/src/Shared/Features/Dashboard/TopOffenders.cs index fc6011c..6e00d93 100644 --- a/src/Shared/Features/Dashboard/TopOffenders.cs +++ b/src/Shared/Features/Dashboard/TopOffenders.cs @@ -1,14 +1,16 @@ -namespace DynamoLeagueBlazor.Shared.Features.Dashboard; +using DynamoLeagueBlazor.Shared.Features.Dashboard.Shared; + +namespace DynamoLeagueBlazor.Shared.Features.Dashboard; public class TopOffendersResult { public IEnumerable<PlayerItem> Players { get; set; } = Array.Empty<PlayerItem>(); - public class PlayerItem + public class PlayerItem : IRankedItem { - public string HeadShotUrl { get; set; } + public string ImageUrl { get; set; } public string Name { get; set; } - public string TotalFineAmount { get; set; } + public string Amount { get; set; } } } diff --git a/src/Shared/Features/Dashboard/TopTeamFines.cs b/src/Shared/Features/Dashboard/TopTeamFines.cs new file mode 100644 index 0000000..7a7a5f6 --- /dev/null +++ b/src/Shared/Features/Dashboard/TopTeamFines.cs @@ -0,0 +1,20 @@ +using DynamoLeagueBlazor.Shared.Features.Dashboard.Shared; + +namespace DynamoLeagueBlazor.Shared.Features.Dashboard; + +public class TopTeamFinesResult +{ + public IEnumerable<TeamItem> Teams { get; set; } = Array.Empty<TeamItem>(); + + public class TeamItem : IRankedItem + { + public string ImageUrl { get; set; } + public string Name { get; set; } + public string Amount { get; set; } + } +} + +public class TopTeamFinesRouteFactory +{ + public const string Uri = "api/dashboard/topteamfines"; +} diff --git a/src/Shared/Features/Fines/List.cs b/src/Shared/Features/Fines/List.cs index 381e5dc..530265d 100644 --- a/src/Shared/Features/Fines/List.cs +++ b/src/Shared/Features/Fines/List.cs @@ -9,6 +9,8 @@ public class FineItem public int Id { get; set; } public string PlayerHeadShotUrl { get; set; } public string PlayerName { get; set; } + public string TeamName { get; set; } + public string TeamLogoUrl { get; set; } public string Reason { get; set; } public string Amount { get; set; } public string Status { get; set; } diff --git a/src/Tests/FakeFactory.cs b/src/Tests/FakeFactory.cs index 881e176..fcb8401 100644 --- a/src/Tests/FakeFactory.cs +++ b/src/Tests/FakeFactory.cs @@ -21,29 +21,32 @@ public static Team CreateFakeTeam() { var faker = new AutoFaker<Team>() .Ignore(f => f.Id) - .Ignore(f => f.Players); + .Ignore(f => f.Players) + .Ignore(f => f.Fines); return faker.Generate(); } - public static Player CreateFakePlayer() + public static Player CreateFakePlayer(int? teamId = null) { var faker = new AutoFaker<Player>() .Ignore(f => f.Id) .Ignore(f => f.Bids) - .Ignore(f => f.TeamId) .Ignore(f => f.Team) - .Ignore(f => f.Fines); + .Ignore(f => f.Fines) + .RuleFor(f => f.TeamId, teamId ?? null); return faker.Generate(); } - public static Fine CreateFakeFine(int playerId) + public static Fine CreateFakeFine(int playerId, int teamId) { var faker = new AutoFaker<Fine>() .Ignore(f => f.Id) .Ignore(f => f.Player) - .RuleFor(f => f.PlayerId, playerId); + .Ignore(f => f.Team) + .RuleFor(f => f.PlayerId, playerId) + .RuleFor(f => f.TeamId, teamId); return faker.Generate(); } @@ -66,7 +69,6 @@ public static ApplicationUser CreateFakeUser(int teamId) public static FakePosition CreateFakePosition() => new(); public static string RandomString => AutoFaker.Generate<string>(); - } public class FakePosition : Position diff --git a/src/Tests/Features/Admin/StartSeasonTests.cs b/src/Tests/Features/Admin/StartSeasonTests.cs index f308185..bcf0b3f 100644 --- a/src/Tests/Features/Admin/StartSeasonTests.cs +++ b/src/Tests/Features/Admin/StartSeasonTests.cs @@ -58,6 +58,54 @@ public async Task GivenAuthenticatedAdmin_WhenNotPlayerIsAFreeAgent_ThenReturnsF result.Should().BeFalse(); } + [Fact] + public async Task GivenAuthenticatedAdmin_WhenAFineExistsBeforeJanuary1stOfTheCurrentYear_ThenTheFineIsRemoved() + { + var application = CreateAdminAuthenticatedApplication(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + + var stubPlayer = CreateFakePlayer(); + stubPlayer.TeamId = stubTeam.Id; + var mockFine = stubPlayer.AddFine(int.MaxValue, RandomString); + mockFine.CreatedOn = DateTime.MinValue; + await application.AddAsync(stubPlayer); + + var client = application.CreateClient(); + + var result = await client.PostAsync(StartSeasonRouteFactory.Uri, null); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var fine = await application.FirstOrDefaultAsync<Fine>(); + fine.Should().BeNull(); + } + + [Fact] + public async Task GivenAuthenticatedAdmin_WhenAFineExistsOnOrAfterJanuary1stOfTheCurrentYear_ThenTheFineIsRemoved() + { + var application = CreateAdminAuthenticatedApplication(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + + var stubPlayer = CreateFakePlayer(); + stubPlayer.TeamId = stubTeam.Id; + var mockFine = stubPlayer.AddFine(int.MaxValue, RandomString); + mockFine.CreatedOn = new DateTime(DateTime.Today.Year, 1, 1); + await application.AddAsync(stubPlayer); + + var client = application.CreateClient(); + + var result = await client.PostAsync(StartSeasonRouteFactory.Uri, null); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var fine = await application.FirstOrDefaultAsync<Fine>(); + fine.Should().NotBeNull(); + } + [Fact] public async Task GivenAuthenticatedAdmin_WhenAPlayerIsEligibleForFreeAgency_ThenSetsPlayerToFreeAgent() { diff --git a/src/Tests/Features/Dashboard/TopOffendersTests.cs b/src/Tests/Features/Dashboard/TopOffendersTests.cs index 24dc170..290c51a 100644 --- a/src/Tests/Features/Dashboard/TopOffendersTests.cs +++ b/src/Tests/Features/Dashboard/TopOffendersTests.cs @@ -21,11 +21,15 @@ public async Task GivenUnauthenticatedUser_ThenDoesNotAllowAccess() public async Task GivenAnyAuthenticatedUser_WhenThereIsOnePlayerWithAFine_ThenReturnsOnePlayerWithAFine() { var application = CreateUserAuthenticatedApplication(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + var mockPlayer = CreateFakePlayer(); - await application.AddAsync(mockPlayer); - var mockFine = CreateFakeFine(mockPlayer.Id); + mockPlayer.TeamId = stubTeam.Id; + var mockFine = mockPlayer.AddFine(int.MaxValue, RandomString); mockFine.Status = true; - await application.AddAsync(mockFine); + await application.AddAsync(mockPlayer); var client = application.CreateClient(); @@ -35,33 +39,33 @@ public async Task GivenAnyAuthenticatedUser_WhenThereIsOnePlayerWithAFine_ThenRe result!.Players.Should().HaveCount(1); var firstPlayer = result.Players.First(); - firstPlayer.TotalFineAmount.Should().Be(mockFine.Amount.ToString("C0")); + firstPlayer.Amount.Should().Be(mockFine.Amount.ToString("C0")); firstPlayer.Name.Should().Be(mockPlayer.Name); - firstPlayer.HeadShotUrl.Should().Be(mockPlayer.HeadShotUrl); + firstPlayer.ImageUrl.Should().Be(mockPlayer.HeadShotUrl); } [Fact] public async Task GivenAnyAuthenticatedUser_WhenThereIsElevenPlayersWithApprovedFines_ThenReturnsOnlyTopTenByFineAmount() { var application = CreateUserAuthenticatedApplication(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + foreach (var count in Enumerable.Range(0, 10)) { var mockPlayer = CreateFakePlayer(); + mockPlayer.TeamId = stubTeam.Id; + var fine = mockPlayer.AddFine(int.MaxValue, RandomString); + fine.Status = true; await application.AddAsync(mockPlayer); - - var mockFine = CreateFakeFine(mockPlayer.Id); - mockFine.Status = true; - mockFine.Amount = int.MaxValue; - await application.AddAsync(mockFine); } - var sixthPlayer = CreateFakePlayer(); - await application.AddAsync(sixthPlayer); - - var lowestFine = CreateFakeFine(sixthPlayer.Id); + var eleventhPlayerWithFine = CreateFakePlayer(); + eleventhPlayerWithFine.TeamId = stubTeam.Id; + var lowestFine = eleventhPlayerWithFine.AddFine(int.MinValue, RandomString); lowestFine.Status = true; - lowestFine.Amount = 1; - await application.AddAsync(lowestFine); + await application.AddAsync(eleventhPlayerWithFine); var client = application.CreateClient(); @@ -69,6 +73,6 @@ public async Task GivenAnyAuthenticatedUser_WhenThereIsElevenPlayersWithApproved result.Should().NotBeNull(); result!.Players.Should().HaveCount(10); - result.Players.Should().OnlyContain(p => p.TotalFineAmount != lowestFine.Amount.ToString("C0")); + result.Players.Should().OnlyContain(p => p.Amount != lowestFine.Amount.ToString("C0")); } } diff --git a/src/Tests/Features/Dashboard/TopTeamFinesTests.cs b/src/Tests/Features/Dashboard/TopTeamFinesTests.cs new file mode 100644 index 0000000..8584ae4 --- /dev/null +++ b/src/Tests/Features/Dashboard/TopTeamFinesTests.cs @@ -0,0 +1,74 @@ +using DynamoLeagueBlazor.Shared.Features.Dashboard; +using System.Net.Http.Json; + +namespace DynamoLeagueBlazor.Tests.Features.Dashboard; + +public class TopTeamFinesTests : IntegrationTestBase +{ + [Fact] + public async Task GivenUnauthenticatedUser_ThenDoesNotAllowAccess() + { + var application = CreateUnauthenticatedApplication(); + + var client = application.CreateClient(); + + var response = await client.GetAsync(TopTeamFinesRouteFactory.Uri); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GivenAnyAuthenticatedUser_WhenThereIsOneTeamWithAFine_ThenReturnsOneTeamWithAFine() + { + var application = CreateUserAuthenticatedApplication(); + + var mockTeam = CreateFakeTeam(); + await application.AddAsync(mockTeam); + + var stubPlayer = CreateFakePlayer(); + stubPlayer.TeamId = mockTeam.Id; + var mockFine = stubPlayer.AddFine(int.MaxValue, RandomString); + mockFine.Status = true; + await application.AddAsync(stubPlayer); + + var client = application.CreateClient(); + + var result = await client.GetFromJsonAsync<TopTeamFinesResult>(TopTeamFinesRouteFactory.Uri); + + result.Should().NotBeNull(); + result!.Teams.Should().HaveCount(1); + + var firstTeam = result.Teams.First(); + firstTeam.Amount.Should().Be(mockFine.Amount.ToString("C0")); + firstTeam.Name.Should().Be(mockTeam.Name); + firstTeam.ImageUrl.Should().Be(mockTeam.LogoUrl); + } + + [Fact] + public async Task GivenAnyAuthenticatedUser_WhenThereIsMultipleTeamsWithApprovedFines_ThenReturnsTheHighestFineCountFirst() + { + var application = CreateUserAuthenticatedApplication(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + + var mockPlayer1 = CreateFakePlayer(); + mockPlayer1.TeamId = stubTeam.Id; + var fine = mockPlayer1.AddFine(int.MaxValue, RandomString); + fine.Status = true; + await application.AddAsync(mockPlayer1); + + var mockPlayer2 = CreateFakePlayer(); + mockPlayer2.TeamId = stubTeam.Id; + var lowestFine = mockPlayer2.AddFine(int.MinValue, RandomString); + lowestFine.Status = true; + await application.AddAsync(mockPlayer2); + + var client = application.CreateClient(); + + var result = await client.GetFromJsonAsync<TopTeamFinesResult>(TopTeamFinesRouteFactory.Uri); + + result.Should().NotBeNull(); + result!.Teams.Should().BeInDescendingOrder(t => t.Amount); + } +} diff --git a/src/Tests/Features/Fines/ListTests.cs b/src/Tests/Features/Fines/ListTests.cs index e31cc37..958e858 100644 --- a/src/Tests/Features/Fines/ListTests.cs +++ b/src/Tests/Features/Fines/ListTests.cs @@ -21,10 +21,14 @@ public async Task GivenUnauthenticatedUser_ThenDoesNotAllowAccess() public async Task GivenAnyAuthenticatedUser_WhenThereIsOneFine_ThenReturnsOneFine() { var application = CreateUserAuthenticatedApplication(); + + var mockTeam = CreateFakeTeam(); + await application.AddAsync(mockTeam); + var mockPlayer = CreateFakePlayer(); + mockPlayer.TeamId = mockTeam.Id; + var mockFine = mockPlayer.AddFine(int.MaxValue, RandomString); await application.AddAsync(mockPlayer); - var mockFine = mockPlayer.AddFine(1, RandomString); - await application.UpdateAsync(mockPlayer); var client = application.CreateClient(); @@ -40,5 +44,7 @@ public async Task GivenAnyAuthenticatedUser_WhenThereIsOneFine_ThenReturnsOneFin fine.Status.Should().BeOneOf("Pending", "Approved"); fine.Amount.Should().Be(mockFine.Amount.ToString("C2")); fine.Reason.Should().Be(mockFine.Reason); + fine.TeamName.Should().Be(mockTeam.Name); + fine.TeamLogoUrl.Should().Be(mockTeam.LogoUrl); } } diff --git a/src/Tests/Features/Fines/ManageFineTests.cs b/src/Tests/Features/Fines/ManageFineTests.cs index 52e0451..5ce599d 100644 --- a/src/Tests/Features/Fines/ManageFineTests.cs +++ b/src/Tests/Features/Fines/ManageFineTests.cs @@ -45,17 +45,21 @@ public async Task GivenAuthenticatedUser_ThenDoesNotAllowAccess() public async Task GivenAuthenticatedAdmin_WhenFineIsApproved_ThenUpdatesIt() { var application = CreateAdminAuthenticatedApplication(); - var client = application.CreateClient(); - var mockPlayer = CreateFakePlayer(); - await application.AddAsync(mockPlayer); + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + + var mockPlayer = CreateFakePlayer(); + mockPlayer.TeamId = stubTeam.Id; var mockFine = mockPlayer.AddFine(int.MaxValue, RandomString); - await application.UpdateAsync(mockPlayer); + await application.AddAsync(mockPlayer); var mockRequest = CreateFakeValidRequest(); mockRequest.Approved = true; mockRequest.FineId = mockFine.Id; + var client = application.CreateClient(); + var response = await client.PostAsJsonAsync(ManageFineRouteFactory.Uri, mockRequest); response.StatusCode.Should().Be(HttpStatusCode.NoContent); @@ -69,17 +73,21 @@ public async Task GivenAuthenticatedAdmin_WhenFineIsApproved_ThenUpdatesIt() public async Task GivenAuthenticatedAdmin_WhenFineIsNotApproved_ThenDeletesIt() { var application = CreateAdminAuthenticatedApplication(); - var client = application.CreateClient(); - var mockPlayer = CreateFakePlayer(); - await application.AddAsync(mockPlayer); + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + + var mockPlayer = CreateFakePlayer(); + mockPlayer.TeamId = stubTeam.Id; var mockFine = mockPlayer.AddFine(int.MaxValue, RandomString); - await application.UpdateAsync(mockPlayer); + await application.AddAsync(mockPlayer); var mockRequest = CreateFakeValidRequest(); mockRequest.Approved = false; mockRequest.FineId = mockFine.Id; + var client = application.CreateClient(); + var response = await client.PostAsJsonAsync(ManageFineRouteFactory.Uri, mockRequest); response.StatusCode.Should().Be(HttpStatusCode.NoContent); diff --git a/src/Tests/Features/Players/AddFineTests.cs b/src/Tests/Features/Players/AddFineTests.cs index d5f96ed..e89b771 100644 --- a/src/Tests/Features/Players/AddFineTests.cs +++ b/src/Tests/Features/Players/AddFineTests.cs @@ -34,12 +34,19 @@ public async Task GivenUnauthenticatedUser_ThenDoesNotAllowAccess() public async Task GivenAnyAuthenticatedUser_WhenAValidFine_ThenSavesIt() { var application = CreateUserAuthenticatedApplication(); - var client = application.CreateClient(); + + var stubTeam = CreateFakeTeam(); + await application.AddAsync(stubTeam); + var mockPlayer = CreateFakePlayer(); + mockPlayer.TeamId = stubTeam.Id; await application.AddAsync(mockPlayer); + var stubRequest = CreateFakeValidRequest(); stubRequest.PlayerId = mockPlayer.Id; + var client = application.CreateClient(); + var response = await client.PostAsJsonAsync(AddFineRouteFactory.Uri, stubRequest); response.StatusCode.Should().Be(HttpStatusCode.OK); diff --git a/src/Tests/IntegrationTestBase.cs b/src/Tests/IntegrationTestBase.cs index f5641f4..b15f0f8 100644 --- a/src/Tests/IntegrationTestBase.cs +++ b/src/Tests/IntegrationTestBase.cs @@ -1,9 +1,233 @@ -namespace DynamoLeagueBlazor.Tests; +using DynamoLeagueBlazor.Server.Infrastructure; +using DynamoLeagueBlazor.Server.Infrastructure.Identity; +using DynamoLeagueBlazor.Shared.Infastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Respawn; +using Respawn.Graph; +using System.Security.Claims; +using System.Text.Encodings.Web; -[Collection("Server")] +namespace DynamoLeagueBlazor.Tests; + +[Collection(nameof(Server))] public class IntegrationTestBase : IAsyncLifetime { public Task DisposeAsync() => Task.CompletedTask; public async Task InitializeAsync() => await ResetStateAsync(); } + + +[CollectionDefinition(nameof(Server))] +public class IntegrationTesting : ICollectionFixture<IntegrationTesting>, IAsyncLifetime +{ + private static Checkpoint _checkpoint = null!; + private static IConfiguration _configuration = null!; + internal static WebApplicationFactory<Program> _setupApplication = null!; + + public async Task InitializeAsync() + { + _configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", true, true) + .AddEnvironmentVariables() + .Build(); + + _checkpoint = new Checkpoint + { + TablesToIgnore = new Table[] { "__EFMigrationsHistory" } + }; + + _setupApplication = new TestWebApplicationFactory(_configuration); + + using var scope = _setupApplication.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); + + await dbContext.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await _setupApplication.DisposeAsync(); + } + + public static async Task ResetStateAsync() + { + await _checkpoint.Reset(_configuration.GetConnectionString("DefaultConnection")); + } + + internal static WebApplicationFactory<Program> CreateUserAuthenticatedApplication() + => CreateApplication() + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(UserAuthenticationHandler.AuthenticationName) + .AddScheme<AuthenticationSchemeOptions, UserAuthenticationHandler>(UserAuthenticationHandler.AuthenticationName, options => { }); + }); + }); + + internal static WebApplicationFactory<Program> CreateAdminAuthenticatedApplication() + => CreateApplication() + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(AdminAuthenticationHandler.AuthenticationName) + .AddScheme<AuthenticationSchemeOptions, AdminAuthenticationHandler>(AdminAuthenticationHandler.AuthenticationName, options => { }); + }); + }); + + internal static WebApplicationFactory<Program> CreateUnauthenticatedApplication() + => CreateApplication(); + + private static WebApplicationFactory<Program> CreateApplication() + { + var application = new TestWebApplicationFactory(_configuration); + + return application; + } +} + +internal class TestWebApplicationFactory : WebApplicationFactory<Program> +{ + private readonly IConfiguration _configuration; + + public TestWebApplicationFactory(IConfiguration configuration) + { + _configuration = configuration; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Test"); + + builder.ConfigureAppConfiguration((builderContext, config) => + { + config.AddConfiguration(_configuration); + }); + + builder.ConfigureTestServices(services => + { + services.AddAuthorization(options => + { + options.AddApplicationAuthorizationPolicies(); + }); + }); + } +} + +internal static class IntegrationTestExtensions +{ + public static async Task<TEntity?> FirstOrDefaultAsync<TEntity>(this WebApplicationFactory<Program> application) + where TEntity : class + { + using var scope = application.Services.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); + + return await context.Set<TEntity>().FirstOrDefaultAsync(); + } + + public static async Task AddAsync<TEntity>(this WebApplicationFactory<Program> application, TEntity entity) + where TEntity : class + { + using var scope = application.Services.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); + + context.Add(entity); + + await context.SaveChangesAsync(); + } + + public static async Task UpdateAsync<TEntity>(this WebApplicationFactory<Program> application, TEntity entity) + where TEntity : class + { + using var scope = application.Services.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); + + context.Update(entity); + + await context.SaveChangesAsync(); + } +} + +internal class UserAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> +{ + public const string AuthenticationName = RoleName.User; + private readonly ApplicationDbContext _applicationDbContext; + public static int TeamId = 1; + + public UserAuthenticationHandler( + IOptionsMonitor<AuthenticationSchemeOptions> options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + ApplicationDbContext applicationDbContext) + : base(options, logger, encoder, clock) + { + _applicationDbContext = applicationDbContext; + } + + protected override async Task<AuthenticateResult> HandleAuthenticateAsync() + { + TeamId = (await _applicationDbContext.Teams.FirstOrDefaultAsync())?.Id ?? TeamId; + + return AuthenticationHandlerUtilities.GetSuccessfulAuthenticateResult(AuthenticationName, TeamId, AuthenticationName); + } +} + +internal class AdminAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> +{ + public const string AuthenticationName = RoleName.Admin; + private readonly ApplicationDbContext _applicationDbContext; + public static int TeamId = 1; + + public AdminAuthenticationHandler( + IOptionsMonitor<AuthenticationSchemeOptions> options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + ApplicationDbContext applicationDbContext) + : base(options, logger, encoder, clock) + { + _applicationDbContext = applicationDbContext; + } + + protected override async Task<AuthenticateResult> HandleAuthenticateAsync() + { + TeamId = (await _applicationDbContext.Teams.FirstOrDefaultAsync())?.Id ?? TeamId; + + return AuthenticationHandlerUtilities.GetSuccessfulAuthenticateResult(AuthenticationName, TeamId, AuthenticationName); + } +} + +internal static class AuthenticationHandlerUtilities +{ + public static AuthenticateResult GetSuccessfulAuthenticateResult(string role, int teamId, string authenticationName) + { + var claims = new[] { + new Claim(ClaimTypes.Name, RandomString), + new Claim(ClaimTypes.Role, role), + new Claim(nameof(ApplicationUser.TeamId), teamId.ToString()), + new Claim(nameof(ApplicationUser.Approved), bool.TrueString) + }; + var identity = new ClaimsIdentity(claims, authenticationName); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, authenticationName); + + var result = AuthenticateResult.Success(ticket); + + return result; + } +} diff --git a/src/Tests/IntegrationTesting.cs b/src/Tests/IntegrationTesting.cs deleted file mode 100644 index 18949ca..0000000 --- a/src/Tests/IntegrationTesting.cs +++ /dev/null @@ -1,224 +0,0 @@ -using DynamoLeagueBlazor.Server.Infrastructure; -using DynamoLeagueBlazor.Server.Infrastructure.Identity; -using DynamoLeagueBlazor.Shared.Infastructure.Identity; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Respawn; -using Respawn.Graph; -using System.Security.Claims; -using System.Text.Encodings.Web; - -namespace DynamoLeagueBlazor.Tests; - -[CollectionDefinition("Server")] -public class IntegrationTesting : ICollectionFixture<IntegrationTesting>, IAsyncLifetime -{ - private static Checkpoint _checkpoint = null!; - private static IConfiguration _configuration = null!; - internal static WebApplicationFactory<Program> _setupApplication = null!; - - public async Task InitializeAsync() - { - _configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", true, true) - .AddEnvironmentVariables() - .Build(); - - _checkpoint = new Checkpoint - { - TablesToIgnore = new Table[] { "__EFMigrationsHistory" } - }; - - _setupApplication = new TestWebApplicationFactory(_configuration); - - using var scope = _setupApplication.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - - await dbContext.Database.MigrateAsync(); - } - - public async Task DisposeAsync() - { - await _setupApplication.DisposeAsync(); - } - - public static async Task ResetStateAsync() - { - await _checkpoint.Reset(_configuration.GetConnectionString("DefaultConnection")); - } - - internal static WebApplicationFactory<Program> CreateUserAuthenticatedApplication() - => CreateApplication() - .WithWebHostBuilder(builder => - { - builder.ConfigureTestServices(services => - { - services.AddAuthentication(UserAuthenticationHandler.AuthenticationName) - .AddScheme<AuthenticationSchemeOptions, UserAuthenticationHandler>(UserAuthenticationHandler.AuthenticationName, options => { }); - }); - }); - - internal static WebApplicationFactory<Program> CreateAdminAuthenticatedApplication() - => CreateApplication() - .WithWebHostBuilder(builder => - { - builder.ConfigureTestServices(services => - { - services.AddAuthentication(AdminAuthenticationHandler.AuthenticationName) - .AddScheme<AuthenticationSchemeOptions, AdminAuthenticationHandler>(AdminAuthenticationHandler.AuthenticationName, options => { }); - }); - }); - - internal static WebApplicationFactory<Program> CreateUnauthenticatedApplication() - => CreateApplication(); - - private static WebApplicationFactory<Program> CreateApplication() - { - var application = new TestWebApplicationFactory(_configuration); - - return application; - } -} - -internal class TestWebApplicationFactory : WebApplicationFactory<Program> -{ - private readonly IConfiguration _configuration; - - public TestWebApplicationFactory(IConfiguration configuration) - { - _configuration = configuration; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Test"); - - builder.ConfigureAppConfiguration((builderContext, config) => - { - config.AddConfiguration(_configuration); - }); - - builder.ConfigureTestServices(services => - { - services.AddAuthorization(options => - { - options.AddApplicationAuthorizationPolicies(); - }); - }); - } -} - -internal static class IntegrationTestExtensions -{ - public static async Task<TEntity?> FirstOrDefaultAsync<TEntity>(this WebApplicationFactory<Program> application) - where TEntity : class - { - using var scope = application.Services.CreateScope(); - - var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - - return await context.Set<TEntity>().FirstOrDefaultAsync(); - } - - public static async Task AddAsync<TEntity>(this WebApplicationFactory<Program> application, TEntity entity) - where TEntity : class - { - using var scope = application.Services.CreateScope(); - - var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - - context.Add(entity); - - await context.SaveChangesAsync(); - } - - public static async Task UpdateAsync<TEntity>(this WebApplicationFactory<Program> application, TEntity entity) - where TEntity : class - { - using var scope = application.Services.CreateScope(); - - var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - - context.Update(entity); - - await context.SaveChangesAsync(); - } -} - -internal class UserAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> -{ - public const string AuthenticationName = RoleName.User; - public static int TeamId = 1; - private readonly ApplicationDbContext _applicationDbContext; - - public UserAuthenticationHandler( - IOptionsMonitor<AuthenticationSchemeOptions> options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock, - ApplicationDbContext applicationDbContext) - : base(options, logger, encoder, clock) - { - _applicationDbContext = applicationDbContext; - } - - protected override async Task<AuthenticateResult> HandleAuthenticateAsync() - { - TeamId = (await _applicationDbContext.Teams.FirstOrDefaultAsync())?.Id ?? TeamId; - - return AuthenticationHandlerUtilities.GetSuccessfulAuthenticateResult(AuthenticationName, TeamId, AuthenticationName); - } -} - -internal class AdminAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> -{ - public const string AuthenticationName = RoleName.Admin; - public static int TeamId = 1; - private readonly ApplicationDbContext _applicationDbContext; - - public AdminAuthenticationHandler( - IOptionsMonitor<AuthenticationSchemeOptions> options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock, - ApplicationDbContext applicationDbContext) - : base(options, logger, encoder, clock) - { - _applicationDbContext = applicationDbContext; - } - - protected override async Task<AuthenticateResult> HandleAuthenticateAsync() - { - TeamId = (await _applicationDbContext.Teams.FirstOrDefaultAsync())?.Id ?? TeamId; - - return AuthenticationHandlerUtilities.GetSuccessfulAuthenticateResult(AuthenticationName, TeamId, AuthenticationName); - } -} - -internal static class AuthenticationHandlerUtilities -{ - public static AuthenticateResult GetSuccessfulAuthenticateResult(string role, int teamId, string authenticationName) - { - var claims = new[] { - new Claim(ClaimTypes.Name, RandomString), - new Claim(ClaimTypes.Role, role), - new Claim(nameof(ApplicationUser.TeamId), teamId.ToString()), - new Claim(nameof(ApplicationUser.Approved), bool.TrueString) - }; - var identity = new ClaimsIdentity(claims, authenticationName); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, authenticationName); - - var result = AuthenticateResult.Success(ticket); - - return result; - } -}