Skip to content

Commit 572f7de

Browse files
committed
refactor: Consolidate handler unit test setup using a shared base class and aggregate factory.
1 parent c064930 commit 572f7de

7 files changed

Lines changed: 193 additions & 355 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Reflection;
2+
3+
namespace BookStore.ApiService.UnitTests;
4+
5+
/// <summary>
6+
/// Domain-neutral factory to create and hydrate aggregates for testing purposes.
7+
/// This mimics how Marten rehydrates aggregates from the event stream.
8+
/// </summary>
9+
public static class AggregateFactory
10+
{
11+
public static T Hydrate<T>(params object[] events) where T : class
12+
{
13+
var aggregate = (T)Activator.CreateInstance(typeof(T), true)!;
14+
var type = typeof(T);
15+
16+
foreach (var @event in events)
17+
{
18+
// Find the appropriate Apply method for this event type
19+
var applyMethod = type.GetMethod("Apply",
20+
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
21+
[@event.GetType()]);
22+
23+
if (applyMethod != null)
24+
{
25+
_ = applyMethod.Invoke(aggregate, [@event]);
26+
}
27+
else
28+
{
29+
throw new InvalidOperationException(
30+
$"Aggregate {type.Name} does not have an Apply method for event type {@event.GetType().Name}");
31+
}
32+
}
33+
34+
return aggregate;
35+
}
36+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
global using BookStore.Shared.Models;
2+
global using NSubstitute;

tests/BookStore.ApiService.UnitTests/Handlers/AuthorHandlerTests.cs

Lines changed: 31 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,11 @@
22
using BookStore.ApiService.Commands;
33
using BookStore.ApiService.Events;
44
using BookStore.ApiService.Handlers.Authors;
5-
using BookStore.ApiService.Infrastructure;
6-
using BookStore.Shared.Models;
7-
using Marten;
85
using Microsoft.AspNetCore.Http;
9-
using Microsoft.Extensions.Caching.Hybrid;
10-
using Microsoft.Extensions.Logging;
11-
using Microsoft.Extensions.Options;
12-
using NSubstitute;
136

147
namespace BookStore.ApiService.UnitTests.Handlers;
158

16-
public class AuthorHandlerTests
9+
public class AuthorHandlerTests : HandlerTestBase
1710
{
1811
[Test]
1912
[Category("Unit")]
@@ -25,24 +18,14 @@ public async Task CreateAuthorHandler_ShouldStartStreamWithAuthorAddedEvent()
2518
new Dictionary<string, AuthorTranslationDto> { ["en"] = new AuthorTranslationDto("Uncle Bob") }
2619
);
2720

28-
var session = Substitute.For<IDocumentSession>();
29-
_ = session.CorrelationId.Returns("test-correlation-id");
30-
31-
var localizationOptions = Options.Create(new LocalizationOptions
32-
{
33-
DefaultCulture = "en",
34-
SupportedCultures = ["en"]
35-
});
36-
3721
// Act
38-
var result = await AuthorHandlers.Handle(command, session, localizationOptions, Substitute.For<HybridCache>(),
39-
Substitute.For<ILogger>());
22+
var result = await AuthorHandlers.Handle(command, Session, LocalizationOptions, Cache, Logger);
4023

4124
// Assert
4225
_ = await Assert.That(result).IsNotNull();
43-
_ = session.Events.Received(1).StartStream<AuthorAggregate>(
26+
_ = Session.Events.Received(1).StartStream<AuthorAggregate>(
4427
command.Id,
45-
Arg.Is<AuthorAdded>(e =>
28+
Arg.Is<AuthorAdded>((AuthorAdded e) =>
4629
e.Name == "Robert C. Martin"));
4730
}
4831

@@ -59,21 +42,12 @@ public async Task CreateAuthorHandler_WithInvalidCulture_ShouldReturnBadRequest(
5942
new Dictionary<string, AuthorTranslationDto> { [invalidCulture] = new AuthorTranslationDto("Uncle Bob") }
6043
);
6144

62-
var session = Substitute.For<IDocumentSession>();
63-
var localizationOptions = Options.Create(new LocalizationOptions
64-
{
65-
DefaultCulture = "en",
66-
SupportedCultures = ["en"]
67-
});
68-
6945
// Act
70-
var result = await AuthorHandlers.Handle(command, session, localizationOptions, Substitute.For<HybridCache>(),
71-
Substitute.For<ILogger>());
46+
var result = await AuthorHandlers.Handle(command, Session, LocalizationOptions, Cache, Logger);
7247

7348
// Assert
74-
// Assert
75-
_ = await Assert.That(result).IsAssignableTo<Microsoft.AspNetCore.Http.IStatusCodeHttpResult>();
76-
var badRequestResult = (Microsoft.AspNetCore.Http.IStatusCodeHttpResult)result;
49+
_ = await Assert.That(result).IsAssignableTo<IStatusCodeHttpResult>();
50+
var badRequestResult = (IStatusCodeHttpResult)result;
7751
_ = await Assert.That(badRequestResult.StatusCode).IsEqualTo(400);
7852
}
7953

@@ -89,33 +63,24 @@ public async Task UpdateAuthorHandler_ShouldAppendAuthorUpdatedEvent()
8963
)
9064
{ ETag = "test-etag" };
9165

92-
var session = Substitute.For<IDocumentSession>();
93-
var httpContext = new DefaultHttpContext();
94-
var httpContextAccessor = Substitute.For<IHttpContextAccessor>();
95-
_ = httpContextAccessor.HttpContext.Returns(httpContext);
96-
97-
var localizationOptions = Options.Create(new LocalizationOptions
98-
{
99-
DefaultCulture = "en",
100-
SupportedCultures = ["en"]
101-
});
102-
10366
// Mock Stream State
104-
_ = session.Events.FetchStreamStateAsync(command.Id).Returns(new Marten.Events.StreamState { Version = 1 });
67+
_ = Session.Events.FetchStreamStateAsync(command.Id).Returns(new Marten.Events.StreamState { Version = 1 });
10568

10669
// Mock Aggregate Load
107-
var existingAggregate = CreateAuthorAggregate(command.Id, "Old Name", false);
108-
_ = session.Events.AggregateStreamAsync<AuthorAggregate>(command.Id).Returns(existingAggregate);
70+
var existingAggregate = AggregateFactory.Hydrate<AuthorAggregate>(
71+
new AuthorAdded(command.Id, "Old Name", new Dictionary<string, AuthorTranslation> { ["en"] = new("Bio") },
72+
DateTimeOffset.UtcNow));
73+
_ = Session.Events.AggregateStreamAsync<AuthorAggregate>(command.Id).Returns(existingAggregate);
10974

11075
// Act
111-
var result = await AuthorHandlers.Handle(command, session, httpContextAccessor, localizationOptions,
112-
Substitute.For<HybridCache>(), Substitute.For<ILogger>());
76+
var result =
77+
await AuthorHandlers.Handle(command, Session, HttpContextAccessor, LocalizationOptions, Cache, Logger);
11378

11479
// Assert
11580
_ = await Assert.That(result).IsTypeOf<Microsoft.AspNetCore.Http.HttpResults.NoContent>();
116-
_ = session.Events.Received(1).Append(
81+
_ = Session.Events.Received(1).Append(
11782
command.Id,
118-
Arg.Is<AuthorUpdated>(e =>
83+
Arg.Is<AuthorUpdated>((AuthorUpdated e) =>
11984
e.Name == "Robert C. Martin Updated"));
12085
}
12186

@@ -127,25 +92,21 @@ public async Task SoftDeleteAuthorHandler_ShouldAppendAuthorSoftDeletedEvent()
12792
var id = Guid.CreateVersion7();
12893
var command = new SoftDeleteAuthor(id);
12994

130-
var session = Substitute.For<IDocumentSession>();
131-
var httpContext = new DefaultHttpContext();
132-
var httpContextAccessor = Substitute.For<IHttpContextAccessor>();
133-
_ = httpContextAccessor.HttpContext.Returns(httpContext);
134-
13595
// Mock Stream State
136-
_ = session.Events.FetchStreamStateAsync(id).Returns(new Marten.Events.StreamState { Version = 1 });
96+
_ = Session.Events.FetchStreamStateAsync(id).Returns(new Marten.Events.StreamState { Version = 1 });
13797

13898
// Mock Aggregate Load
139-
var existingAggregate = CreateAuthorAggregate(id, "Author", false);
140-
_ = session.Events.AggregateStreamAsync<AuthorAggregate>(id).Returns(existingAggregate);
99+
var existingAggregate = AggregateFactory.Hydrate<AuthorAggregate>(
100+
new AuthorAdded(id, "Author", new Dictionary<string, AuthorTranslation> { ["en"] = new("Bio") },
101+
DateTimeOffset.UtcNow));
102+
_ = Session.Events.AggregateStreamAsync<AuthorAggregate>(id).Returns(existingAggregate);
141103

142104
// Act
143-
var result = await AuthorHandlers.Handle(command, session, httpContextAccessor, Substitute.For<HybridCache>(),
144-
Substitute.For<ILogger>());
105+
var result = await AuthorHandlers.Handle(command, Session, HttpContextAccessor, Cache, Logger);
145106

146107
// Assert
147108
_ = await Assert.That(result).IsTypeOf<Microsoft.AspNetCore.Http.HttpResults.NoContent>();
148-
_ = session.Events.Received(1).Append(
109+
_ = Session.Events.Received(1).Append(
149110
id,
150111
Arg.Is<AuthorSoftDeleted>(e => e.Id == id));
151112
}
@@ -158,43 +119,23 @@ public async Task RestoreAuthorHandler_ShouldAppendAuthorRestoredEvent()
158119
var id = Guid.CreateVersion7();
159120
var command = new RestoreAuthor(id);
160121

161-
var session = Substitute.For<IDocumentSession>();
162-
var httpContext = new DefaultHttpContext();
163-
var httpContextAccessor = Substitute.For<IHttpContextAccessor>();
164-
_ = httpContextAccessor.HttpContext.Returns(httpContext);
165-
166122
// Mock Stream State
167-
_ = session.Events.FetchStreamStateAsync(id).Returns(new Marten.Events.StreamState { Version = 1 });
123+
_ = Session.Events.FetchStreamStateAsync(id).Returns(new Marten.Events.StreamState { Version = 1 });
168124

169125
// Mock Aggregate Load - Create DELETED aggregate
170-
var existingAggregate = CreateAuthorAggregate(id, "Author", true);
171-
_ = session.Events.AggregateStreamAsync<AuthorAggregate>(id).Returns(existingAggregate);
126+
var existingAggregate = AggregateFactory.Hydrate<AuthorAggregate>(
127+
new AuthorAdded(id, "Author", new Dictionary<string, AuthorTranslation> { ["en"] = new("Bio") },
128+
DateTimeOffset.UtcNow),
129+
new AuthorSoftDeleted(id, DateTimeOffset.UtcNow));
130+
_ = Session.Events.AggregateStreamAsync<AuthorAggregate>(id).Returns(existingAggregate);
172131

173132
// Act
174-
var result = await AuthorHandlers.Handle(command, session, httpContextAccessor, Substitute.For<HybridCache>(),
175-
Substitute.For<ILogger>());
133+
var result = await AuthorHandlers.Handle(command, Session, HttpContextAccessor, Cache, Logger);
176134

177135
// Assert
178136
_ = await Assert.That(result).IsTypeOf<Microsoft.AspNetCore.Http.HttpResults.NoContent>();
179-
_ = session.Events.Received(1).Append(
137+
_ = Session.Events.Received(1).Append(
180138
id,
181139
Arg.Is<AuthorRestored>(e => e.Id == id));
182140
}
183-
184-
static AuthorAggregate CreateAuthorAggregate(Guid id, string name, bool isDeleted)
185-
{
186-
// Create instance (AuthorAggregate has parameterless constructor but it might be private/internal?)
187-
// It's public class, but properties are private set.
188-
// Assuming default constructor is accessible or we use Activator.
189-
var aggregate = (AuthorAggregate)Activator.CreateInstance(typeof(AuthorAggregate), true)!;
190-
191-
// Set properties via reflection
192-
typeof(AuthorAggregate).GetProperty(nameof(AuthorAggregate.Id))!.SetValue(aggregate, id);
193-
typeof(AuthorAggregate).GetProperty(nameof(AuthorAggregate.Name))!.SetValue(aggregate, name);
194-
typeof(AuthorAggregate).GetProperty(nameof(AuthorAggregate.Deleted))!.SetValue(aggregate, isDeleted);
195-
typeof(AuthorAggregate).GetProperty(nameof(AuthorAggregate.Translations))!.SetValue(aggregate,
196-
new Dictionary<string, AuthorTranslation> { ["en"] = new("Bio") });
197-
198-
return aggregate;
199-
}
200141
}

0 commit comments

Comments
 (0)