Skip to content

Commit

Permalink
✨ 320 ddd complete a mission 2 (#326)
Browse files Browse the repository at this point in the history
* Added endpoint for completing a mission.  Updated ApiFilter validation to handle DomainException.

* Fixed up tests
  • Loading branch information
danielmackay committed May 29, 2024
1 parent e4f8a7b commit e46ce92
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using SSW.CleanArchitecture.Application.Common.Exceptions;
using SSW.CleanArchitecture.Application.Common.Interfaces;
using SSW.CleanArchitecture.Domain.Teams;

namespace SSW.CleanArchitecture.Application.Features.Teams.Commands.CompleteMission;

public sealed record CompleteMissionCommand(Guid TeamId) : IRequest;

// ReSharper disable once UnusedType.Global
public sealed class CompleteMissionCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<CompleteMissionCommand>
{
public async Task Handle(CompleteMissionCommand request, CancellationToken cancellationToken)
{
var teamId = new TeamId(request.TeamId);
var team = dbContext.Teams
.WithSpecification(new TeamByIdSpec(teamId))
.FirstOrDefault();

if (team is null)
{
throw new NotFoundException(nameof(Team), teamId);
}

team.CompleteCurrentMission();
await dbContext.SaveChangesAsync(cancellationToken);
}
}

public class CompleteMissionCommandValidator : AbstractValidator<CompleteMissionCommand>
{
public CompleteMissionCommandValidator()
{
RuleFor(v => v.TeamId)
.NotEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

namespace SSW.CleanArchitecture.Application.Features.Teams.Commands.ExecuteMission;

public sealed record ExecuteMissionCommand(Guid TeamId, string Description) : IRequest<Guid>;
public sealed record ExecuteMissionCommand(Guid TeamId, string Description) : IRequest;

// ReSharper disable once UnusedType.Global
public sealed class ExecuteMissionCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<ExecuteMissionCommand, Guid>
: IRequestHandler<ExecuteMissionCommand>
{
public async Task<Guid> Handle(ExecuteMissionCommand request, CancellationToken cancellationToken)
public async Task Handle(ExecuteMissionCommand request, CancellationToken cancellationToken)
{
var teamId = new TeamId(request.TeamId);
var team = dbContext.Teams
Expand All @@ -24,8 +24,6 @@ public async Task<Guid> Handle(ExecuteMissionCommand request, CancellationToken

team.ExecuteMission(request.Description);
await dbContext.SaveChangesAsync(cancellationToken);

return team.Missions.First().Id.Value;
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/Domain/Common/DomainException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace SSW.CleanArchitecture.Domain.Common;

public class DomainException : Exception
{
public DomainException()
{
}

public DomainException(string message) : base(message)
{
}

public DomainException(string message, Exception innerException) : base(message, innerException)
{
}
}
3 changes: 2 additions & 1 deletion src/Domain/Teams/Mission.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ardalis.GuardClauses;
using SSW.CleanArchitecture.Domain.Common;
using SSW.CleanArchitecture.Domain.Common.Base;

namespace SSW.CleanArchitecture.Domain.Teams;
Expand Down Expand Up @@ -28,7 +29,7 @@ internal void Complete()
{
if (Status == MissionStatus.Complete)
{
throw new InvalidOperationException("Mission is already completed");
throw new DomainException("Mission is already completed");
}

Status = MissionStatus.Complete;
Expand Down
7 changes: 4 additions & 3 deletions src/Domain/Teams/Team.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ardalis.GuardClauses;
using SSW.CleanArchitecture.Domain.Common;
using SSW.CleanArchitecture.Domain.Common.Base;
using SSW.CleanArchitecture.Domain.Heroes;

Expand Down Expand Up @@ -54,7 +55,7 @@ public void ExecuteMission(string description)

if (Status != TeamStatus.Available)
{
throw new InvalidOperationException("The team is currently not available for a new mission.");
throw new DomainException("The team is currently not available for a new mission.");
}

var mission = Mission.Create(description);
Expand All @@ -66,12 +67,12 @@ public void CompleteCurrentMission()
{
if (Status != TeamStatus.OnMission)
{
throw new InvalidOperationException("The team is currently not on a mission.");
throw new DomainException("The team is currently not on a mission.");
}

if (CurrentMission is null)
{
throw new InvalidOperationException("There is no mission in progress.");
throw new DomainException("There is no mission in progress.");
}

CurrentMission.Complete();
Expand Down
23 changes: 17 additions & 6 deletions src/WebApi/Features/TeamEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using SSW.CleanArchitecture.Application.Features.Teams.Commands.AddHeroToTeam;
using SSW.CleanArchitecture.Application.Features.Teams.Commands.CompleteMission;
using SSW.CleanArchitecture.Application.Features.Teams.Commands.CreateTeam;
using SSW.CleanArchitecture.Application.Features.Teams.Commands.ExecuteMission;
using SSW.CleanArchitecture.Application.Features.Teams.Queries.GetAllTeams;
Expand Down Expand Up @@ -42,7 +43,7 @@ public static void MapTeamEndpoints(this WebApplication app)
{
var command = new AddHeroToTeamCommand(teamId, heroId);
await sender.Send(command, ct);
return Results.Created();
return Results.Ok();
})
.WithName("AddHeroToTeam")
.ProducesPost();
Expand All @@ -59,15 +60,25 @@ public static void MapTeamEndpoints(this WebApplication app)
.ProducesGet<TeamDto>();

group
.MapPost("/{teamId:guid}/missions",
async (ISender sender, Guid teamId, [FromBody]ExcuteMissionRequest request, CancellationToken ct) =>
.MapPost("/{teamId:guid}/execute-mission",
async (ISender sender, Guid teamId, [FromBody] ExcuteMissionRequest request, CancellationToken ct) =>
{
var command = new ExecuteMissionCommand(teamId, request.Description);
var response = await sender.Send(command, ct);
return Results.Ok(response);
var command = new ExecuteMissionCommand(teamId, request.Description); await sender.Send(command, ct);
return Results.Ok();
})
.WithName("ExecuteMission")
.ProducesPost();

group
.MapPost("/{teamId:guid}/complete-mission",
async (ISender sender, Guid teamId, CancellationToken ct) =>
{
var command = new CompleteMissionCommand(teamId);
await sender.Send(command, ct);
return Results.Ok();
})
.WithName("CompleteMission")
.ProducesPost();
}
}

Expand Down
14 changes: 13 additions & 1 deletion src/WebApi/Filters/KnownExceptionsHandler.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using Microsoft.AspNetCore.Diagnostics;
using SSW.CleanArchitecture.Application.Common.Exceptions;
using SSW.CleanArchitecture.Domain.Common;

namespace SSW.CleanArchitecture.WebApi.Filters;

public class KnownExceptionsHandler : IExceptionHandler
{
private static readonly IDictionary<Type, Func<HttpContext, Exception, IResult>> ExceptionHandlers = new Dictionary<Type, Func<HttpContext, Exception, IResult>>
{
{ typeof(NotFoundException), HandleNotFoundException },
{ typeof(ValidationException), HandleValidationException },
{ typeof(NotFoundException), HandleNotFoundException }
{ typeof(DomainException), HandleDomainException },
};

public async ValueTask<bool> TryHandleAsync(
Expand Down Expand Up @@ -36,6 +38,16 @@ private static IResult HandleValidationException(HttpContext context, Exception
type: "https://tools.ietf.org/html/rfc7231#section-6.5.1");
}

private static IResult HandleDomainException(HttpContext context, Exception exception)
{
var domainException = exception as DomainException ?? throw new InvalidOperationException("Exception is not of type ValidationException");

return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
title: "A domain error occurred.",
type: "https://tools.ietf.org/html/rfc7231#section-6.5.1",
detail: domainException.Message);
}

private static IResult HandleNotFoundException(HttpContext context, Exception exception) =>
Results.Problem(statusCode: StatusCodes.Status404NotFound,
title: "The specified resource was not found.",
Expand Down
5 changes: 4 additions & 1 deletion src/WebApi/WebApi.http
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ Content-Type: application/json
}

### Execute Mission
POST {{baseUrl}}/teams/{{teamId}}/missions
POST {{baseUrl}}/teams/{{teamId}}/execute-mission
Content-Type: application/json

{
"description": "Save the world from the evil villains"
}

### Complete Mission
POST {{baseUrl}}/teams/{{teamId}}/complete-mission
3 changes: 2 additions & 1 deletion tests/Domain.UnitTests/Teams/TeamTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using SSW.CleanArchitecture.Domain.Common;
using SSW.CleanArchitecture.Domain.Heroes;
using SSW.CleanArchitecture.Domain.Teams;

Expand Down Expand Up @@ -132,6 +133,6 @@ public void CompleteCurrentMission_WhenNoMissionHasBeenExecuted_ShouldThrow()
var act = () => team.CompleteCurrentMission();

// Assert
act.Should().Throw<InvalidOperationException>().WithMessage("The team is currently not on a mission.");
act.Should().Throw<DomainException>().WithMessage("The team is currently not on a mission.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ public async Task Command_ShouldAddHeroToTeam()
var result = await client.PostAsync($"/api/teams/{teamId}/heroes/{heroId}", null);

// Assert
result.StatusCode.Should().Be(HttpStatusCode.Created);
var updatedTeam = await GetQueryable<Team>()
.WithSpecification(new TeamByIdSpec(team.Id))
.FirstOrDefaultAsync();

var updatedTeam = await client.GetFromJsonAsync<TeamDto>($"/api/teams/{teamId}");
result.StatusCode.Should().Be(HttpStatusCode.OK);
updatedTeam.Should().NotBeNull();
updatedTeam!.Heroes.Should().HaveCount(1);
updatedTeam.Heroes.First().Id.Should().Be(heroId);
updatedTeam.Heroes.First().Id.Should().Be(hero.Id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SSW.CleanArchitecture.Domain.Teams;
using System.Net;
using WebApi.IntegrationTests.Common.Factories;
using WebApi.IntegrationTests.Common.Fixtures;

namespace WebApi.IntegrationTests.Endpoints.Teams.Commands;

public class CompleteMissionCommandTests(TestingDatabaseFixture fixture, ITestOutputHelper output)
: IntegrationTestBase(fixture, output)
{
[Fact]
public async Task Command_ShouldCompleteMission()
{
// Arrange
var hero = HeroFactory.Generate();
var team = TeamFactory.Generate();
team.AddHero(hero);
team.ExecuteMission("Save the world");
await AddEntityAsync(team);
var teamId = team.Id.Value;
var client = GetAnonymousClient();

// Act
var result = await client.PostAsync($"/api/teams/{teamId}/complete-mission", null);

// Assert
var updatedTeam = await GetQueryable<Team>()
.WithSpecification(new TeamByIdSpec(team.Id))
.FirstOrDefaultAsync();
var mission = updatedTeam!.Missions.First();

result.StatusCode.Should().Be(HttpStatusCode.OK);
updatedTeam!.Missions.Should().HaveCount(1);
updatedTeam.Status.Should().Be(TeamStatus.Available);
mission.Status.Should().Be(MissionStatus.Complete);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Ardalis.Specification.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SSW.CleanArchitecture.Domain.Teams;
using SSW.CleanArchitecture.WebApi.Features;
Expand All @@ -17,24 +18,24 @@ public async Task Command_ShouldExecuteMission()
// Arrange
var hero = HeroFactory.Generate();
var team = TeamFactory.Generate();
await AddEntityAsync(hero);
await AddEntityAsync(team);
team.AddHero(hero);
await Context.SaveChangesAsync();
await AddEntityAsync(team);
var teamId = team.Id.Value;
var client = GetAnonymousClient();
var request = new ExcuteMissionRequest("Save the world");

// Act
var result = await client.PostAsJsonAsync($"/api/teams/{teamId}/missions", request);
var result = await client.PostAsJsonAsync($"/api/teams/{teamId}/execute-mission", request);

// Assert
var response = await result.Content.ReadFromJsonAsync<Guid>();
var missionId = new MissionId(response);
var mission = await GetQueryable<Mission>().FirstOrDefaultAsync(m => m.Id == missionId);
var updatedTeam = await GetQueryable<Team>()
.WithSpecification(new TeamByIdSpec(team.Id))
.FirstOrDefaultAsync();
var mission = updatedTeam!.Missions.First();

result.StatusCode.Should().Be(HttpStatusCode.OK);
mission.Should().NotBeNull();
mission!.Description.Should().Be(request.Description);
updatedTeam!.Missions.Should().HaveCount(1);
updatedTeam.Status.Should().Be(TeamStatus.OnMission);
mission.Status.Should().Be(MissionStatus.InProgress);
}
}

0 comments on commit e46ce92

Please sign in to comment.