Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using Shared.DomainDrivenDesign.EventSourcing;
using Shouldly;
using SimpleResults;
using System.Reflection;
using TransactionProcessor.DomainEvents;
using TransactionProcessor.Models.MerchantSchedule;

namespace TransactionProcessor.Aggregates.Tests
{
public class MerchantScheduleAggregateTests
{
private static readonly Guid MerchantScheduleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid EstateId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid MerchantId = Guid.Parse("33333333-3333-3333-3333-333333333333");

[Fact]
public void MerchantScheduleAggregate_CanBeCreated_IsCreated()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);

aggregate.AggregateId.ShouldBe(MerchantScheduleId);
aggregate.IsCreated.ShouldBeFalse();
}

[Fact]
public void MerchantScheduleAggregate_Create_WithInitialMonths_IsCreated()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);

Result result = aggregate.Create(EstateId,
MerchantId,
2026,
[
new MerchantScheduleMonth
{
Month = 1,
ClosedDays = [1, 26]
}
]);

result.IsSuccess.ShouldBeTrue();
aggregate.IsCreated.ShouldBeTrue();
aggregate.EstateId.ShouldBe(EstateId);
aggregate.MerchantId.ShouldBe(MerchantId);
aggregate.Year.ShouldBe(2026);

MerchantSchedule model = aggregate.GetSchedule();
model.Months.ShouldHaveSingleItem();
model.Months.Single().Month.ShouldBe(1);
model.Months.Single().ClosedDays.ShouldBe([1, 26]);
}

[Fact]
public void MerchantScheduleAggregate_Create_InvalidYear_ErrorReturned()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);

Result result = aggregate.Create(EstateId, MerchantId, 1899, []);

result.IsFailed.ShouldBeTrue();
result.Status.ShouldBe(ResultStatus.Invalid);
result.Message.ShouldBe("A valid year must be provided when creating a merchant schedule");
}

[Fact]
public void MerchantScheduleAggregate_SetMonthSchedule_WhenChanged_EmitsMonthEvent()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
aggregate.Create(EstateId, MerchantId, 2026, []);

Result result = aggregate.SetMonthSchedule(12, [25, 26]);

result.IsSuccess.ShouldBeTrue();

List<IDomainEvent> pendingEvents = GetPendingEvents(aggregate);
pendingEvents.Last().ShouldBeOfType<MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent>();

MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent updatedEvent =
(MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent)pendingEvents.Last();
updatedEvent.Month.ShouldBe(12);
updatedEvent.ClosedDays.ShouldBe([25, 26]);
}

[Fact]
public void MerchantScheduleAggregate_SetMonthSchedule_SameValue_NoAdditionalEventRaised()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
aggregate.Create(EstateId, MerchantId, 2026, []);
aggregate.SetMonthSchedule(5, [1, 2]);

List<IDomainEvent> pendingEvents = GetPendingEvents(aggregate);
Int32 originalEventCount = pendingEvents.Count;

Result result = aggregate.SetMonthSchedule(5, [2, 1]);

result.IsSuccess.ShouldBeTrue();
pendingEvents.Count.ShouldBe(originalEventCount);
}

[Fact]
public void MerchantScheduleAggregate_SetMonthSchedule_InvalidDay_ErrorReturned()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
aggregate.Create(EstateId, MerchantId, 2025, []);

Result result = aggregate.SetMonthSchedule(2, [29]);

result.IsFailed.ShouldBeTrue();
result.Status.ShouldBe(ResultStatus.Invalid);
result.Message.ShouldBe("Only days between 1 and 28 can be supplied for 2025-02");
}

[Fact]
public void MerchantScheduleAggregate_UpdateSchedule_DuplicateMonth_ErrorReturned()
{
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
aggregate.Create(EstateId, MerchantId, 2026, []);

Result result = aggregate.UpdateSchedule([
new MerchantScheduleMonth { Month = 6, ClosedDays = [1] },
new MerchantScheduleMonth { Month = 6, ClosedDays = [2] }
]);

result.IsFailed.ShouldBeTrue();
result.Status.ShouldBe(ResultStatus.Invalid);
result.Message.ShouldBe("Each month can only be provided once when updating a merchant schedule");
}

private static List<IDomainEvent> GetPendingEvents(MerchantScheduleAggregate aggregate)
{
PropertyInfo property = aggregate.GetType().GetProperty("PendingEvents", BindingFlags.Instance | BindingFlags.NonPublic);
property.ShouldNotBeNull();

Object value = property.GetValue(aggregate);
value.ShouldNotBeNull();

return (List<IDomainEvent>)value;
}
}
}
219 changes: 219 additions & 0 deletions TransactionProcessor.Aggregates/MerchantScheduleAggregate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
using Shared.DomainDrivenDesign.EventSourcing;
using Shared.EventStore.Aggregate;
using SimpleResults;
using System.Diagnostics.CodeAnalysis;
using TransactionProcessor.DomainEvents;
using MerchantScheduleModel = TransactionProcessor.Models.MerchantSchedule.MerchantSchedule;
using MerchantScheduleMonthModel = TransactionProcessor.Models.MerchantSchedule.MerchantScheduleMonth;

namespace TransactionProcessor.Aggregates
{
public static class MerchantScheduleAggregateExtensions
{
public static Result Create(this MerchantScheduleAggregate aggregate,
Guid estateId,
Guid merchantId,
Int32 year,
IEnumerable<MerchantScheduleMonthModel>? months)
{
Result result = ValidateCreateArguments(aggregate, estateId, merchantId, year);
if (result.IsFailed)
return result;

if (aggregate.IsCreated)
return Result.Success();

MerchantScheduleDomainEvents.MerchantScheduleCreatedEvent merchantScheduleCreatedEvent =
new(aggregate.AggregateId, estateId, merchantId, year);

aggregate.ApplyAndAppend(merchantScheduleCreatedEvent);

return aggregate.UpdateSchedule(months ?? []);
}

public static Result UpdateSchedule(this MerchantScheduleAggregate aggregate,
IEnumerable<MerchantScheduleMonthModel> months)
{
Result result = aggregate.EnsureScheduleHasBeenCreated();
if (result.IsFailed)
return result;

List<MerchantScheduleMonthModel> monthList = months?.ToList() ?? [];
if (monthList.GroupBy(m => m.Month).Any(g => g.Count() > 1))
return Result.Invalid("Each month can only be provided once when updating a merchant schedule");

foreach (MerchantScheduleMonthModel month in monthList.OrderBy(m => m.Month))
{
Result monthResult = aggregate.SetMonthSchedule(month.Month, month.ClosedDays);
if (monthResult.IsFailed)
return monthResult;
}

return Result.Success();
}

public static Result SetMonthSchedule(this MerchantScheduleAggregate aggregate,
Int32 month,
IEnumerable<Int32>? closedDays)
{
Result result = aggregate.EnsureScheduleHasBeenCreated();
if (result.IsFailed)
return result;

result = ValidateMonth(aggregate.Year, month, closedDays);
if (result.IsFailed)
return result;

Int32[] normalisedClosedDays = NormaliseDays(closedDays);

if (aggregate.Months.TryGetValue(month, out MerchantScheduleMonthModel? existingMonth))
{
if (existingMonth.ClosedDays.SequenceEqual(normalisedClosedDays))
return Result.Success();
}

MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent merchantScheduleMonthUpdatedEvent =
new(aggregate.AggregateId, aggregate.EstateId, aggregate.MerchantId, aggregate.Year, month, normalisedClosedDays);

aggregate.ApplyAndAppend(merchantScheduleMonthUpdatedEvent);

return Result.Success();
}

public static MerchantScheduleModel GetSchedule(this MerchantScheduleAggregate aggregate)
{
MerchantScheduleModel model = new()
{
MerchantScheduleId = aggregate.AggregateId,
EstateId = aggregate.EstateId,
MerchantId = aggregate.MerchantId,
Year = aggregate.Year,
Months = []
};

foreach (MerchantScheduleMonthModel month in aggregate.Months.Values.OrderBy(m => m.Month))
{
model.Months.Add(new MerchantScheduleMonthModel
{
Month = month.Month,
ClosedDays = [.. month.ClosedDays]
});
}

return model;
}

public static void PlayEvent(this MerchantScheduleAggregate aggregate,
MerchantScheduleDomainEvents.MerchantScheduleCreatedEvent domainEvent)
{
aggregate.EstateId = domainEvent.EstateId;
aggregate.MerchantId = domainEvent.MerchantId;
aggregate.Year = domainEvent.Year;
aggregate.IsCreated = true;
}

public static void PlayEvent(this MerchantScheduleAggregate aggregate,
MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent domainEvent)
{
aggregate.Months[domainEvent.Month] = new MerchantScheduleMonthModel
{
Month = domainEvent.Month,
ClosedDays = [.. domainEvent.ClosedDays]
};
}

private static Result EnsureScheduleHasBeenCreated(this MerchantScheduleAggregate aggregate)
{
if (aggregate.IsCreated == false)
return Result.Invalid("Merchant schedule has not been created");

return Result.Success();
}

private static Result ValidateCreateArguments(MerchantScheduleAggregate aggregate,
Guid estateId,
Guid merchantId,
Int32 year)
{
if (aggregate.AggregateId == Guid.Empty)
return Result.Invalid("Merchant schedule id must be provided");

if (estateId == Guid.Empty)
return Result.Invalid("Estate id must be provided when creating a merchant schedule");

if (merchantId == Guid.Empty)
return Result.Invalid("Merchant id must be provided when creating a merchant schedule");

if (year < 1900)
return Result.Invalid("A valid year must be provided when creating a merchant schedule");

return Result.Success();
}

private static Result ValidateMonth(Int32 year,
Int32 month,
IEnumerable<Int32>? closedDays)
{
if (month is < 1 or > 12)
return Result.Invalid("A valid month must be provided when updating a merchant schedule");

Int32 daysInMonth = DateTime.DaysInMonth(year, month);
Int32[] normalisedClosedDays = NormaliseDays(closedDays);

if (normalisedClosedDays.Any(day => day < 1 || day > daysInMonth))
return Result.Invalid($"Only days between 1 and {daysInMonth} can be supplied for {year}-{month:D2}");

return Result.Success();
}

private static Int32[] NormaliseDays(IEnumerable<Int32>? days) =>
(days ?? []).Distinct().OrderBy(day => day).ToArray();
}

public record MerchantScheduleAggregate : Aggregate
{
internal readonly Dictionary<Int32, MerchantScheduleMonthModel> Months;

[ExcludeFromCodeCoverage]
public MerchantScheduleAggregate()
{
this.Months = new Dictionary<Int32, MerchantScheduleMonthModel>();
}

private MerchantScheduleAggregate(Guid aggregateId)
{
if (aggregateId == Guid.Empty)
throw new ArgumentException("Value cannot be empty.", nameof(aggregateId));

this.AggregateId = aggregateId;
this.Months = new Dictionary<Int32, MerchantScheduleMonthModel>();
}

public Guid EstateId { get; internal set; }

public Guid MerchantId { get; internal set; }

public Int32 Year { get; internal set; }

public Boolean IsCreated { get; internal set; }

public static MerchantScheduleAggregate Create(Guid aggregateId)
{
return new MerchantScheduleAggregate(aggregateId);
}

public override void PlayEvent(IDomainEvent domainEvent) => MerchantScheduleAggregateExtensions.PlayEvent(this, (dynamic)domainEvent);

[ExcludeFromCodeCoverage]
protected override Object GetMetadata()
{
return new
{
MerchantScheduleId = this.AggregateId,
this.EstateId,
this.MerchantId,
this.Year
};
}
}
}
Loading
Loading