Skip to content

Commit

Permalink
Added E2E tests for Closing the books example
Browse files Browse the repository at this point in the history
  • Loading branch information
oskardudycz committed Feb 15, 2024
1 parent e4bfb29 commit 0c70ed9
Show file tree
Hide file tree
Showing 16 changed files with 291 additions and 8 deletions.
7 changes: 7 additions & 0 deletions EventSourcing.NetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales", "Sample\Clos
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales.Api", "Sample\ClosingTheBooks\PointOfSales.Api\PointOfSales.Api.csproj", "{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales.Api.Tests", "Sample\ClosingTheBooks\PointOfSales.Api.Tests\PointOfSales.Api.Tests.csproj", "{42510FFD-04F5-4580-9B02-9CBA260718DC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -988,6 +990,10 @@ Global
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8}.Release|Any CPU.Build.0 = Release|Any CPU
{42510FFD-04F5-4580-9B02-9CBA260718DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{42510FFD-04F5-4580-9B02-9CBA260718DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{42510FFD-04F5-4580-9B02-9CBA260718DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{42510FFD-04F5-4580-9B02-9CBA260718DC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1170,6 +1176,7 @@ Global
{C8F02DB9-5FEA-46C8-95E3-BB4255CB0667} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34}
{F40DF11A-354C-4438-B674-B95B9A401C33} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}
{CE15C7EC-85CA-44B8-B13B-206E308E8EF8} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}
{42510FFD-04F5-4580-9B02-9CBA260718DC} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Net;

namespace PointOfSales.Api.Tests.CashRegisters;

using static Scenarios;

public class EndToEndPositiveScenarios(ApiSpecification<Program> api):
IClassFixture<ApiSpecification<Program>>
{
[Fact]
public Task ForRegisteredCashRegister_OpensFirstCashierShift() =>
api.Given([InitializedCashRegister(cashRegisterId)])
.When(
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts"),
BODY(new OpenShiftRequest(Guid.NewGuid().ToString()))
).Then(CREATED_WITH_DEFAULT_HEADERS($"/api/cash-registers/{cashRegisterId}/cashier-shifts/1"));

[Fact]
public Task ForOpenedCashRegister_DoesntOpensNextCashierShift() =>
api.Given([InitializedCashRegister(cashRegisterId), OpenedCashierShift(cashRegisterId)])
.When(
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts"),
BODY(new OpenShiftRequest(Guid.NewGuid().ToString()))
).Then(PRECONDITION_FAILED);

[Fact]
public Task ForOpenedCashRegister_ClosesCashierShift() =>
api.Given([
InitializedCashRegister(cashRegisterId),
OpenedCashierShift(cashRegisterId)
])
.When(
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts/1/close"),
BODY(new CloseShiftRequest(100)),
HEADERS(IF_MATCH(1))
).Then(OK);


[Fact]
public Task ForClosedCashRegister_OpensCashierShift() =>
api.Given([
InitializedCashRegister(cashRegisterId),
OpenedCashierShift(cashRegisterId),
ClosedCashierShift(cashRegisterId, 1, 1)
])
.When(
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts"),
BODY(new OpenShiftRequest(Guid.NewGuid().ToString()))
).Then(CREATED_WITH_DEFAULT_HEADERS($"/api/cash-registers/{cashRegisterId}/cashier-shifts/2"));

private readonly string cashRegisterId = Guid.NewGuid().ToString();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace PointOfSales.Api.Tests.CashRegisters;

public class InitializeCashRegisterTests(ApiSpecification<Program> api):
IClassFixture<ApiSpecification<Program>>
{
[Fact]
public Task InitializeCashRegister() =>
api.Given()
.When(
POST,
URI($"/api/cash-registers/{cashRegisterId}")
).Then(CREATED_WITH_DEFAULT_HEADERS($"/api/cash-registers/{cashRegisterId}"));

private readonly Guid cashRegisterId = Guid.NewGuid();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Bogus;
using Bogus.DataSets;

namespace PointOfSales.Api.Tests.CashRegisters;

public static class Scenarios
{
private static readonly Faker faker = new();

public static RequestDefinition InitializedCashRegister(string? cashRegisterId = null) =>
SEND(
"Initialize Cash Register",
POST,
URI($"/api/cash-registers/{cashRegisterId ?? Guid.NewGuid().ToString()}")
);

public static RequestDefinition OpenedCashierShift(string cashRegisterId, string? cashierId = null) =>
SEND(
"Open Cashier Shift",
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts"),
BODY(new OpenShiftRequest(cashierId ?? Guid.NewGuid().ToString()))
);

public static RequestDefinition RegisteredTransaction(Guid cashRegisterId, int shiftNumber, decimal? amount) =>
SEND(
"Register Transaction",
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber}/transactions"),
BODY(new RegisterTransactionRequest(amount ?? faker.Finance.Amount()))
);

public static RequestDefinition ClosedCashierShift(
string cashRegisterId,
int shiftNumber,
int etag,
decimal? declaredTender = null
) =>
SEND(
"Close Cashier Shift",
POST,
URI($"/api/cash-registers/{cashRegisterId}/cashier-shifts/{shiftNumber}/close"),
BODY(new CloseShiftRequest(declaredTender ?? faker.Finance.Amount())),
HEADERS(IF_MATCH(etag))
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.1" />
<PackageReference Include="Ogooreck" Version="0.8.0" />
<PackageReference Include="Bogus" Version="35.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PointOfSales.Api\PointOfSales.Api.csproj" />
</ItemGroup>

<Import Project="..\..\..\Tests.Build.props" />

</Project>
18 changes: 18 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales.Api.Tests/Settings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Oakton;
using Xunit.Abstractions;
using Xunit.Sdk;

[assembly: CollectionBehavior(DisableTestParallelization = true)]

[assembly: TestFramework("PointOfSales.Api.Tests.AssemblyFixture", "PointOfSales.Api.Tests")]

namespace PointOfSales.Api.Tests;

public sealed class AssemblyFixture : XunitTestFramework
{
public AssemblyFixture(IMessageSink messageSink)
:base(messageSink)
{
OaktonEnvironment.AutoStartHost = true;
}
}
3 changes: 3 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales.Api.Tests/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
global using Xunit;
global using Ogooreck.API;
global using static Ogooreck.API.ApiSpecification;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace Helpdesk.Api.Core.Http.Middlewares.ExceptionHandling;

public class ExceptionToProblemDetailsHandler(Func<Exception, HttpContext, ProblemDetails?>? customExceptionMap)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken
)
{
var details = customExceptionMap?.Invoke(exception, httpContext) ?? exception.MapToProblemDetails();

httpContext.Response.StatusCode = details.Status ?? StatusCodes.Status500InternalServerError;
await httpContext.Response
.WriteAsJsonAsync(details, cancellationToken: cancellationToken).ConfigureAwait(false);

return true;
}
}

public static class ExceptionHandlingMiddleware
{
public static IServiceCollection AddDefaultExceptionHandler(
this IServiceCollection serviceCollection,
Func<Exception, HttpContext, ProblemDetails?>? customExceptionMap = null
) =>
serviceCollection
.AddProblemDetails()
.AddSingleton<IExceptionHandler>(new ExceptionToProblemDetailsHandler(customExceptionMap));
}

public static class ProblemDetailsExtensions
{
public static ProblemDetails MapToProblemDetails(this Exception exception)
{
var statusCode = exception switch
{
ArgumentException _ => StatusCodes.Status400BadRequest,
ValidationException _ => StatusCodes.Status400BadRequest,
UnauthorizedAccessException _ => StatusCodes.Status401Unauthorized,
InvalidOperationException _ => StatusCodes.Status403Forbidden,
NotImplementedException _ => StatusCodes.Status501NotImplemented,
_ => StatusCodes.Status500InternalServerError
};

return exception.MapToProblemDetails(statusCode);
}

public static ProblemDetails MapToProblemDetails(
this Exception exception,
int statusCode,
string? title = null,
string? detail = null
) =>
new() { Title = title ?? exception.GetType().Name, Detail = detail ?? exception.Message, Status = statusCode };
}
18 changes: 16 additions & 2 deletions Sample/ClosingTheBooks/PointOfSales.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Text.Json.Serialization;
using Helpdesk.Api.Core.Http.Middlewares.ExceptionHandling;
using Helpdesk.Api.Core.Marten;
using JasperFx.CodeGeneration;
using Marten;
using Marten.AspNetCore;
using Marten.Events;
using Marten.Events.Daemon.Resiliency;
using Marten.Events.Projections;
using Marten.Exceptions;
using Marten.Schema.Identity;
using Marten.Services.Json;
using Oakton;
Expand All @@ -28,6 +30,15 @@
builder.Services
.AddEndpointsApiExplorer()
.AddSwaggerGen()
.AddDefaultExceptionHandler(
(exception, _) => exception switch
{
ConcurrencyException =>
exception.MapToProblemDetails(StatusCodes.Status412PreconditionFailed),
ExistingStreamIdCollisionException =>
exception.MapToProblemDetails(StatusCodes.Status412PreconditionFailed),
_ => null,
})
.AddMarten(options =>
{
var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "PointOfSales";
Expand All @@ -47,7 +58,8 @@
options.Projections.LiveStreamAggregation<CashierShift>();
options.Projections.LiveStreamAggregation<CashRegister>();
options.Projections.Add<CashierShiftTrackerProjection>(ProjectionLifecycle.Async);
// Added as inline for now to make tests easier, should be async in the end
options.Projections.Add<CashierShiftTrackerProjection>(ProjectionLifecycle.Inline);
})
.OptimizeArtifactWorkflow(TypeLoadMode.Static)
.UseLightweightSessions()
Expand All @@ -62,6 +74,8 @@

var app = builder.Build();

app.UseExceptionHandler();

app.MapPost("/api/cash-registers/{cashRegisterId}",
async (
IDocumentSession documentSession,
Expand All @@ -75,7 +89,7 @@
}
);

app.MapPost("/api/cash-registers/{cashRegisterId}/cashier-shifts/open",
app.MapPost("/api/cash-registers/{cashRegisterId}/cashier-shifts",
async (
IDocumentSession documentSession,
string cashRegisterId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"PointOfSales": "PORT = 5432; HOST = postgres; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"
}
}
12 changes: 12 additions & 0 deletions Sample/ClosingTheBooks/PointOfSales.Api/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"PointOfSales": "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ decimal FinalFloat
public CashierShift Apply(CashierShiftEvent @event) =>
(this, @event) switch
{
(NonExisting or Closed , ShiftOpened shiftOpened) =>
(_, ShiftOpened shiftOpened) =>
new Opened(shiftOpened.CashierShiftId, shiftOpened.Float),

(Opened state, TransactionRegistered transactionRegistered) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static class CashierShiftDecider
(OpenShift open, NonExisting) =>
[
new ShiftOpened(
new CashierShiftId(open.CashRegisterId, 0),
new CashierShiftId(open.CashRegisterId, 1),
open.CashierId,
0,
open.Now
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace PointOfSales.CashierShifts;

public record CashierShiftTracker(
string Id,
Guid? LastShiftClosedEventId
long? LastShiftClosedSequence
);

public class CashierShiftTrackerProjection: MultiStreamProjection<CashierShiftTracker, string>
Expand All @@ -21,5 +21,5 @@ public CashierShiftTrackerProjection()
new(logged.CashRegisterId, null);

public CashierShiftTracker Apply(IEvent<CashierShiftEvent.ShiftClosed> closed, CashierShiftTracker current) =>
current with { LastShiftClosedEventId = closed.Id };
current with { LastShiftClosedSequence = closed.Sequence };
}
Loading

0 comments on commit 0c70ed9

Please sign in to comment.