Skip to content

Commit

Permalink
✨ 318 ddd add a hero to a team (#323)
Browse files Browse the repository at this point in the history
* AddHeroToTeam endpoint and command

* Added command to add hero to a team

* Added query to get a team

* Fixed warning

* Fixed issue with Database project.

* Fixed integration test

* Tidied up code

* Reverted test data entity numbers

* Replace IEnumerable<T> on aggregates to IReadonlyList<T>

* Update tests/WebApi.IntegrationTests/Common/Fixtures/IntegrationTestBase.cs

Co-authored-by: Matt Goldman [SSW] <[email protected]>

---------

Co-authored-by: Matt Goldman [SSW] <[email protected]>
  • Loading branch information
danielmackay and matt-goldman-ssw committed May 29, 2024
1 parent cf28e78 commit 585b0b4
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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<AddHeroToTeamCommand>
{
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<AddHeroToTeamCommand>
{
public AddHeroToTeamCommandValidator()
{
RuleFor(v => v.HeroId)
.NotEmpty();

RuleFor(v => v.TeamId)
.NotEmpty();
}
}
42 changes: 42 additions & 0 deletions src/Application/Features/Teams/Queries/GetTeam/GetTeamQuery.cs
Original file line number Diff line number Diff line change
@@ -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<TeamDto?>;

public sealed class GetAllTeamsQueryHandler(IApplicationDbContext dbContext) : IRequestHandler<GetTeamQuery, TeamDto?>
{
public async Task<TeamDto?> 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<HeroDto> Heroes { get; init; } = [];
}

public class HeroDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
}
2 changes: 1 addition & 1 deletion src/Domain/Heroes/Hero.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class Hero : AggregateRoot<HeroId>
public string Name { get; private set; } = null!;
public string Alias { get; private set; } = null!;
public int PowerLevel { get; private set; }
public IEnumerable<Power> Powers => _powers.AsReadOnly();
public IReadOnlyList<Power> Powers => _powers.AsReadOnly();

private Hero() { }

Expand Down
11 changes: 11 additions & 0 deletions src/Domain/Heroes/TeamByIdSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Ardalis.Specification;

namespace SSW.CleanArchitecture.Domain.Heroes;

public sealed class HeroByIdSpec : SingleResultSpecification<Hero>
{
public HeroByIdSpec(HeroId heroId)
{
Query.Where(t => t.Id == heroId);
}
}
4 changes: 2 additions & 2 deletions src/Domain/Teams/Team.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ public class Team : AggregateRoot<TeamId>
public TeamStatus Status { get; private set; }

private readonly List<Mission> _missions = [];
public IEnumerable<Mission> Missions => _missions.AsReadOnly();
public IReadOnlyList<Mission> Missions => _missions.AsReadOnly();
private Mission? CurrentMission => _missions.FirstOrDefault(m => m.Status == MissionStatus.InProgress);

private readonly List<Hero> _heroes = [];
public IEnumerable<Hero> Heroes => _heroes.AsReadOnly();
public IReadOnlyList<Hero> Heroes => _heroes.AsReadOnly();

private Team() { }

Expand Down
13 changes: 13 additions & 0 deletions src/Domain/Teams/TeamByIdSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Ardalis.Specification;

namespace SSW.CleanArchitecture.Domain.Teams;

public sealed class TeamByIdSpec : SingleResultSpecification<Team>
{
public TeamByIdSpec(TeamId teamId)
{
Query.Where(t => t.Id == teamId)
.Include(t => t.Missions)
.Include(t => t.Heroes);
}
}
27 changes: 27 additions & 0 deletions src/WebApi/Features/TeamEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -28,5 +33,27 @@ public static void MapTeamEndpoints(this WebApplication app)
})
.WithName("GetAllTeams")
.ProducesGet<TeamDto[]>();

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<TeamDto>();
}
}
38 changes: 38 additions & 0 deletions src/WebApi/WebApi.http
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -23,6 +24,9 @@ internal static class ServiceCollectionExt
options.UseSqlServer(databaseContainer.ConnectionString,
b => b.MigrationsAssembly(typeof(T).Assembly.FullName));
options.LogTo(m => Debug.WriteLine(m));
options.EnableSensitiveDataLogging();
options.AddInterceptors(
services.BuildServiceProvider().GetRequiredService<EntitySaveChangesInterceptor>()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TeamDto>($"/api/teams/{teamId}");
updatedTeam.Should().NotBeNull();
updatedTeam!.Heroes.Should().HaveCount(1);
updatedTeam.Heroes.First().Id.Should().Be(heroId);
}
}
8 changes: 8 additions & 0 deletions tools/Database/MockCurrentUserServiceProvider.cs
Original file line number Diff line number Diff line change
@@ -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";
}
4 changes: 4 additions & 0 deletions tools/Database/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

builder.ConfigureServices((context, services) =>
{
services.AddSingleton(TimeProvider.System);
services.AddScoped<ICurrentUserService, MockCurrentUserService>();
services.AddScoped<EntitySaveChangesInterceptor>();
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(context.Configuration.GetConnectionString("DefaultConnection"), opt =>
Expand Down

0 comments on commit 585b0b4

Please sign in to comment.