diff --git a/README.md b/README.md index 63f011d2..c57f21b4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Full Modular Monolith .NET application with Domain-Driven Design approach.   [3.13 Integration Tests](#313-integration-tests) +  [3.14 System Integration Testing](#314-system-integration-testing) + [4. Technology](#4-technology) [5. How to Run](#5-how-to-run) @@ -1052,6 +1054,170 @@ public class MeetingPaymentTests : TestBase Each Command/Query processing is a separate execution (with different object graph resolution, context, database connection etc.) thanks to Composition Root of each module. This behavior is important and desirable. +### 3.14 System Integration Testing + +#### Definition +[System Integration Testing (SIT)](https://en.wikipedia.org/wiki/System_integration_testing) is performed to verify the interactions between the modules of a software system. It involves the overall testing of a complete system of many subsystem components or elements. + +#### Implementation + +Implementation of system integration tests is based on approach of integration testing of modules in isolation (invoking commands and queries) described in the previous section. + +The problem is that in this case we are dealing with **asynchronous communication**. Due to asynchrony, our **test must wait for the result** at certain times. + +To correctly implement such tests, the **Sampling** technique and implementation described in the [Growing Object-Oriented Software, Guided by Tests](https://www.amazon.com/Growing-Object-Oriented-Software-Guided-Tests/dp/0321503627) book was used: + +>An asynchronous test must wait for success and use timeouts to detect failure. This implies that every tested activity must have an observable effect: a test must affect the system so that its observable state becomes different. This sounds obvious but it drives how we think about writing asynchronous tests. If an activity has no observable effect, there is nothing the test can wait for, and therefore no way for the test to synchronize with the system it is testing. There are two ways a test can observe the system: by sampling its observable state or by listening for events that it sends out. + +![](docs/Images/SystemIntegrationTests.jpg) + +Test below: +1. Creates Meeting Group Proposal in Meetings module +2. Waits until Meeting Group Proposal to verification will be available in Administration module with 10 seconds timeout +3. Accepts Meeting Group Proposal in Administration module +4. Waits until Meeting Group is created in Meetings module with 15 seconds timeout + +```csharp +public class CreateMeetingGroupTests : TestBase +{ + [Test] + public async Task CreateMeetingGroupScenario_WhenProposalIsAccepted() + { + var meetingGroupId = await MeetingsModule.ExecuteCommandAsync( + new ProposeMeetingGroupCommand("Name", + "Description", + "Location", + "PL")); + + AssertEventually( + new GetMeetingGroupProposalFromAdministrationProbe(meetingGroupId, AdministrationModule), + 10000); + + await AdministrationModule.ExecuteCommandAsync(new AcceptMeetingGroupProposalCommand(meetingGroupId)); + + AssertEventually( + new GetCreatedMeetingGroupFromMeetingsProbe(meetingGroupId, MeetingsModule), + 15000); + } + + private class GetCreatedMeetingGroupFromMeetingsProbe : IProbe + { + private readonly Guid _expectedMeetingGroupId; + + private readonly IMeetingsModule _meetingsModule; + + private List _allMeetingGroups; + + public GetCreatedMeetingGroupFromMeetingsProbe( + Guid expectedMeetingGroupId, + IMeetingsModule meetingsModule) + { + _expectedMeetingGroupId = expectedMeetingGroupId; + _meetingsModule = meetingsModule; + } + + public bool IsSatisfied() + { + return _allMeetingGroups != null && + _allMeetingGroups.Any(x => x.Id == _expectedMeetingGroupId); + } + + public async Task SampleAsync() + { + _allMeetingGroups = await _meetingsModule.ExecuteQueryAsync(new GetAllMeetingGroupsQuery()); + } + + public string DescribeFailureTo() + => $"Meeting group with ID: {_expectedMeetingGroupId} is not created"; + } + + private class GetMeetingGroupProposalFromAdministrationProbe : IProbe + { + private readonly Guid _expectedMeetingGroupProposalId; + + private MeetingGroupProposalDto _meetingGroupProposal; + + private readonly IAdministrationModule _administrationModule; + + public GetMeetingGroupProposalFromAdministrationProbe(Guid expectedMeetingGroupProposalId, IAdministrationModule administrationModule) + { + _expectedMeetingGroupProposalId = expectedMeetingGroupProposalId; + _administrationModule = administrationModule; + } + + public bool IsSatisfied() + { + if (_meetingGroupProposal == null) + { + return false; + } + + if (_meetingGroupProposal.Id == _expectedMeetingGroupProposalId && + _meetingGroupProposal.StatusCode == MeetingGroupProposalStatus.ToVerify.Value) + { + return true; + } + + return false; + } + + public async Task SampleAsync() + { + try + { + _meetingGroupProposal = + await _administrationModule.ExecuteQueryAsync( + new GetMeetingGroupProposalQuery(_expectedMeetingGroupProposalId)); + } + catch + { + // ignored + } + } + + public string DescribeFailureTo() + => $"Meeting group proposal with ID: {_expectedMeetingGroupProposalId} to verification not created"; + } +} +``` + +Poller class implementation (based on example in the book): + +```csharp +public class Poller +{ + private readonly int _timeoutMillis; + + private readonly int _pollDelayMillis; + + public Poller(int timeoutMillis) + { + _timeoutMillis = timeoutMillis; + _pollDelayMillis = 1000; + } + + public void Check(IProbe probe) + { + var timeout = new Timeout(_timeoutMillis); + while (!probe.IsSatisfied()) + { + if (timeout.HasTimedOut()) + { + throw new AssertErrorException(DescribeFailureOf(probe)); + } + + Thread.Sleep(_pollDelayMillis); + probe.SampleAsync(); + } + } + + private static string DescribeFailureOf(IProbe probe) + { + return probe.DescribeFailureTo(); + } +} +``` + ## 4. Technology List of technologies, frameworks and libraries used for implementation: @@ -1132,6 +1298,7 @@ List of features/tasks/approaches to add: | Architecture Decision Log update | High | Completed | 2019-11-09 | | Integration automated tests | Normal | Completed | 2020-02-24 | | Migration to .NET Core 3.1 | Low | Completed | 2020-03-04 | +| System Integration Testing | Normal | Completed | 2020-03-28 | | API automated tests | Normal | | | | FrontEnd SPA application | Normal | | | | Meeting comments feature | Low | | | @@ -1218,6 +1385,7 @@ The project is under [MIT license](https://opensource.org/licenses/MIT). - ["The Art of Unit Testing: with examples in C#"](https://www.amazon.com/Art-Unit-Testing-examples/dp/1617290890) book, Roy Osherove - ["Unit Test Your Architecture with ArchUnit"](https://blogs.oracle.com/javamagazine/unit-test-your-architecture-with-archunit) article, Jonas Havers - ["Unit Testing Principles, Practices, and Patterns"](https://www.amazon.com/Unit-Testing-Principles-Practices-Patterns/dp/1617296279) book, Vladimir Khorikov +- ["Growing Object-Oriented Software, Guided by Tests"](https://www.amazon.com/Growing-Object-Oriented-Software-Guided-Tests/dp/0321503627) book, Steve Freeman, Nat Pryce ### UML - ["UML Distilled: A Brief Guide to the Standard Object Modeling Language (3rd Edition)"](https://www.amazon.com/UML-Distilled-Standard-Modeling-Language/dp/0321193687) book, Martin Fowler diff --git a/docs/Images/SystemIntegrationTests.jpg b/docs/Images/SystemIntegrationTests.jpg new file mode 100644 index 00000000..d0f14253 Binary files /dev/null and b/docs/Images/SystemIntegrationTests.jpg differ diff --git a/src/API/CompanyName.MyMeetings.API/Startup.cs b/src/API/CompanyName.MyMeetings.API/Startup.cs index faeeb93b..444b7b8a 100644 --- a/src/API/CompanyName.MyMeetings.API/Startup.cs +++ b/src/API/CompanyName.MyMeetings.API/Startup.cs @@ -163,8 +163,19 @@ private IServiceProvider CreateAutofacServiceProvider(IServiceCollection service var emailsConfiguration = new EmailsConfiguration(_configuration["EmailsConfiguration:FromEmail"]); - MeetingsStartup.Initialize(this._configuration[MeetingsConnectionString], executionContextAccessor, _logger, emailsConfiguration); - AdministrationStartup.Initialize(this._configuration[MeetingsConnectionString], executionContextAccessor, _logger); + MeetingsStartup.Initialize( + this._configuration[MeetingsConnectionString], + executionContextAccessor, + _logger, + emailsConfiguration, + null); + + AdministrationStartup.Initialize( + this._configuration[MeetingsConnectionString], + executionContextAccessor, + _logger, + null); + UserAccessStartup.Initialize( this._configuration[MeetingsConnectionString], executionContextAccessor, @@ -172,6 +183,7 @@ private IServiceProvider CreateAutofacServiceProvider(IServiceCollection service emailsConfiguration, this._configuration["Security:TextEncryptionKey"], null); + PaymentsStartup.Initialize( this._configuration[MeetingsConnectionString], executionContextAccessor, diff --git a/src/CompanyName.MyMeetings.sln b/src/CompanyName.MyMeetings.sln index ddefa292..253e07db 100644 --- a/src/CompanyName.MyMeetings.sln +++ b/src/CompanyName.MyMeetings.sln @@ -101,6 +101,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CompanyName.MyMeetings.Modu EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CompanyName.MyMeetings.Modules.Administration.IntegrationTests", "Modules\Administration\Tests\IntegrationTests\CompanyName.MyMeetings.Modules.Administration.IntegrationTests.csproj", "{3D7FDF4A-6B8B-49CA-8E28-5B292DC0954F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CompanyName.MyMeetings.IntegrationTests", "Tests\IntegrationTests\CompanyName.MyMeetings.IntegrationTests.csproj", "{586DB9FA-CBBF-4867-A57D-E2359925D09A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -245,6 +247,10 @@ Global {3D7FDF4A-6B8B-49CA-8E28-5B292DC0954F}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D7FDF4A-6B8B-49CA-8E28-5B292DC0954F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3D7FDF4A-6B8B-49CA-8E28-5B292DC0954F}.Release|Any CPU.Build.0 = Release|Any CPU + {586DB9FA-CBBF-4867-A57D-E2359925D09A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {586DB9FA-CBBF-4867-A57D-E2359925D09A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {586DB9FA-CBBF-4867-A57D-E2359925D09A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {586DB9FA-CBBF-4867-A57D-E2359925D09A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -292,6 +298,7 @@ Global {396A6C3F-74BA-4D85-8BF3-1182F9DE9D95} = {0FF699EF-8156-43CB-8D18-8EA28F30E9EE} {B448FDC3-5F85-47EE-9F4A-2654E8CC67E1} = {53E4F002-E708-45F7-8444-19EB8977B5C9} {3D7FDF4A-6B8B-49CA-8E28-5B292DC0954F} = {F544D6DA-740A-4313-9542-D989666EA9DE} + {586DB9FA-CBBF-4867-A57D-E2359925D09A} = {8B08A9EE-CE27-4CC3-ACB3-3BD9628E5479} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6B94C21A-AA6D-4D82-963E-C69C0353B938} diff --git a/src/Modules/Administration/Infrastructure/Configuration/AdministrationStartup.cs b/src/Modules/Administration/Infrastructure/Configuration/AdministrationStartup.cs index 5993cdf1..828bd585 100644 --- a/src/Modules/Administration/Infrastructure/Configuration/AdministrationStartup.cs +++ b/src/Modules/Administration/Infrastructure/Configuration/AdministrationStartup.cs @@ -1,5 +1,6 @@ using Autofac; using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; using CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configuration.Authentication; using CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configuration.DataAccess; using CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configuration.EventsBus; @@ -20,11 +21,12 @@ public class AdministrationStartup public static void Initialize( string connectionString, IExecutionContextAccessor executionContextAccessor, - ILogger logger) + ILogger logger, + IEventsBus eventsBus) { var moduleLogger = logger.ForContext("Module", "Administration"); - ConfigureContainer(connectionString, executionContextAccessor, moduleLogger); + ConfigureContainer(connectionString, executionContextAccessor, moduleLogger, eventsBus); QuartzStartup.Initialize(moduleLogger); @@ -33,7 +35,8 @@ public static void Initialize( private static void ConfigureContainer(string connectionString, IExecutionContextAccessor executionContextAccessor, - ILogger logger) + ILogger logger, + IEventsBus eventsBus) { var containerBuilder = new ContainerBuilder(); @@ -43,7 +46,7 @@ private static void ConfigureContainer(string connectionString, containerBuilder.RegisterModule(new DataAccessModule(connectionString, loggerFactory)); containerBuilder.RegisterModule(new ProcessingModule()); - containerBuilder.RegisterModule(new EventsBusModule()); + containerBuilder.RegisterModule(new EventsBusModule(eventsBus)); containerBuilder.RegisterModule(new MediatorModule()); containerBuilder.RegisterModule(new AuthenticationModule()); containerBuilder.RegisterModule(new OutboxModule()); diff --git a/src/Modules/Administration/Infrastructure/Configuration/EventsBus/EventsBusModule.cs b/src/Modules/Administration/Infrastructure/Configuration/EventsBus/EventsBusModule.cs index 2223f1b1..c23ec2a1 100644 --- a/src/Modules/Administration/Infrastructure/Configuration/EventsBus/EventsBusModule.cs +++ b/src/Modules/Administration/Infrastructure/Configuration/EventsBus/EventsBusModule.cs @@ -6,11 +6,27 @@ namespace CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configura { internal class EventsBusModule : Autofac.Module { + private readonly IEventsBus _eventsBus; + + public EventsBusModule(IEventsBus eventsBus) + { + _eventsBus = eventsBus; + } + protected override void Load(ContainerBuilder builder) { - builder.RegisterType() - .As() - .SingleInstance(); + if (_eventsBus != null) + { + builder.RegisterInstance(_eventsBus).SingleInstance(); + + } + else + { + builder.RegisterType() + .As() + .SingleInstance(); + } + } } } \ No newline at end of file diff --git a/src/Modules/Administration/Infrastructure/Configuration/Quartz/QuartzStartup.cs b/src/Modules/Administration/Infrastructure/Configuration/Quartz/QuartzStartup.cs index bb518921..8677faea 100644 --- a/src/Modules/Administration/Infrastructure/Configuration/Quartz/QuartzStartup.cs +++ b/src/Modules/Administration/Infrastructure/Configuration/Quartz/QuartzStartup.cs @@ -30,7 +30,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler @@ -42,7 +42,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler @@ -54,7 +54,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler.ScheduleJob(processInternalCommandsJob, triggerCommandsProcessing).GetAwaiter().GetResult(); diff --git a/src/Modules/Administration/Tests/IntegrationTests/SeedWork/TestBase.cs b/src/Modules/Administration/Tests/IntegrationTests/SeedWork/TestBase.cs index f31267c0..c720c9a8 100644 --- a/src/Modules/Administration/Tests/IntegrationTests/SeedWork/TestBase.cs +++ b/src/Modules/Administration/Tests/IntegrationTests/SeedWork/TestBase.cs @@ -54,7 +54,8 @@ public async Task BeforeEachTest() AdministrationStartup.Initialize( ConnectionString, ExecutionContext, - Logger); + Logger, + null); AdministrationModule = new AdministrationModule(); } diff --git a/src/Modules/Meetings/Application/Configuration/Commands/InternalCommandBase.cs b/src/Modules/Meetings/Application/Configuration/Commands/InternalCommandBase.cs index 80319d74..fe490702 100644 --- a/src/Modules/Meetings/Application/Configuration/Commands/InternalCommandBase.cs +++ b/src/Modules/Meetings/Application/Configuration/Commands/InternalCommandBase.cs @@ -12,4 +12,19 @@ protected InternalCommandBase(Guid id) this.Id = id; } } + + public abstract class InternalCommandBase : ICommand + { + public Guid Id { get; } + + protected InternalCommandBase() + { + this.Id = Guid.NewGuid(); + } + + protected InternalCommandBase(Guid id) + { + this.Id = id; + } + } } \ No newline at end of file diff --git a/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommand.cs b/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommand.cs index 1fc72d07..3d7606d2 100644 --- a/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommand.cs +++ b/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommand.cs @@ -1,8 +1,9 @@ -using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; +using System; +using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; namespace CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroupProposals.ProposeMeetingGroup { - public class ProposeMeetingGroupCommand : CommandBase + public class ProposeMeetingGroupCommand : CommandBase { public ProposeMeetingGroupCommand(string name, string description, string locationCity, string locationCountryCode) { diff --git a/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommandHandler.cs b/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommandHandler.cs index b1496e7b..1721d19e 100644 --- a/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommandHandler.cs +++ b/src/Modules/Meetings/Application/MeetingGroupProposals/ProposeMeetingGroup/ProposeMeetingGroupCommandHandler.cs @@ -9,7 +9,7 @@ namespace CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroupProposals.ProposeMeetingGroup { - internal class ProposeMeetingGroupCommandHandler : ICommandHandler + internal class ProposeMeetingGroupCommandHandler : ICommandHandler { private readonly IMeetingGroupProposalRepository _meetingGroupProposalRepository; private readonly IMemberContext _memberContext; @@ -22,7 +22,7 @@ internal ProposeMeetingGroupCommandHandler( _memberContext = memberContext; } - public async Task Handle(ProposeMeetingGroupCommand request, CancellationToken cancellationToken) + public async Task Handle(ProposeMeetingGroupCommand request, CancellationToken cancellationToken) { var meetingGroupProposal = MeetingGroupProposal.ProposeNew( request.Name, @@ -32,7 +32,7 @@ public async Task Handle(ProposeMeetingGroupCommand request, CancellationT await _meetingGroupProposalRepository.AddAsync(meetingGroupProposal); - return new Unit(); + return meetingGroupProposal.Id.Value; } } } \ No newline at end of file diff --git a/src/Modules/Meetings/Infrastructure/Configuration/EventsBus/EventsBusModule.cs b/src/Modules/Meetings/Infrastructure/Configuration/EventsBus/EventsBusModule.cs index d29e6e0f..ebbdd4c9 100644 --- a/src/Modules/Meetings/Infrastructure/Configuration/EventsBus/EventsBusModule.cs +++ b/src/Modules/Meetings/Infrastructure/Configuration/EventsBus/EventsBusModule.cs @@ -6,11 +6,26 @@ namespace CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.E { internal class EventsBusModule : Autofac.Module { + private readonly IEventsBus _eventsBus; + + public EventsBusModule(IEventsBus eventsBus) + { + _eventsBus = eventsBus; + } + protected override void Load(ContainerBuilder builder) { - builder.RegisterType() + if (_eventsBus != null) + { + builder.RegisterInstance(_eventsBus).SingleInstance(); + } + else + { + builder.RegisterType() .As() .SingleInstance(); + + } } } } \ No newline at end of file diff --git a/src/Modules/Meetings/Infrastructure/Configuration/MeetingsStartup.cs b/src/Modules/Meetings/Infrastructure/Configuration/MeetingsStartup.cs index 79d7a211..2fd3eb82 100644 --- a/src/Modules/Meetings/Infrastructure/Configuration/MeetingsStartup.cs +++ b/src/Modules/Meetings/Infrastructure/Configuration/MeetingsStartup.cs @@ -1,6 +1,7 @@ using Autofac; using CompanyName.MyMeetings.BuildingBlocks.Application; using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; using CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.Authentication; using CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.DataAccess; using CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.Email; @@ -23,11 +24,17 @@ public static void Initialize( string connectionString, IExecutionContextAccessor executionContextAccessor, ILogger logger, - EmailsConfiguration emailsConfiguration) + EmailsConfiguration emailsConfiguration, + IEventsBus eventsBus) { var moduleLogger = logger.ForContext("Module", "Meetings"); - ConfigureCompositionRoot(connectionString, executionContextAccessor, moduleLogger, emailsConfiguration); + ConfigureCompositionRoot( + connectionString, + executionContextAccessor, + moduleLogger, + emailsConfiguration, + eventsBus); QuartzStartup.Initialize(moduleLogger); @@ -38,7 +45,8 @@ private static void ConfigureCompositionRoot( string connectionString, IExecutionContextAccessor executionContextAccessor, ILogger logger, - EmailsConfiguration emailsConfiguration) + EmailsConfiguration emailsConfiguration, + IEventsBus eventsBus) { var containerBuilder = new ContainerBuilder(); @@ -48,7 +56,7 @@ private static void ConfigureCompositionRoot( containerBuilder.RegisterModule(new DataAccessModule(connectionString, loggerFactory)); containerBuilder.RegisterModule(new ProcessingModule()); - containerBuilder.RegisterModule(new EventsBusModule()); + containerBuilder.RegisterModule(new EventsBusModule(eventsBus)); containerBuilder.RegisterModule(new MediatorModule()); containerBuilder.RegisterModule(new AuthenticationModule()); containerBuilder.RegisterModule(new OutboxModule()); diff --git a/src/Modules/Meetings/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs b/src/Modules/Meetings/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs index f3e83a9d..a97d30a1 100644 --- a/src/Modules/Meetings/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs +++ b/src/Modules/Meetings/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs @@ -37,7 +37,7 @@ public async Task Handle(ProcessInternalCommandsCommand command, Cancellat foreach (var internalCommand in internalCommandsList) { Type type = Assemblies.Application.GetType(internalCommand.Type); - var commandToProcess = JsonConvert.DeserializeObject(internalCommand.Data, type) as ICommand; + dynamic commandToProcess = JsonConvert.DeserializeObject(internalCommand.Data, type); await CommandsExecutor.Execute(commandToProcess); } diff --git a/src/Modules/Meetings/Infrastructure/Configuration/Processing/ProcessingModule.cs b/src/Modules/Meetings/Infrastructure/Configuration/Processing/ProcessingModule.cs index b2ae2d26..b1f99cbe 100644 --- a/src/Modules/Meetings/Infrastructure/Configuration/Processing/ProcessingModule.cs +++ b/src/Modules/Meetings/Infrastructure/Configuration/Processing/ProcessingModule.cs @@ -33,9 +33,17 @@ protected override void Load(ContainerBuilder builder) typeof(UnitOfWorkCommandHandlerDecorator<>), typeof(ICommandHandler<>)); + builder.RegisterGenericDecorator( + typeof(UnitOfWorkCommandHandlerWithResultDecorator<,>), + typeof(ICommandHandler<,>)); + builder.RegisterGenericDecorator( typeof(ValidationCommandHandlerDecorator<>), - typeof(ICommandHandler<>)); + typeof(ICommandHandler<>)); + + builder.RegisterGenericDecorator( + typeof(ValidationCommandHandlerWithResultDecorator<,>), + typeof(ICommandHandler<,>)); builder.RegisterGenericDecorator( typeof(LoggingCommandHandlerDecorator<>), diff --git a/src/Modules/Meetings/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs b/src/Modules/Meetings/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs new file mode 100644 index 00000000..037b05ac --- /dev/null +++ b/src/Modules/Meetings/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CompanyName.MyMeetings.BuildingBlocks.Domain; +using CompanyName.MyMeetings.Modules.Meetings.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.Processing +{ + internal class UnitOfWorkCommandHandlerWithResultDecorator : ICommandHandler where T : ICommand + { + private readonly ICommandHandler _decorated; + private readonly IUnitOfWork _unitOfWork; + private readonly MeetingsContext _meetingsContext; + + public UnitOfWorkCommandHandlerWithResultDecorator( + ICommandHandler decorated, + IUnitOfWork unitOfWork, + MeetingsContext meetingsContext) + { + _decorated = decorated; + _unitOfWork = unitOfWork; + _meetingsContext = meetingsContext; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + var result = await this._decorated.Handle(command, cancellationToken); + + if (command is InternalCommandBase) + { + var internalCommand = await _meetingsContext.InternalCommands.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken: cancellationToken); + + if (internalCommand != null) + { + internalCommand.ProcessedDate = DateTime.UtcNow; + } + } + + await this._unitOfWork.CommitAsync(cancellationToken); + + return result; + } + } +} \ No newline at end of file diff --git a/src/Modules/Meetings/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs b/src/Modules/Meetings/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs new file mode 100644 index 00000000..56d8717f --- /dev/null +++ b/src/Modules/Meetings/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.Meetings.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration.Processing +{ + internal class ValidationCommandHandlerWithResultDecorator : ICommandHandler where T : ICommand + { + private readonly IList> _validators; + private readonly ICommandHandler _decorated; + + public ValidationCommandHandlerWithResultDecorator( + IList> validators, + ICommandHandler decorated) + { + this._validators = validators; + _decorated = decorated; + } + + public Task Handle(T command, CancellationToken cancellationToken) + { + var errors = _validators + .Select(v => v.Validate(command)) + .SelectMany(result => result.Errors) + .Where(error => error != null) + .ToList(); + + if (errors.Any()) + { + throw new InvalidCommandException(errors.Select(x => x.ErrorMessage).ToList()); + } + + return _decorated.Handle(command, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Modules/Meetings/Infrastructure/Configuration/Quartz/QuartzStartup.cs b/src/Modules/Meetings/Infrastructure/Configuration/Quartz/QuartzStartup.cs index 79b6d1fb..13c200ae 100644 --- a/src/Modules/Meetings/Infrastructure/Configuration/Quartz/QuartzStartup.cs +++ b/src/Modules/Meetings/Infrastructure/Configuration/Quartz/QuartzStartup.cs @@ -30,7 +30,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler @@ -42,7 +42,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler @@ -54,7 +54,7 @@ internal static void Initialize(ILogger logger) TriggerBuilder .Create() .StartNow() - .WithCronSchedule("0/15 * * ? * *") + .WithCronSchedule("0/2 * * ? * *") .Build(); scheduler.ScheduleJob(processInternalCommandsJob, triggerCommandsProcessing).GetAwaiter().GetResult(); diff --git a/src/Tests/IntegrationTests/CompanyName.MyMeetings.IntegrationTests.csproj b/src/Tests/IntegrationTests/CompanyName.MyMeetings.IntegrationTests.csproj new file mode 100644 index 00000000..2df04932 --- /dev/null +++ b/src/Tests/IntegrationTests/CompanyName.MyMeetings.IntegrationTests.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/IntegrationTests/CreateMeetingGroup/CreateMeetingGroupTests.cs b/src/Tests/IntegrationTests/CreateMeetingGroup/CreateMeetingGroupTests.cs new file mode 100644 index 00000000..c36cddef --- /dev/null +++ b/src/Tests/IntegrationTests/CreateMeetingGroup/CreateMeetingGroupTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CompanyName.MyMeetings.IntegrationTests.SeedWork; +using CompanyName.MyMeetings.Modules.Administration.Application.Contracts; +using CompanyName.MyMeetings.Modules.Administration.Application.MeetingGroupProposals.AcceptMeetingGroupProposal; +using CompanyName.MyMeetings.Modules.Administration.Application.MeetingGroupProposals.GetMeetingGroupProposal; +using CompanyName.MyMeetings.Modules.Administration.Domain.MeetingGroupProposals; +using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; +using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroupProposals.ProposeMeetingGroup; +using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroups.GetAllMeetingGroups; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.IntegrationTests.CreateMeetingGroup +{ + public class CreateMeetingGroupTests : TestBase + { + [Test] + public async Task CreateMeetingGroupScenario_WhenProposalIsAccepted() + { + var meetingGroupId = await MeetingsModule.ExecuteCommandAsync( + new ProposeMeetingGroupCommand("Name", + "Description", + "Location", + "PL")); + + AssertEventually( + new GetMeetingGroupProposalFromAdministrationProbe(meetingGroupId, AdministrationModule), + 10000); + + await AdministrationModule.ExecuteCommandAsync(new AcceptMeetingGroupProposalCommand(meetingGroupId)); + + AssertEventually( + new GetCreatedMeetingGroupFromMeetingsProbe(meetingGroupId, MeetingsModule), + 15000); + } + + private class GetCreatedMeetingGroupFromMeetingsProbe : IProbe + { + private readonly Guid _expectedMeetingGroupId; + + private readonly IMeetingsModule _meetingsModule; + + private List _allMeetingGroups; + + public GetCreatedMeetingGroupFromMeetingsProbe( + Guid expectedMeetingGroupId, + IMeetingsModule meetingsModule) + { + _expectedMeetingGroupId = expectedMeetingGroupId; + _meetingsModule = meetingsModule; + } + + public bool IsSatisfied() + { + return _allMeetingGroups != null && + _allMeetingGroups.Any(x => x.Id == _expectedMeetingGroupId); + } + + public async Task SampleAsync() + { + _allMeetingGroups = await _meetingsModule.ExecuteQueryAsync(new GetAllMeetingGroupsQuery()); + } + + public string DescribeFailureTo() + => $"Meeting group with ID: {_expectedMeetingGroupId} is not created"; + } + + private class GetMeetingGroupProposalFromAdministrationProbe : IProbe + { + private readonly Guid _expectedMeetingGroupProposalId; + + private MeetingGroupProposalDto _meetingGroupProposal; + + private readonly IAdministrationModule _administrationModule; + + public GetMeetingGroupProposalFromAdministrationProbe(Guid expectedMeetingGroupProposalId, IAdministrationModule administrationModule) + { + _expectedMeetingGroupProposalId = expectedMeetingGroupProposalId; + _administrationModule = administrationModule; + } + + public bool IsSatisfied() + { + if (_meetingGroupProposal == null) + { + return false; + } + + if (_meetingGroupProposal.Id == _expectedMeetingGroupProposalId && + _meetingGroupProposal.StatusCode == MeetingGroupProposalStatus.ToVerify.Value) + { + return true; + } + + return false; + } + + public async Task SampleAsync() + { + try + { + _meetingGroupProposal = + await _administrationModule.ExecuteQueryAsync( + new GetMeetingGroupProposalQuery(_expectedMeetingGroupProposalId)); + } + catch + { + // ignored + } + } + + public string DescribeFailureTo() + => $"Meeting group proposal with ID: {_expectedMeetingGroupProposalId} to verification not created"; + } + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/AssertErrorException.cs b/src/Tests/IntegrationTests/SeedWork/AssertErrorException.cs new file mode 100644 index 00000000..98a88c2e --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/AssertErrorException.cs @@ -0,0 +1,12 @@ +using System; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public class AssertErrorException : Exception + { + public AssertErrorException(string message) : base(message) + { + + } + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs b/src/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs new file mode 100644 index 00000000..f7e42274 --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs @@ -0,0 +1,16 @@ +using System; +using CompanyName.MyMeetings.BuildingBlocks.Application; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public class ExecutionContextMock : IExecutionContextAccessor + { + public ExecutionContextMock(Guid userId) + { + UserId = userId; + } + public Guid UserId { get; } + public Guid CorrelationId { get; } + public bool IsAvailable { get; } + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/IProbe.cs b/src/Tests/IntegrationTests/SeedWork/IProbe.cs new file mode 100644 index 00000000..7b3adb1c --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/IProbe.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public interface IProbe + { + bool IsSatisfied(); + + Task SampleAsync(); + + string DescribeFailureTo(); + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/Poller.cs b/src/Tests/IntegrationTests/SeedWork/Poller.cs new file mode 100644 index 00000000..bbabc921 --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/Poller.cs @@ -0,0 +1,37 @@ +using System.Threading; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public class Poller + { + private readonly int _timeoutMillis; + + private readonly int _pollDelayMillis; + + public Poller(int timeoutMillis) + { + _timeoutMillis = timeoutMillis; + _pollDelayMillis = 1000; + } + + public void Check(IProbe probe) + { + var timeout = new Timeout(_timeoutMillis); + while (!probe.IsSatisfied()) + { + if (timeout.HasTimedOut()) + { + throw new AssertErrorException(DescribeFailureOf(probe)); + } + + Thread.Sleep(_pollDelayMillis); + probe.SampleAsync(); + } + } + + private static string DescribeFailureOf(IProbe probe) + { + return probe.DescribeFailureTo(); + } + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/TestBase.cs b/src/Tests/IntegrationTests/SeedWork/TestBase.cs new file mode 100644 index 00000000..cdb8f9f6 --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/TestBase.cs @@ -0,0 +1,117 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Threading.Tasks; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Domain; +using CompanyName.MyMeetings.BuildingBlocks.EventBus; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using CompanyName.MyMeetings.Modules.Administration.Application.Contracts; +using CompanyName.MyMeetings.Modules.Administration.Infrastructure; +using CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configuration; +using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; +using CompanyName.MyMeetings.Modules.Meetings.Infrastructure; +using CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration; +using Dapper; +using NSubstitute; +using NUnit.Framework; +using Serilog; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public class TestBase + { + protected string ConnectionString; + + protected ILogger Logger; + + protected IAdministrationModule AdministrationModule; + + protected IMeetingsModule MeetingsModule; + + protected IEmailSender EmailSender; + + protected ExecutionContextMock ExecutionContext; + + protected IEventsBus EventsBus; + + + [SetUp] + public async Task BeforeEachTest() + { + const string connectionStringEnvironmentVariable = + "ASPNETCORE_MyMeetings_IntegrationTests_ConnectionString"; + ConnectionString = Environment.GetEnvironmentVariable(connectionStringEnvironmentVariable, EnvironmentVariableTarget.Machine); + if (ConnectionString == null) + { + throw new ApplicationException( + $"Define connection string to integration tests database using environment variable: {connectionStringEnvironmentVariable}"); + } + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await ClearDatabase(sqlConnection); + } + + Logger = Substitute.For(); + EmailSender = Substitute.For(); + ExecutionContext = new ExecutionContextMock(Guid.NewGuid()); + + EventsBus = new InMemoryEventBusClient(Logger); + + AdministrationStartup.Initialize( + ConnectionString, + ExecutionContext, + Logger, + EventsBus); + + MeetingsStartup.Initialize( + ConnectionString, + ExecutionContext, + Logger, + new EmailsConfiguration("from@email.com"), + EventsBus); + + AdministrationModule = new AdministrationModule(); + MeetingsModule = new MeetingsModule(); + } + + private static async Task ClearDatabase(IDbConnection connection) + { + const string sql = "DELETE FROM [administration].[InboxMessages] " + + "DELETE FROM [administration].[InternalCommands] " + + "DELETE FROM [administration].[OutboxMessages] " + + "DELETE FROM [administration].[MeetingGroupProposals] " + + "DELETE FROM [administration].[Members] " + + "DELETE FROM [meetings].[InboxMessages] " + + "DELETE FROM [meetings].[InternalCommands] " + + "DELETE FROM [meetings].[OutboxMessages] " + + "DELETE FROM [meetings].[MeetingAttendees] " + + "DELETE FROM [meetings].[MeetingGroupMembers] " + + "DELETE FROM [meetings].[MeetingGroupProposals] " + + "DELETE FROM [meetings].[MeetingGroups] " + + "DELETE FROM [meetings].[MeetingNotAttendees] " + + "DELETE FROM [meetings].[Meetings] " + + "DELETE FROM [meetings].[MeetingWaitlistMembers] " + + "DELETE FROM [meetings].[Members] "; + + await connection.ExecuteScalarAsync(sql); + } + + protected static void AssertBrokenRule(AsyncTestDelegate testDelegate) where TRule : class, IBusinessRule + { + var message = $"Expected {typeof(TRule).Name} broken rule"; + var businessRuleValidationException = Assert.CatchAsync(testDelegate, message); + if (businessRuleValidationException != null) + { + Assert.That(businessRuleValidationException.BrokenRule, Is.TypeOf(), message); + } + } + + protected static void AssertEventually(IProbe probe, int timeout) + { + new Poller(timeout).Check(probe); + } + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/SeedWork/Timeout.cs b/src/Tests/IntegrationTests/SeedWork/Timeout.cs new file mode 100644 index 00000000..e95bbfe8 --- /dev/null +++ b/src/Tests/IntegrationTests/SeedWork/Timeout.cs @@ -0,0 +1,19 @@ +using System; + +namespace CompanyName.MyMeetings.IntegrationTests.SeedWork +{ + public class Timeout + { + private readonly DateTime _endTime; + + public Timeout(int duration) + { + this._endTime = DateTime.Now.AddMilliseconds(duration); + } + + public bool HasTimedOut() + { + return DateTime.Now > _endTime; + } + } +}