Skip to content

Commit c81f202

Browse files
Merge pull request #1659 from TransactionProcessing/copilot/add-merchant-schedule-endpoint
Add read-model merchant schedule endpoint and use it in schedule creation validation
2 parents da3b773 + e4fa5f4 commit c81f202

52 files changed

Lines changed: 4229 additions & 928 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using Shared.DomainDrivenDesign.EventSourcing;
2+
using Shouldly;
3+
using SimpleResults;
4+
using System.Reflection;
5+
using TransactionProcessor.DomainEvents;
6+
using TransactionProcessor.Models.MerchantSchedule;
7+
8+
namespace TransactionProcessor.Aggregates.Tests
9+
{
10+
public class MerchantScheduleAggregateTests
11+
{
12+
private static readonly Guid MerchantScheduleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
13+
private static readonly Guid EstateId = Guid.Parse("22222222-2222-2222-2222-222222222222");
14+
private static readonly Guid MerchantId = Guid.Parse("33333333-3333-3333-3333-333333333333");
15+
16+
[Fact]
17+
public void MerchantScheduleAggregate_CanBeCreated_IsCreated()
18+
{
19+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
20+
21+
aggregate.AggregateId.ShouldBe(MerchantScheduleId);
22+
aggregate.IsCreated.ShouldBeFalse();
23+
}
24+
25+
[Fact]
26+
public void MerchantScheduleAggregate_Create_WithInitialMonths_IsCreated()
27+
{
28+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
29+
30+
Result result = aggregate.Create(EstateId,
31+
MerchantId,
32+
2026,
33+
[
34+
new MerchantScheduleMonth
35+
{
36+
Month = 1,
37+
ClosedDays = [1, 26]
38+
}
39+
]);
40+
41+
result.IsSuccess.ShouldBeTrue();
42+
aggregate.IsCreated.ShouldBeTrue();
43+
aggregate.EstateId.ShouldBe(EstateId);
44+
aggregate.MerchantId.ShouldBe(MerchantId);
45+
aggregate.Year.ShouldBe(2026);
46+
47+
MerchantSchedule model = aggregate.GetSchedule();
48+
model.Months.ShouldHaveSingleItem();
49+
model.Months.Single().Month.ShouldBe(1);
50+
model.Months.Single().ClosedDays.ShouldBe([1, 26]);
51+
}
52+
53+
[Fact]
54+
public void MerchantScheduleAggregate_Create_InvalidYear_ErrorReturned()
55+
{
56+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
57+
58+
Result result = aggregate.Create(EstateId, MerchantId, 1899, []);
59+
60+
result.IsFailed.ShouldBeTrue();
61+
result.Status.ShouldBe(ResultStatus.Invalid);
62+
result.Message.ShouldBe("A valid year must be provided when creating a merchant schedule");
63+
}
64+
65+
[Fact]
66+
public void MerchantScheduleAggregate_SetMonthSchedule_WhenChanged_EmitsMonthEvent()
67+
{
68+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
69+
aggregate.Create(EstateId, MerchantId, 2026, []);
70+
71+
Result result = aggregate.SetMonthSchedule(12, [25, 26]);
72+
73+
result.IsSuccess.ShouldBeTrue();
74+
75+
List<IDomainEvent> pendingEvents = GetPendingEvents(aggregate);
76+
pendingEvents.Last().ShouldBeOfType<MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent>();
77+
78+
MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent updatedEvent =
79+
(MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent)pendingEvents.Last();
80+
updatedEvent.Month.ShouldBe(12);
81+
updatedEvent.ClosedDays.ShouldBe([25, 26]);
82+
}
83+
84+
[Fact]
85+
public void MerchantScheduleAggregate_SetMonthSchedule_SameValue_NoAdditionalEventRaised()
86+
{
87+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
88+
aggregate.Create(EstateId, MerchantId, 2026, []);
89+
aggregate.SetMonthSchedule(5, [1, 2]);
90+
91+
List<IDomainEvent> pendingEvents = GetPendingEvents(aggregate);
92+
Int32 originalEventCount = pendingEvents.Count;
93+
94+
Result result = aggregate.SetMonthSchedule(5, [2, 1]);
95+
96+
result.IsSuccess.ShouldBeTrue();
97+
pendingEvents.Count.ShouldBe(originalEventCount);
98+
}
99+
100+
[Fact]
101+
public void MerchantScheduleAggregate_SetMonthSchedule_InvalidDay_ErrorReturned()
102+
{
103+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
104+
aggregate.Create(EstateId, MerchantId, 2025, []);
105+
106+
Result result = aggregate.SetMonthSchedule(2, [29]);
107+
108+
result.IsFailed.ShouldBeTrue();
109+
result.Status.ShouldBe(ResultStatus.Invalid);
110+
result.Message.ShouldBe("Only days between 1 and 28 can be supplied for 2025-02");
111+
}
112+
113+
[Fact]
114+
public void MerchantScheduleAggregate_UpdateSchedule_DuplicateMonth_ErrorReturned()
115+
{
116+
MerchantScheduleAggregate aggregate = MerchantScheduleAggregate.Create(MerchantScheduleId);
117+
aggregate.Create(EstateId, MerchantId, 2026, []);
118+
119+
Result result = aggregate.UpdateSchedule([
120+
new MerchantScheduleMonth { Month = 6, ClosedDays = [1] },
121+
new MerchantScheduleMonth { Month = 6, ClosedDays = [2] }
122+
]);
123+
124+
result.IsFailed.ShouldBeTrue();
125+
result.Status.ShouldBe(ResultStatus.Invalid);
126+
result.Message.ShouldBe("Each month can only be provided once when updating a merchant schedule");
127+
}
128+
129+
private static List<IDomainEvent> GetPendingEvents(MerchantScheduleAggregate aggregate)
130+
{
131+
PropertyInfo property = aggregate.GetType().GetProperty("PendingEvents", BindingFlags.Instance | BindingFlags.NonPublic);
132+
property.ShouldNotBeNull();
133+
134+
Object value = property.GetValue(aggregate);
135+
value.ShouldNotBeNull();
136+
137+
return (List<IDomainEvent>)value;
138+
}
139+
}
140+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using Shared.DomainDrivenDesign.EventSourcing;
2+
using Shared.EventStore.Aggregate;
3+
using SimpleResults;
4+
using System.Diagnostics.CodeAnalysis;
5+
using TransactionProcessor.DomainEvents;
6+
using MerchantScheduleModel = TransactionProcessor.Models.MerchantSchedule.MerchantSchedule;
7+
using MerchantScheduleMonthModel = TransactionProcessor.Models.MerchantSchedule.MerchantScheduleMonth;
8+
9+
namespace TransactionProcessor.Aggregates
10+
{
11+
public static class MerchantScheduleAggregateExtensions
12+
{
13+
public static Result Create(this MerchantScheduleAggregate aggregate,
14+
Guid estateId,
15+
Guid merchantId,
16+
Int32 year,
17+
IEnumerable<MerchantScheduleMonthModel>? months)
18+
{
19+
Result result = ValidateCreateArguments(aggregate, estateId, merchantId, year);
20+
if (result.IsFailed)
21+
return result;
22+
23+
if (aggregate.IsCreated)
24+
return Result.Success();
25+
26+
MerchantScheduleDomainEvents.MerchantScheduleCreatedEvent merchantScheduleCreatedEvent =
27+
new(aggregate.AggregateId, estateId, merchantId, year);
28+
29+
aggregate.ApplyAndAppend(merchantScheduleCreatedEvent);
30+
31+
return aggregate.UpdateSchedule(months ?? []);
32+
}
33+
34+
public static Result UpdateSchedule(this MerchantScheduleAggregate aggregate,
35+
IEnumerable<MerchantScheduleMonthModel> months)
36+
{
37+
Result result = aggregate.EnsureScheduleHasBeenCreated();
38+
if (result.IsFailed)
39+
return result;
40+
41+
List<MerchantScheduleMonthModel> monthList = months?.ToList() ?? [];
42+
if (monthList.GroupBy(m => m.Month).Any(g => g.Count() > 1))
43+
return Result.Invalid("Each month can only be provided once when updating a merchant schedule");
44+
45+
foreach (MerchantScheduleMonthModel month in monthList.OrderBy(m => m.Month))
46+
{
47+
Result monthResult = aggregate.SetMonthSchedule(month.Month, month.ClosedDays);
48+
if (monthResult.IsFailed)
49+
return monthResult;
50+
}
51+
52+
return Result.Success();
53+
}
54+
55+
public static Result SetMonthSchedule(this MerchantScheduleAggregate aggregate,
56+
Int32 month,
57+
IEnumerable<Int32>? closedDays)
58+
{
59+
Result result = aggregate.EnsureScheduleHasBeenCreated();
60+
if (result.IsFailed)
61+
return result;
62+
63+
result = ValidateMonth(aggregate.Year, month, closedDays);
64+
if (result.IsFailed)
65+
return result;
66+
67+
Int32[] normalisedClosedDays = NormaliseDays(closedDays);
68+
69+
if (aggregate.Months.TryGetValue(month, out MerchantScheduleMonthModel? existingMonth))
70+
{
71+
if (existingMonth.ClosedDays.SequenceEqual(normalisedClosedDays))
72+
return Result.Success();
73+
}
74+
75+
MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent merchantScheduleMonthUpdatedEvent =
76+
new(aggregate.AggregateId, aggregate.EstateId, aggregate.MerchantId, aggregate.Year, month, normalisedClosedDays);
77+
78+
aggregate.ApplyAndAppend(merchantScheduleMonthUpdatedEvent);
79+
80+
return Result.Success();
81+
}
82+
83+
public static MerchantScheduleModel GetSchedule(this MerchantScheduleAggregate aggregate)
84+
{
85+
MerchantScheduleModel model = new()
86+
{
87+
MerchantScheduleId = aggregate.AggregateId,
88+
EstateId = aggregate.EstateId,
89+
MerchantId = aggregate.MerchantId,
90+
Year = aggregate.Year,
91+
Months = []
92+
};
93+
94+
foreach (MerchantScheduleMonthModel month in aggregate.Months.Values.OrderBy(m => m.Month))
95+
{
96+
model.Months.Add(new MerchantScheduleMonthModel
97+
{
98+
Month = month.Month,
99+
ClosedDays = [.. month.ClosedDays]
100+
});
101+
}
102+
103+
return model;
104+
}
105+
106+
public static void PlayEvent(this MerchantScheduleAggregate aggregate,
107+
MerchantScheduleDomainEvents.MerchantScheduleCreatedEvent domainEvent)
108+
{
109+
aggregate.EstateId = domainEvent.EstateId;
110+
aggregate.MerchantId = domainEvent.MerchantId;
111+
aggregate.Year = domainEvent.Year;
112+
aggregate.IsCreated = true;
113+
}
114+
115+
public static void PlayEvent(this MerchantScheduleAggregate aggregate,
116+
MerchantScheduleDomainEvents.MerchantScheduleMonthUpdatedEvent domainEvent)
117+
{
118+
aggregate.Months[domainEvent.Month] = new MerchantScheduleMonthModel
119+
{
120+
Month = domainEvent.Month,
121+
ClosedDays = [.. domainEvent.ClosedDays]
122+
};
123+
}
124+
125+
private static Result EnsureScheduleHasBeenCreated(this MerchantScheduleAggregate aggregate)
126+
{
127+
if (aggregate.IsCreated == false)
128+
return Result.Invalid("Merchant schedule has not been created");
129+
130+
return Result.Success();
131+
}
132+
133+
private static Result ValidateCreateArguments(MerchantScheduleAggregate aggregate,
134+
Guid estateId,
135+
Guid merchantId,
136+
Int32 year)
137+
{
138+
if (aggregate.AggregateId == Guid.Empty)
139+
return Result.Invalid("Merchant schedule id must be provided");
140+
141+
if (estateId == Guid.Empty)
142+
return Result.Invalid("Estate id must be provided when creating a merchant schedule");
143+
144+
if (merchantId == Guid.Empty)
145+
return Result.Invalid("Merchant id must be provided when creating a merchant schedule");
146+
147+
if (year < 1900)
148+
return Result.Invalid("A valid year must be provided when creating a merchant schedule");
149+
150+
return Result.Success();
151+
}
152+
153+
private static Result ValidateMonth(Int32 year,
154+
Int32 month,
155+
IEnumerable<Int32>? closedDays)
156+
{
157+
if (month is < 1 or > 12)
158+
return Result.Invalid("A valid month must be provided when updating a merchant schedule");
159+
160+
Int32 daysInMonth = DateTime.DaysInMonth(year, month);
161+
Int32[] normalisedClosedDays = NormaliseDays(closedDays);
162+
163+
if (normalisedClosedDays.Any(day => day < 1 || day > daysInMonth))
164+
return Result.Invalid($"Only days between 1 and {daysInMonth} can be supplied for {year}-{month:D2}");
165+
166+
return Result.Success();
167+
}
168+
169+
private static Int32[] NormaliseDays(IEnumerable<Int32>? days) =>
170+
(days ?? []).Distinct().OrderBy(day => day).ToArray();
171+
}
172+
173+
public record MerchantScheduleAggregate : Aggregate
174+
{
175+
internal readonly Dictionary<Int32, MerchantScheduleMonthModel> Months;
176+
177+
[ExcludeFromCodeCoverage]
178+
public MerchantScheduleAggregate()
179+
{
180+
this.Months = new Dictionary<Int32, MerchantScheduleMonthModel>();
181+
}
182+
183+
private MerchantScheduleAggregate(Guid aggregateId)
184+
{
185+
if (aggregateId == Guid.Empty)
186+
throw new ArgumentException("Value cannot be empty.", nameof(aggregateId));
187+
188+
this.AggregateId = aggregateId;
189+
this.Months = new Dictionary<Int32, MerchantScheduleMonthModel>();
190+
}
191+
192+
public Guid EstateId { get; internal set; }
193+
194+
public Guid MerchantId { get; internal set; }
195+
196+
public Int32 Year { get; internal set; }
197+
198+
public Boolean IsCreated { get; internal set; }
199+
200+
public static MerchantScheduleAggregate Create(Guid aggregateId)
201+
{
202+
return new MerchantScheduleAggregate(aggregateId);
203+
}
204+
205+
public override void PlayEvent(IDomainEvent domainEvent) => MerchantScheduleAggregateExtensions.PlayEvent(this, (dynamic)domainEvent);
206+
207+
[ExcludeFromCodeCoverage]
208+
protected override Object GetMetadata()
209+
{
210+
return new
211+
{
212+
MerchantScheduleId = this.AggregateId,
213+
this.EstateId,
214+
this.MerchantId,
215+
this.Year
216+
};
217+
}
218+
}
219+
}

0 commit comments

Comments
 (0)