Skip to content

Commit d0670bf

Browse files
test(email): add unit and integration tests for email service
- Extract ISmtpClientFactory for testability (DI pattern) - Add 11 unit tests for EmailTemplates (content, XSS protection, HTML structure) - Add 13 unit tests for SmtpEmailService (SMTP calls, settings, error handling) - Add 7 integration tests sending real emails to contact@fantasy-realm.com - Integration tests use SkippableFact to skip when not configured - Configure shared user-secrets between Api and Tests.Integration
1 parent 16c8513 commit d0670bf

File tree

8 files changed

+589
-2
lines changed

8 files changed

+589
-2
lines changed

src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
4242
}
4343

4444
services.Configure<EmailSettings>(configuration.GetSection(EmailSettings.SectionName));
45+
services.AddSingleton<ISmtpClientFactory, SmtpClientFactory>();
4546
services.AddScoped<IEmailService, SmtpEmailService>();
4647

4748
return services;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using MailKit.Net.Smtp;
2+
3+
namespace FantasyRealm.Infrastructure.Email
4+
{
5+
/// <summary>
6+
/// Factory interface for creating SMTP client instances.
7+
/// Enables dependency injection and unit testing.
8+
/// </summary>
9+
public interface ISmtpClientFactory
10+
{
11+
/// <summary>
12+
/// Creates a new SMTP client instance.
13+
/// </summary>
14+
/// <returns>A new <see cref="ISmtpClient"/> instance.</returns>
15+
ISmtpClient Create();
16+
}
17+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using MailKit.Net.Smtp;
2+
3+
namespace FantasyRealm.Infrastructure.Email
4+
{
5+
/// <summary>
6+
/// Default factory implementation that creates real SMTP client instances.
7+
/// </summary>
8+
public class SmtpClientFactory : ISmtpClientFactory
9+
{
10+
/// <inheritdoc />
11+
public ISmtpClient Create()
12+
{
13+
return new SmtpClient();
14+
}
15+
}
16+
}

src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,22 @@ namespace FantasyRealm.Infrastructure.Email
1313
public class SmtpEmailService : IEmailService
1414
{
1515
private readonly EmailSettings _settings;
16+
private readonly ISmtpClientFactory _smtpClientFactory;
1617
private readonly ILogger<SmtpEmailService> _logger;
1718

1819
/// <summary>
1920
/// Initializes a new instance of the <see cref="SmtpEmailService"/> class.
2021
/// </summary>
2122
/// <param name="settings">The email configuration settings.</param>
23+
/// <param name="smtpClientFactory">The factory for creating SMTP clients.</param>
2224
/// <param name="logger">The logger instance.</param>
23-
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
25+
public SmtpEmailService(
26+
IOptions<EmailSettings> settings,
27+
ISmtpClientFactory smtpClientFactory,
28+
ILogger<SmtpEmailService> logger)
2429
{
2530
_settings = settings.Value;
31+
_smtpClientFactory = smtpClientFactory;
2632
_logger = logger;
2733
}
2834

@@ -97,7 +103,7 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod
97103

98104
try
99105
{
100-
using var client = new SmtpClient();
106+
using var client = _smtpClientFactory.Create();
101107

102108
var secureSocketOptions = _settings.UseSsl
103109
? SecureSocketOptions.StartTls
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using FantasyRealm.Infrastructure.Email;
2+
using Microsoft.Extensions.Configuration;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
5+
using Xunit.Abstractions;
6+
7+
namespace FantasyRealm.Tests.Integration.Email
8+
{
9+
/// <summary>
10+
/// Integration tests for SmtpEmailService that send real emails.
11+
/// These tests are skipped in CI and should be run manually.
12+
/// </summary>
13+
[Trait("Category", "Integration")]
14+
[Trait("Category", "Email")]
15+
public class SmtpEmailServiceIntegrationTests
16+
{
17+
private const string TestRecipient = "contact@fantasy-realm.com";
18+
19+
private readonly IConfiguration _configuration;
20+
private readonly EmailSettings _emailSettings;
21+
private readonly bool _isConfigured;
22+
private readonly ITestOutputHelper _output;
23+
24+
public SmtpEmailServiceIntegrationTests(ITestOutputHelper output)
25+
{
26+
_output = output;
27+
28+
_configuration = new ConfigurationBuilder()
29+
.AddJsonFile("appsettings.json", optional: true)
30+
.AddUserSecrets<SmtpEmailServiceIntegrationTests>()
31+
.AddEnvironmentVariables()
32+
.Build();
33+
34+
_emailSettings = new EmailSettings();
35+
_configuration.GetSection("Email").Bind(_emailSettings);
36+
37+
_isConfigured = !string.IsNullOrEmpty(_emailSettings.Host) &&
38+
!string.IsNullOrEmpty(_emailSettings.Password) &&
39+
!string.IsNullOrEmpty(_emailSettings.FromAddress);
40+
41+
_output.WriteLine($"Email configured: {_isConfigured}");
42+
_output.WriteLine($"Host: {_emailSettings.Host}");
43+
_output.WriteLine($"From: {_emailSettings.FromAddress}");
44+
}
45+
46+
[SkippableFact]
47+
public async Task SendWelcomeEmailAsync_SendsRealEmail()
48+
{
49+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
50+
51+
var service = CreateEmailService();
52+
53+
await service.SendWelcomeEmailAsync(TestRecipient, "IntegrationTestUser");
54+
55+
// If no exception is thrown, the email was sent successfully.
56+
// Check contact@fantasy-realm.com mailbox to verify.
57+
Assert.True(true);
58+
}
59+
60+
[SkippableFact]
61+
public async Task SendPasswordResetEmailAsync_SendsRealEmail()
62+
{
63+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
64+
65+
var service = CreateEmailService();
66+
67+
await service.SendPasswordResetEmailAsync(
68+
TestRecipient,
69+
"IntegrationTestUser",
70+
"test-reset-token-12345");
71+
72+
Assert.True(true);
73+
}
74+
75+
[SkippableFact]
76+
public async Task SendCharacterApprovedEmailAsync_SendsRealEmail()
77+
{
78+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
79+
80+
var service = CreateEmailService();
81+
82+
await service.SendCharacterApprovedEmailAsync(
83+
TestRecipient,
84+
"IntegrationTestUser",
85+
"Thorin the Brave");
86+
87+
Assert.True(true);
88+
}
89+
90+
[SkippableFact]
91+
public async Task SendCharacterRejectedEmailAsync_SendsRealEmail()
92+
{
93+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
94+
95+
var service = CreateEmailService();
96+
97+
await service.SendCharacterRejectedEmailAsync(
98+
TestRecipient,
99+
"IntegrationTestUser",
100+
"TestCharacter",
101+
"This is an integration test - character name contains test data.");
102+
103+
Assert.True(true);
104+
}
105+
106+
[SkippableFact]
107+
public async Task SendCommentApprovedEmailAsync_SendsRealEmail()
108+
{
109+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
110+
111+
var service = CreateEmailService();
112+
113+
await service.SendCommentApprovedEmailAsync(
114+
TestRecipient,
115+
"IntegrationTestUser",
116+
"Elara the Wise");
117+
118+
Assert.True(true);
119+
}
120+
121+
[SkippableFact]
122+
public async Task SendCommentRejectedEmailAsync_SendsRealEmail()
123+
{
124+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
125+
126+
var service = CreateEmailService();
127+
128+
await service.SendCommentRejectedEmailAsync(
129+
TestRecipient,
130+
"IntegrationTestUser",
131+
"TestCharacter",
132+
"This is an integration test - comment was flagged for testing purposes.");
133+
134+
Assert.True(true);
135+
}
136+
137+
[SkippableFact]
138+
public async Task SendAccountSuspendedEmailAsync_SendsRealEmail()
139+
{
140+
Skip.If(!_isConfigured, "Email settings not configured. Set Email:Password in user-secrets.");
141+
142+
var service = CreateEmailService();
143+
144+
await service.SendAccountSuspendedEmailAsync(
145+
TestRecipient,
146+
"IntegrationTestUser",
147+
"This is an integration test - no actual suspension occurred.");
148+
149+
Assert.True(true);
150+
}
151+
152+
private SmtpEmailService CreateEmailService()
153+
{
154+
var options = Options.Create(_emailSettings);
155+
var factory = new SmtpClientFactory();
156+
var logger = LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<SmtpEmailService>();
157+
158+
return new SmtpEmailService(options, factory, logger);
159+
}
160+
}
161+
}

src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<IsPackable>false</IsPackable>
8+
<UserSecretsId>007759ae-ee7a-4987-a1c3-cc316e317846</UserSecretsId>
89
</PropertyGroup>
910

1011
<ItemGroup>
1112
<PackageReference Include="coverlet.collector" Version="6.0.4" />
1213
<PackageReference Include="FluentAssertions" Version="8.8.0" />
1314
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
15+
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
1416
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
1517
<PackageReference Include="Testcontainers.MongoDb" Version="4.9.0" />
1618
<PackageReference Include="Testcontainers.PostgreSql" Version="4.9.0" />
1719
<PackageReference Include="xunit" Version="2.9.3" />
1820
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
21+
<PackageReference Include="Xunit.SkippableFact" Version="1.5.23" />
1922
</ItemGroup>
2023

2124
<ItemGroup>
@@ -27,4 +30,8 @@
2730
<ProjectReference Include="..\..\src\FantasyRealm.Infrastructure\FantasyRealm.Infrastructure.csproj" />
2831
</ItemGroup>
2932

33+
<ItemGroup>
34+
<None Include="..\..\src\FantasyRealm.Api\appsettings.json" Link="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
35+
</ItemGroup>
36+
3037
</Project>

0 commit comments

Comments
 (0)