diff --git a/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs b/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs new file mode 100644 index 0000000..40c0ecf --- /dev/null +++ b/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using SSW.CleanArchitecture.Application.Common.Exceptions; +using SSW.CleanArchitecture.Application.Common.Interfaces; +using SSW.CleanArchitecture.Domain.Heroes; + +namespace SSW.CleanArchitecture.Application.Features.Heroes.Commands.UpdateHero; + +public sealed record UpdateHeroCommand( + HeroId HeroId, + string Name, + string Alias, + IEnumerable Powers) : IRequest; + +// ReSharper disable once UnusedType.Global +public sealed class UpdateHeroCommandHandler(IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(UpdateHeroCommand request, CancellationToken cancellationToken) + { + var hero = await dbContext.Heroes + .Include(h => h.Powers) + .FirstOrDefaultAsync(h => h.Id == request.HeroId, cancellationToken); + + if (hero is null) + { + throw new NotFoundException(nameof(Hero), request.HeroId); + } + + hero.UpdateName(request.Name); + hero.UpdateAlias(request.Alias); + var existingPowers = hero.Powers.Select(p => p.Name).ToList(); + foreach (var existingPower in existingPowers) + { + hero.RemovePower(existingPower); + } + + foreach (var heroPowerModel in request.Powers) + { + hero.AddPower(new Power(heroPowerModel.Name, heroPowerModel.PowerLevel)); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return hero.Id.Value; + } +} \ No newline at end of file diff --git a/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroPowerDto.cs b/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroPowerDto.cs new file mode 100644 index 0000000..06b985b --- /dev/null +++ b/src/Application/Features/Heroes/Commands/UpdateHero/UpdateHeroPowerDto.cs @@ -0,0 +1,7 @@ +namespace SSW.CleanArchitecture.Application.Features.Heroes.Commands.UpdateHero; + +public class UpdateHeroPowerDto +{ + public required string Name { get; set; } + public required int PowerLevel { get; set; } +} \ No newline at end of file diff --git a/src/WebApi/Features/HeroEndpoints.cs b/src/WebApi/Features/HeroEndpoints.cs index 891e9b2..ad06f6a 100644 --- a/src/WebApi/Features/HeroEndpoints.cs +++ b/src/WebApi/Features/HeroEndpoints.cs @@ -1,5 +1,6 @@ using MediatR; using SSW.CleanArchitecture.Application.Features.Heroes.Commands.CreateHero; +using SSW.CleanArchitecture.Application.Features.Heroes.Commands.UpdateHero; using SSW.CleanArchitecture.Application.Features.Heroes.Queries.GetAllHeroes; using SSW.CleanArchitecture.WebApi.Extensions; @@ -24,6 +25,15 @@ public static void MapHeroEndpoints(this WebApplication app) // myWeirdField: "string" vs myWeirdField: "this-silly-string" // (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/79) + group + .MapPut("/", async (ISender sender, UpdateHeroCommand command, CancellationToken ct) => + { + await sender.Send(command, ct); + return Results.NoContent(); + }) + .WithName("UpdateHero") + .ProducesPut(); + group .MapPost("/", async (ISender sender, CreateHeroCommand command, CancellationToken ct) => { diff --git a/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/CreateHero/CreateHeroCommandTests.cs b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/CreateHero/CreateHeroCommandTests.cs index 7198be6..acaa48b 100644 --- a/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/CreateHero/CreateHeroCommandTests.cs +++ b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/CreateHero/CreateHeroCommandTests.cs @@ -29,7 +29,7 @@ public async Task Command_ShouldCreateHero() // Assert result.StatusCode.Should().Be(HttpStatusCode.Created); - var item = await Context.Heroes.FirstAsync(); + var item = await Context.Heroes.AsNoTracking().FirstAsync(); item.Should().NotBeNull(); item.Name.Should().Be(cmd.Name); diff --git a/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHero/UpdateHeroCommandTests.cs b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHero/UpdateHeroCommandTests.cs new file mode 100644 index 0000000..9ee4ef3 --- /dev/null +++ b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHero/UpdateHeroCommandTests.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using SSW.CleanArchitecture.Application.Features.Heroes.Commands.UpdateHero; +using SSW.CleanArchitecture.Domain.Heroes; +using System.Net; +using System.Net.Http.Json; +using WebApi.IntegrationTests.Common.Factories; +using WebApi.IntegrationTests.Common.Fixtures; + +namespace WebApi.IntegrationTests.Endpoints.Heroes.Commands.UpdateHero; + +public class UpdateHeroCommandTests(TestingDatabaseFixture fixture, ITestOutputHelper output) + : IntegrationTestBase(fixture, output) +{ + [Fact] + public async Task Command_ShouldUpdateHero() + { + // Arrange + var heroName = "2021-01-01T00:00:00Z"; + var heroAlias = "2021-01-01T00:00:00Z-alias"; + var hero = HeroFactory.Generate(); + await AddEntityAsync(hero); + (string Name, int PowerLevel)[] powers = [ + ("Heat vision", 7), + ("Super-strength", 10), + ("Flight", 8), + ]; + var cmd = new UpdateHeroCommand( + hero.Id, + heroName, + heroAlias, + powers.Select(p => new UpdateHeroPowerDto { Name = p.Name, PowerLevel = p.PowerLevel })); + var client = GetAnonymousClient(); + var createdTimeStamp = DateTime.Now; + + // Act + var result = await client.PutAsJsonAsync("/heroes", cmd); + + // Assert + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + Hero item = await Context.Heroes.AsNoTracking().FirstAsync(dbHero => dbHero.Id == hero.Id); + + item.Should().NotBeNull(); + item.Name.Should().Be(cmd.Name); + item.Alias.Should().Be(cmd.Alias); + item.PowerLevel.Should().Be(25); + item.Powers.Should().HaveCount(3); + item.UpdatedAt.Should().NotBe(hero.CreatedAt); + item.UpdatedAt.Should().BeCloseTo(createdTimeStamp, TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task Command_WhenHeroDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var heroId = new HeroId(Guid.NewGuid()); + var cmd = new UpdateHeroCommand( + heroId, + "foo", + "bar", + new [] { new UpdateHeroPowerDto { Name = "Heat vision", PowerLevel = 7 } }); + var client = GetAnonymousClient(); + + // Act + var result = await client.PutAsJsonAsync("/heroes", cmd); + + // Assert + result.StatusCode.Should().Be(HttpStatusCode.NotFound); + Hero? item = await Context.Heroes.AsNoTracking().FirstOrDefaultAsync(dbHero => dbHero.Id == heroId); + + item.Should().BeNull(); + } +} \ No newline at end of file