diff --git a/src/Application/Features/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs b/src/Application/Features/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs new file mode 100644 index 0000000..d1b561c --- /dev/null +++ b/src/Application/Features/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs @@ -0,0 +1,52 @@ +using SSW.CleanArchitecture.Application.Common.Exceptions; +using SSW.CleanArchitecture.Application.Common.Interfaces; +using SSW.CleanArchitecture.Domain.Heroes; +using SSW.CleanArchitecture.Domain.Teams; + +namespace SSW.CleanArchitecture.Application.Features.Teams.Commands.AddHeroToTeam; + +public sealed record AddHeroToTeamCommand(Guid TeamId, Guid HeroId) : IRequest; + +// ReSharper disable once UnusedType.Global +public sealed class AddHeroToTeamCommandHandler(IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(AddHeroToTeamCommand request, CancellationToken cancellationToken) + { + var teamId = new TeamId(request.TeamId); + var heroId = new HeroId(request.HeroId); + + var team = dbContext.Teams + .WithSpecification(new TeamByIdSpec(teamId)) + .FirstOrDefault(); + + if (team is null) + { + throw new NotFoundException(nameof(Team), teamId); + } + + var hero = dbContext.Heroes + .WithSpecification(new HeroByIdSpec(heroId)) + .FirstOrDefault(); + + if (hero is null) + { + throw new NotFoundException(nameof(Hero), heroId); + } + + team.AddHero(hero); + await dbContext.SaveChangesAsync(cancellationToken); + } +} + +public class AddHeroToTeamCommandValidator : AbstractValidator +{ + public AddHeroToTeamCommandValidator() + { + RuleFor(v => v.HeroId) + .NotEmpty(); + + RuleFor(v => v.TeamId) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Application/Features/Teams/Queries/GetTeam/GetTeamQuery.cs b/src/Application/Features/Teams/Queries/GetTeam/GetTeamQuery.cs new file mode 100644 index 0000000..7852da8 --- /dev/null +++ b/src/Application/Features/Teams/Queries/GetTeam/GetTeamQuery.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using SSW.CleanArchitecture.Application.Common.Interfaces; +using SSW.CleanArchitecture.Domain.Teams; + +namespace SSW.CleanArchitecture.Application.Features.Teams.Queries.GetTeam; + +public record GetTeamQuery(Guid TeamId) : IRequest; + +public sealed class GetAllTeamsQueryHandler(IApplicationDbContext dbContext) : IRequestHandler +{ + public async Task Handle( + GetTeamQuery request, + CancellationToken cancellationToken) + { + var teamId = new TeamId(request.TeamId); + + var team = await dbContext.Teams + .Where(t => t.Id == teamId) + .Select(t => new TeamDto + { + Id = t.Id.Value, + Name = t.Name, + Heroes = t.Heroes.Select(h => new HeroDto { Id = h.Id.Value, Name = h.Name }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + + return team; + } +} + +public class TeamDto +{ + public Guid Id { get; init; } + public required string Name { get; init; } + public List Heroes { get; init; } = []; +} + +public class HeroDto +{ + public Guid Id { get; init; } + public required string Name { get; init; } +} \ No newline at end of file diff --git a/src/Domain/Heroes/Hero.cs b/src/Domain/Heroes/Hero.cs index ae26cca..ef31517 100644 --- a/src/Domain/Heroes/Hero.cs +++ b/src/Domain/Heroes/Hero.cs @@ -12,7 +12,7 @@ public class Hero : AggregateRoot public string Name { get; private set; } = null!; public string Alias { get; private set; } = null!; public int PowerLevel { get; private set; } - public IEnumerable Powers => _powers.AsReadOnly(); + public IReadOnlyList Powers => _powers.AsReadOnly(); private Hero() { } diff --git a/src/Domain/Heroes/TeamByIdSpec.cs b/src/Domain/Heroes/TeamByIdSpec.cs new file mode 100644 index 0000000..42952e1 --- /dev/null +++ b/src/Domain/Heroes/TeamByIdSpec.cs @@ -0,0 +1,11 @@ +using Ardalis.Specification; + +namespace SSW.CleanArchitecture.Domain.Heroes; + +public sealed class HeroByIdSpec : SingleResultSpecification +{ + public HeroByIdSpec(HeroId heroId) + { + Query.Where(t => t.Id == heroId); + } +} \ No newline at end of file diff --git a/src/Domain/Teams/Team.cs b/src/Domain/Teams/Team.cs index 887553a..1bebaeb 100644 --- a/src/Domain/Teams/Team.cs +++ b/src/Domain/Teams/Team.cs @@ -14,11 +14,11 @@ public class Team : AggregateRoot public TeamStatus Status { get; private set; } private readonly List _missions = []; - public IEnumerable Missions => _missions.AsReadOnly(); + public IReadOnlyList Missions => _missions.AsReadOnly(); private Mission? CurrentMission => _missions.FirstOrDefault(m => m.Status == MissionStatus.InProgress); private readonly List _heroes = []; - public IEnumerable Heroes => _heroes.AsReadOnly(); + public IReadOnlyList Heroes => _heroes.AsReadOnly(); private Team() { } diff --git a/src/Domain/Teams/TeamByIdSpec.cs b/src/Domain/Teams/TeamByIdSpec.cs new file mode 100644 index 0000000..b033629 --- /dev/null +++ b/src/Domain/Teams/TeamByIdSpec.cs @@ -0,0 +1,13 @@ +using Ardalis.Specification; + +namespace SSW.CleanArchitecture.Domain.Teams; + +public sealed class TeamByIdSpec : SingleResultSpecification +{ + public TeamByIdSpec(TeamId teamId) + { + Query.Where(t => t.Id == teamId) + .Include(t => t.Missions) + .Include(t => t.Heroes); + } +} \ No newline at end of file diff --git a/src/WebApi/Features/TeamEndpoints.cs b/src/WebApi/Features/TeamEndpoints.cs index 9c00c9f..dc60c32 100644 --- a/src/WebApi/Features/TeamEndpoints.cs +++ b/src/WebApi/Features/TeamEndpoints.cs @@ -1,7 +1,12 @@ using MediatR; +using SSW.CleanArchitecture.Application.Features.Teams.Commands.AddHeroToTeam; using SSW.CleanArchitecture.Application.Features.Teams.Commands.CreateTeam; using SSW.CleanArchitecture.Application.Features.Teams.Queries.GetAllTeams; +using SSW.CleanArchitecture.Application.Features.Teams.Queries.GetTeam; +using SSW.CleanArchitecture.Domain.Heroes; +using SSW.CleanArchitecture.Domain.Teams; using SSW.CleanArchitecture.WebApi.Extensions; +using TeamDto = SSW.CleanArchitecture.Application.Features.Teams.Queries.GetAllTeams.TeamDto; namespace SSW.CleanArchitecture.WebApi.Features; @@ -28,5 +33,27 @@ public static void MapTeamEndpoints(this WebApplication app) }) .WithName("GetAllTeams") .ProducesGet(); + + group + .MapPost("/{teamId:guid}/heroes/{heroId:guid}", + async (ISender sender, Guid teamId, Guid heroId, CancellationToken ct) => + { + var command = new AddHeroToTeamCommand(teamId, heroId); + await sender.Send(command, ct); + return Results.Created(); + }) + .WithName("AddHeroToTeam") + .ProducesPost(); + + group + .MapGet("/{teamId:guid}", + async (ISender sender, Guid teamId, CancellationToken ct) => + { + var query = new GetTeamQuery(teamId); + var results = await sender.Send(query, ct); + return Results.Ok(results); + }) + .WithName("GetTeam") + .ProducesGet(); } } \ No newline at end of file diff --git a/src/WebApi/WebApi.http b/src/WebApi/WebApi.http new file mode 100644 index 0000000..17cc118 --- /dev/null +++ b/src/WebApi/WebApi.http @@ -0,0 +1,38 @@ +@baseUrl = https://localhost:7255/api +@teamId = a3e403bb-082e-494c-81a4-1b1eac2d1d5c +@heroId = d6771ee7-d844-472c-90a0-6ccabf488630 + +### Get Heroes +GET {{baseUrl}}/heroes + +### Get Teams +GET {{baseUrl}}/teams + +### Add Hero to Team +POST {{baseUrl}}/teams/{{teamId}}/heroes/{{heroId}} + +### Get Team +GET {{baseUrl}}/teams/{{teamId}} + +### Create Team +POST {{baseUrl}}/teams +Content-Type: application/json + +{ + "name": "Dans A-Team" +} + +### Create Hero +POST {{baseUrl}}/heroes +Content-Type: application/json + +{ + "name": "Super Dan", + "alias": "Dan", + "powers": [ + { + "name": "Super Strength", + "powerLevel": 1 + } + ] +} diff --git a/tests/WebApi.IntegrationTests/Common/Fixtures/IntegrationTestBase.cs b/tests/WebApi.IntegrationTests/Common/Fixtures/IntegrationTestBase.cs index c258914..6e4e4f3 100644 --- a/tests/WebApi.IntegrationTests/Common/Fixtures/IntegrationTestBase.cs +++ b/tests/WebApi.IntegrationTests/Common/Fixtures/IntegrationTestBase.cs @@ -14,6 +14,10 @@ public abstract class IntegrationTestBase : IAsyncLifetime private readonly TestingDatabaseFixture _fixture; protected IMediator Mediator { get; } + + // TODO: Consider removing this as query results can be cached and cause bad test results + // Also, consider encapsulating this and only exposing a `Query` method that internally uses `AsNoTracking()` + // see: https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/324 protected ApplicationDbContext Context { get; } protected IntegrationTestBase(TestingDatabaseFixture fixture, ITestOutputHelper output) diff --git a/tests/WebApi.IntegrationTests/Common/Fixtures/ServiceCollectionExt.cs b/tests/WebApi.IntegrationTests/Common/Fixtures/ServiceCollectionExt.cs index cab45bc..b5b1528 100644 --- a/tests/WebApi.IntegrationTests/Common/Fixtures/ServiceCollectionExt.cs +++ b/tests/WebApi.IntegrationTests/Common/Fixtures/ServiceCollectionExt.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using SSW.CleanArchitecture.Infrastructure.Persistence.Interceptors; +using System.Diagnostics; namespace WebApi.IntegrationTests.Common.Fixtures; @@ -23,6 +24,9 @@ internal static IServiceCollection ReplaceDbContext( options.UseSqlServer(databaseContainer.ConnectionString, b => b.MigrationsAssembly(typeof(T).Assembly.FullName)); + options.LogTo(m => Debug.WriteLine(m)); + options.EnableSensitiveDataLogging(); + options.AddInterceptors( services.BuildServiceProvider().GetRequiredService() ); diff --git a/tests/WebApi.IntegrationTests/Endpoints/Teams/Commands/AddHeroToTeamCommandTests.cs b/tests/WebApi.IntegrationTests/Endpoints/Teams/Commands/AddHeroToTeamCommandTests.cs new file mode 100644 index 0000000..d7dd519 --- /dev/null +++ b/tests/WebApi.IntegrationTests/Endpoints/Teams/Commands/AddHeroToTeamCommandTests.cs @@ -0,0 +1,40 @@ +using Ardalis.Specification.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using SSW.CleanArchitecture.Application.Features.Teams.Commands.AddHeroToTeam; +using SSW.CleanArchitecture.Application.Features.Teams.Commands.CreateTeam; +using SSW.CleanArchitecture.Application.Features.Teams.Queries.GetTeam; +using SSW.CleanArchitecture.Domain.Teams; +using System.Net; +using System.Net.Http.Json; +using WebApi.IntegrationTests.Common.Factories; +using WebApi.IntegrationTests.Common.Fixtures; + +namespace WebApi.IntegrationTests.Endpoints.Teams.Commands; + +public class AddHeroToTeamCommandTests(TestingDatabaseFixture fixture, ITestOutputHelper output) + : IntegrationTestBase(fixture, output) +{ + [Fact] + public async Task Command_ShouldAddHeroToTeam() + { + // Arrange + var hero = HeroFactory.Generate(); + var team = TeamFactory.Generate(); + await AddEntityAsync(hero); + await AddEntityAsync(team); + var teamId = team.Id.Value; + var heroId = hero.Id.Value; + var client = GetAnonymousClient(); + + // Act + var result = await client.PostAsync($"/api/teams/{teamId}/heroes/{heroId}", null); + + // Assert + result.StatusCode.Should().Be(HttpStatusCode.Created); + + var updatedTeam = await client.GetFromJsonAsync($"/api/teams/{teamId}"); + updatedTeam.Should().NotBeNull(); + updatedTeam!.Heroes.Should().HaveCount(1); + updatedTeam.Heroes.First().Id.Should().Be(heroId); + } +} \ No newline at end of file diff --git a/tests/WebApi.IntegrationTests/Endpoints/Teams/Queries/GetAllHeroesQueryTests.cs b/tests/WebApi.IntegrationTests/Endpoints/Teams/Queries/GetAllTeamsQueryTests.cs similarity index 100% rename from tests/WebApi.IntegrationTests/Endpoints/Teams/Queries/GetAllHeroesQueryTests.cs rename to tests/WebApi.IntegrationTests/Endpoints/Teams/Queries/GetAllTeamsQueryTests.cs diff --git a/tools/Database/MockCurrentUserServiceProvider.cs b/tools/Database/MockCurrentUserServiceProvider.cs new file mode 100644 index 0000000..ea94213 --- /dev/null +++ b/tools/Database/MockCurrentUserServiceProvider.cs @@ -0,0 +1,8 @@ +using SSW.CleanArchitecture.Application.Common.Interfaces; + +namespace SSW.CleanArchitecture.Database; + +public class MockCurrentUserService : ICurrentUserService +{ + public string UserId => "00000000-0000-0000-0000-000000000000"; +} \ No newline at end of file diff --git a/tools/Database/Program.cs b/tools/Database/Program.cs index aa9a5e8..b9e9696 100644 --- a/tools/Database/Program.cs +++ b/tools/Database/Program.cs @@ -12,6 +12,10 @@ builder.ConfigureServices((context, services) => { + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + services.AddDbContext(options => { options.UseSqlServer(context.Configuration.GetConnectionString("DefaultConnection"), opt =>