add full integration tests for flight services

This commit is contained in:
meysamhadeli 2022-05-20 23:11:12 +04:30
parent c7c9b2dd9e
commit f325dd18ee
28 changed files with 336 additions and 56 deletions

View File

@ -2,7 +2,8 @@ using BuildingBlocks.Domain.Event;
namespace BuildingBlocks.Contracts.EventBus.Messages;
public record FlightCreated(string FlightNumber) : IIntegrationEvent;
public record FlightUpdated(string FlightNumber) : IIntegrationEvent;
public record FlightCreated(long Id) : IIntegrationEvent;
public record FlightUpdated(long Id) : IIntegrationEvent;
public record FlightDeleted(long Id) : IIntegrationEvent;
public record AircraftCreated(long Id) : IIntegrationEvent;
public record AirportCreated(long Id) : IIntegrationEvent;

View File

@ -6,7 +6,7 @@ namespace BuildingBlocks.Domain.Model
{
}
public abstract class Aggregate<TId> : Auditable, IAggregate<TId>
public abstract class Aggregate<TId> : Entity, IAggregate<TId>
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
@ -27,8 +27,8 @@ namespace BuildingBlocks.Domain.Model
public virtual void When(object @event) { }
public TId Id { get; protected set; }
public long Version { get; protected set; } = -1;
public bool IsDeleted { get; protected set; }
public TId Id { get; protected set; }
}
}

View File

@ -1,9 +1,10 @@
namespace BuildingBlocks.Domain.Model;
public abstract class Auditable : IAuditable
public abstract class Entity : IEntity
{
public DateTime? CreatedAt { get; set; }
public long? CreatedBy { get; set; }
public DateTime? LastModified { get; set; }
public long? LastModifiedBy { get; set; }
public bool IsDeleted { get; set; }
}

View File

@ -3,12 +3,11 @@ using BuildingBlocks.EventStoreDB.Events;
namespace BuildingBlocks.Domain.Model
{
public interface IAggregate : IProjection, IAuditable
public interface IAggregate : IProjection, IEntity
{
IReadOnlyList<IDomainEvent> DomainEvents { get; }
IEvent[] ClearDomainEvents();
long Version { get; }
public bool IsDeleted { get; }
}
public interface IAggregate<out T> : IAggregate

View File

@ -1,11 +0,0 @@
namespace BuildingBlocks.Domain.Model;
public interface IAuditable : IEntity
{
public DateTime? CreatedAt { get; set; }
public long? CreatedBy { get; set; }
public DateTime? LastModified { get; set; }
public long? LastModifiedBy { get; set; }
}

View File

@ -2,10 +2,9 @@ namespace BuildingBlocks.Domain.Model;
public interface IEntity
{
}
public interface IEntity<out TId>
{
TId Id { get; }
public bool IsDeleted { get; }
public DateTime? CreatedAt { get; set; }
public long? CreatedBy { get; set; }
public DateTime? LastModified { get; set; }
public long? LastModifiedBy { get; set; }
public bool IsDeleted { get; set; }
}

View File

@ -21,18 +21,9 @@ public abstract class AppDbContextBase : DbContext, IDbContext
_httpContextAccessor = httpContextAccessor;
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(builder);
}
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
{
if (_currentTransaction != null)
{
return;
}
if (_currentTransaction != null) return;
_currentTransaction = await Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken);
}
@ -92,8 +83,17 @@ public abstract class AppDbContextBase : DbContext, IDbContext
return domainEvents.ToImmutableList();
}
// https://www.meziantou.net/entity-framework-core-generate-tracking-columns.htm
// https://www.meziantou.net/entity-framework-core-soft-delete-using-query-filters.htm
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(builder);
// ref: https://github.com/pdevito3/MessageBusTestingInMemHarness/blob/main/RecipeManagement/src/RecipeManagement/Databases/RecipesDbContext.cs
builder.FilterSoftDeletedProperties();
}
// ref: https://www.meziantou.net/entity-framework-core-generate-tracking-columns.htm
// ref: https://www.meziantou.net/entity-framework-core-soft-delete-using-query-filters.htm
private void OnBeforeSaving()
{
var nameIdentifier = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
@ -102,7 +102,7 @@ public abstract class AppDbContextBase : DbContext, IDbContext
foreach (var entry in ChangeTracker.Entries<IAggregate>())
{
bool isAuditable = entry.Entity.GetType().IsAssignableTo(typeof(IAggregate));
var isAuditable = entry.Entity.GetType().IsAssignableTo(typeof(IAggregate));
if (isAuditable)
{
@ -117,6 +117,13 @@ public abstract class AppDbContextBase : DbContext, IDbContext
entry.Entity.LastModifiedBy = userId;
entry.Entity.LastModified = DateTime.Now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.LastModifiedBy = userId;
entry.Entity.LastModified = DateTime.Now;
entry.Entity.IsDeleted = true;
break;
}
}
}

View File

@ -56,12 +56,12 @@ public class EfTxBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TRe
nameof(EfTxBehavior<TRequest, TResponse>),
typeof(TRequest).FullName);
await _dbContextBase.CommitTransactionAsync(cancellationToken);
var domainEvents = _dbContextBase.GetDomainEvents();
await _busPublisher.SendAsync(domainEvents.ToArray(), cancellationToken);
await _dbContextBase.CommitTransactionAsync(cancellationToken);
return response;
}
catch

View File

@ -1,4 +1,7 @@
using System.Linq.Expressions;
using BuildingBlocks.Domain.Model;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -20,4 +23,22 @@ public static class Extensions
return services;
}
// ref: https://github.com/pdevito3/MessageBusTestingInMemHarness/blob/main/RecipeManagement/src/RecipeManagement/Databases/RecipesDbContext.cs
public static void FilterSoftDeletedProperties(this ModelBuilder modelBuilder)
{
Expression<Func<IAggregate, bool>> filterExpr = e => !e.IsDeleted;
foreach (var mutableEntityType in modelBuilder.Model.GetEntityTypes()
.Where(m => m.ClrType.IsAssignableTo(typeof(IEntity))))
{
// modify expression to handle correct child type
var parameter = Expression.Parameter(mutableEntityType.ClrType);
var body = ReplacingExpressionVisitor
.Replace(filterExpr.Parameters.First(), parameter, filterExpr.Body);
var lambdaExpression = Expression.Lambda(body, parameter);
// set filter
mutableEntityType.SetQueryFilter(lambdaExpression);
}
}
}

View File

@ -3,6 +3,6 @@ using BuildingBlocks.Domain.Model;
namespace BuildingBlocks.Mongo;
public interface IMongoRepository<TEntity, in TId> : IRepository<TEntity, TId>
where TEntity : class, IEntity<TId>
where TEntity : class, IAggregate<TId>
{
}

View File

@ -4,7 +4,7 @@ using BuildingBlocks.Domain.Model;
namespace BuildingBlocks.Mongo;
public interface IReadRepository<TEntity, in TId>
where TEntity : class, IEntity<TId>
where TEntity : class, IAggregate<TId>
{
Task<TEntity?> FindByIdAsync(TId id, CancellationToken cancellationToken = default);
@ -25,7 +25,7 @@ public interface IReadRepository<TEntity, in TId>
}
public interface IWriteRepository<TEntity, in TId>
where TEntity : class, IEntity<TId>
where TEntity : class, IAggregate<TId>
{
Task<TEntity> AddAsync(TEntity entity, CancellationToken cancellationToken = default);
Task<TEntity> UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
@ -39,11 +39,11 @@ public interface IRepository<TEntity, in TId> :
IReadRepository<TEntity, TId>,
IWriteRepository<TEntity, TId>,
IDisposable
where TEntity : class, IEntity<TId>
where TEntity : class, IAggregate<TId>
{
}
public interface IRepository<TEntity> : IRepository<TEntity, long>
where TEntity : class, IEntity<long>
where TEntity : class, IAggregate<long>
{
}

View File

@ -5,7 +5,7 @@ using MongoDB.Driver;
namespace BuildingBlocks.Mongo;
public class MongoRepository<TEntity, TId> : IMongoRepository<TEntity, TId>
where TEntity : class, IEntity<TId>
where TEntity : class, IAggregate<TId>
{
private readonly IMongoDbContext _context;
protected readonly IMongoCollection<TEntity> DbSet;

View File

@ -18,8 +18,9 @@ public sealed class EventMapper : IEventMapper
{
return @event switch
{
FlightCreatedDomainEvent e => new FlightCreated(e.FlightNumber),
FlightUpdatedDomainEvent e => new FlightUpdated(e.FlightNumber),
FlightCreatedDomainEvent e => new FlightCreated(e.Id),
FlightUpdatedDomainEvent e => new FlightUpdated(e.Id),
FlightDeletedDomainEvent e => new FlightDeleted(e.Id),
AirportCreatedDomainEvent e => new AirportCreated(e.Id),
AircraftCreatedDomainEvent e => new AircraftCreated(e.Id),
_ => null

View File

@ -0,0 +1,9 @@
using System;
using BuildingBlocks.Domain.Event;
using Flight.Flights.Models;
namespace Flight.Flights.Events.Domain;
public record FlightDeletedDomainEvent(long Id, string FlightNumber, long AircraftId, DateTime DepartureDate,
long DepartureAirportId, DateTime ArriveDate, long ArriveAirportId, decimal DurationMinutes,
DateTime FlightDate, FlightStatus Status, decimal Price, bool IsDeleted) : IDomainEvent;

View File

@ -33,7 +33,7 @@ public class CreateFlightCommandHandler : IRequestHandler<CreateFlightCommand, F
throw new FlightAlreadyExistException();
var flightEntity = Models.Flight.Create(command.Id, command.FlightNumber, command.AircraftId, command.DepartureAirportId, command.DepartureDate,
command.ArriveDate, command.ArriveAirportId, command.DurationMinutes, command.FlightDate, FlightStatus.Completed, command.Price);
command.ArriveDate, command.ArriveAirportId, command.DurationMinutes, command.FlightDate, command.Status, command.Price);
var newFlight = await _flightDbContext.Flights.AddAsync(flightEntity, cancellationToken);

View File

@ -0,0 +1,6 @@
using Flight.Flights.Dtos;
using MediatR;
namespace Flight.Flights.Features.DeleteFlight;
public record DeleteFlightCommand(long Id) : IRequest<FlightResponseDto>;

View File

@ -0,0 +1,43 @@
using System.Threading;
using System.Threading.Tasks;
using Ardalis.GuardClauses;
using Flight.Data;
using Flight.Flights.Dtos;
using Flight.Flights.Exceptions;
using Flight.Flights.Models;
using MapsterMapper;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Flight.Flights.Features.DeleteFlight;
public class DeleteFlightCommandHandler : IRequestHandler<DeleteFlightCommand, FlightResponseDto>
{
private readonly FlightDbContext _flightDbContext;
private readonly IMapper _mapper;
public DeleteFlightCommandHandler(IMapper mapper, FlightDbContext flightDbContext)
{
_mapper = mapper;
_flightDbContext = flightDbContext;
}
public async Task<FlightResponseDto> Handle(DeleteFlightCommand command, CancellationToken cancellationToken)
{
Guard.Against.Null(command, nameof(command));
var flight = await _flightDbContext.Flights.SingleOrDefaultAsync(x => x.Id == command.Id, cancellationToken);
if (flight is null)
throw new FlightNotFountException();
var deleteFlight = _flightDbContext.Flights.Remove(flight).Entity;
flight.Delete(deleteFlight.Id, deleteFlight.FlightNumber, deleteFlight.AircraftId, deleteFlight.DepartureAirportId,
deleteFlight.DepartureDate, deleteFlight.ArriveDate, deleteFlight.ArriveAirportId, deleteFlight.DurationMinutes,
deleteFlight.FlightDate, deleteFlight.Status, deleteFlight.Price);
return _mapper.Map<FlightResponseDto>(deleteFlight);
}
}

View File

@ -0,0 +1,14 @@
using FluentValidation;
namespace Flight.Flights.Features.DeleteFlight;
public class DeleteFlightCommandValidator : AbstractValidator<DeleteFlightCommand>
{
public DeleteFlightCommandValidator()
{
CascadeMode = CascadeMode.Stop;
RuleFor(x => x.Id).NotEmpty();
}
}

View File

@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using BuildingBlocks.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace Flight.Flights.Features.DeleteFlight;
[Route(BaseApiPath + "/flight")]
public class DeleteFlightEndpoint : BaseController
{
[Authorize]
[HttpDelete]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(Summary = "Delete flight", Description = "Delete flight")]
public async Task<ActionResult> Update(DeleteFlightCommand command, CancellationToken cancellationToken)
{
var result = await Mediator.Send(command, cancellationToken);
return Ok(result);
}
}

View File

@ -35,7 +35,7 @@ public class UpdateFlightCommandHandler : IRequestHandler<UpdateFlightCommand, F
flight.Update(command.Id, command.FlightNumber, command.AircraftId, command.DepartureAirportId, command.DepartureDate,
command.ArriveDate, command.ArriveAirportId, command.DurationMinutes, command.FlightDate, FlightStatus.Completed, command.Price, command.IsDeleted);
command.ArriveDate, command.ArriveAirportId, command.DurationMinutes, command.FlightDate, command.Status, command.Price, command.IsDeleted);
var updateFlight = _flightDbContext.Flights.Update(flight);

View File

@ -71,4 +71,27 @@ public class Flight : Aggregate<long>
AddDomainEvent(@event);
}
public void Delete(long id, string flightNumber, long aircraftId,
long departureAirportId, DateTime departureDate, DateTime arriveDate,
long arriveAirportId, decimal durationMinutes, DateTime flightDate, FlightStatus status,
decimal price, bool isDeleted = true)
{
FlightNumber = flightNumber;
AircraftId = aircraftId;
DepartureAirportId = departureAirportId;
DepartureDate = departureDate;
arriveDate = ArriveDate;
ArriveAirportId = arriveAirportId;
DurationMinutes = durationMinutes;
FlightDate = flightDate;
Status = status;
Price = price;
IsDeleted = isDeleted;
var @event = new FlightDeletedDomainEvent(id, flightNumber, aircraftId, departureDate, departureAirportId,
arriveDate, arriveAirportId, durationMinutes, flightDate, status, price, isDeleted);
AddDomainEvent(@event);
}
}

View File

@ -0,0 +1,34 @@
using System.Threading.Tasks;
using BuildingBlocks.Contracts.EventBus.Messages;
using FluentAssertions;
using Integration.Test.Fakes;
using Xunit;
namespace Integration.Test.Aircraft;
[Collection(nameof(TestFixture))]
public class CreateAircraftTests
{
private readonly TestFixture _fixture;
public CreateAircraftTests(TestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task should_create_new_aircraft_to_db_and_publish_message_to_broker()
{
// Arrange
var command = new FakeCreateAircraftCommand().Generate();
// Act
var aircraftResponse = await _fixture.SendAsync(command);
// Assert
aircraftResponse.Should().NotBeNull();
aircraftResponse?.Name.Should().Be(command.Name);
(await _fixture.IsFaultyPublished<AircraftCreated>()).Should().BeFalse();
(await _fixture.IsPublished<AircraftCreated>()).Should().BeTrue();
}
}

View File

@ -0,0 +1,34 @@
using System.Threading.Tasks;
using BuildingBlocks.Contracts.EventBus.Messages;
using FluentAssertions;
using Integration.Test.Fakes;
using Xunit;
namespace Integration.Test.Airport;
[Collection(nameof(TestFixture))]
public class CreateAirportTests
{
private readonly TestFixture _fixture;
public CreateAirportTests(TestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task should_create_new_airport_to_db_and_publish_message_to_broker()
{
// Arrange
var command = new FakeCreateAirportCommand().Generate();
// Act
var airportResponse = await _fixture.SendAsync(command);
// Assert
airportResponse.Should().NotBeNull();
airportResponse?.Name.Should().Be(command.Name);
(await _fixture.IsFaultyPublished<AirportCreated>()).Should().BeFalse();
(await _fixture.IsPublished<AirportCreated>()).Should().BeTrue();
}
}

View File

@ -0,0 +1,13 @@
using AutoBogus;
using BuildingBlocks.IdsGenerator;
using Flight.Aircrafts.Features.CreateAircraft;
namespace Integration.Test.Fakes;
public class FakeCreateAircraftCommand : AutoFaker<CreateAircraftCommand>
{
public FakeCreateAircraftCommand()
{
RuleFor(r => r.Id, _ => SnowFlakIdGenerator.NewId());
}
}

View File

@ -0,0 +1,13 @@
using AutoBogus;
using BuildingBlocks.IdsGenerator;
using Flight.Airports.Features.CreateAirport;
namespace Integration.Test.Fakes;
public class FakeCreateAirportCommand : AutoFaker<CreateAirportCommand>
{
public FakeCreateAirportCommand()
{
RuleFor(r => r.Id, _ => SnowFlakIdGenerator.NewId());
}
}

View File

@ -1,4 +1,5 @@
using AutoBogus;
using BuildingBlocks.IdsGenerator;
using Flight.Flights.Features.CreateFlight;
namespace Integration.Test.Fakes;
@ -7,7 +8,7 @@ public sealed class FakeCreateFlightCommand : AutoFaker<CreateFlightCommand>
{
public FakeCreateFlightCommand()
{
RuleFor(r => r.Id, r => r.Random.Number(50, 100000));
RuleFor(r => r.Id, _ => SnowFlakIdGenerator.NewId());
RuleFor(r => r.FlightNumber, r => r.Random.String());
RuleFor(r => r.DepartureAirportId, _ => 1);
RuleFor(r => r.ArriveAirportId, _ => 2);

View File

@ -21,11 +21,7 @@ public class CreateFlightTests
public async Task should_create_new_flight_to_db_and_publish_message_to_broker()
{
// Arrange
var fakeFlight = new FakeCreateFlightCommand().Generate();
var command = new CreateFlightCommand(fakeFlight.FlightNumber, fakeFlight.AircraftId,
fakeFlight.DepartureAirportId, fakeFlight.DepartureDate,
fakeFlight.ArriveDate, fakeFlight.ArriveAirportId, fakeFlight.DurationMinutes, fakeFlight.FlightDate,
fakeFlight.Status, fakeFlight.Price);
var command = new FakeCreateFlightCommand().Generate();
// Act
var flightResponse = await _fixture.SendAsync(command);

View File

@ -0,0 +1,50 @@
using System.Linq;
using System.Threading.Tasks;
using BuildingBlocks.Contracts.EventBus.Messages;
using Flight.Flights.Features.CreateFlight;
using Flight.Flights.Features.DeleteFlight;
using FluentAssertions;
using Integration.Test.Fakes;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace Integration.Test.Flight;
[Collection(nameof(TestFixture))]
public class DeleteFlightTests
{
private readonly TestFixture _fixture;
public DeleteFlightTests(TestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task should_delete_flight_from_db()
{
// Arrange
var createFlightCommand = new FakeCreateFlightCommand().Generate();
var flightEntity = global::Flight.Flights.Models.Flight.Create(
createFlightCommand.Id, createFlightCommand.FlightNumber, createFlightCommand.AircraftId, createFlightCommand.DepartureAirportId,
createFlightCommand.DepartureDate, createFlightCommand.ArriveDate, createFlightCommand.ArriveAirportId, createFlightCommand.DurationMinutes,
createFlightCommand.FlightDate, createFlightCommand.Status, createFlightCommand.Price);
await _fixture.InsertAsync(flightEntity);
var command = new DeleteFlightCommand(flightEntity.Id);
// Act
await _fixture.SendAsync(command);
var deletedFlight = (await _fixture.ExecuteDbContextAsync(db => db.Flights
.Where(x => x.Id == command.Id)
.IgnoreQueryFilters()
.ToListAsync())
).FirstOrDefault();
// Assert
deletedFlight?.IsDeleted.Should().BeTrue();
(await _fixture.IsFaultyPublished<FlightDeleted>()).Should().BeFalse();
(await _fixture.IsPublished<FlightDeleted>()).Should().BeTrue();
}
}