Introduce Yavsc.Interfaces.ISmtpClient and a recording test fake
Narrow ISmtpClient to the four operations MailSender actually uses, behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake (RecordingSmtpClientFactory). The fake is pre-registered in WebServerFixture so SMTP calls are short-circuited; the EMailling test now asserts the Connect -> Authenticate -> Send -> Disconnect sequence. Yavsc.Abstract stays free of MailKit/MimeKit.
This commit is contained in:
parent
16508e9fb2
commit
2dec799d71
13 changed files with 264 additions and 10 deletions
|
|
@ -19,6 +19,6 @@ internal static class PlatformBootstrap
|
|||
return;
|
||||
|
||||
Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri;
|
||||
Platform.CreateBrowser = () => new LoopbackBrowser();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,12 @@ using Anthropic.SDK;
|
|||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Yavsc;
|
||||
using Yavsc.Abstract.Interfaces;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Interfaces;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
|
|
@ -84,6 +86,8 @@ internal class Program
|
|||
services.AddTransient<ITrueEmailSender, MailSender>()
|
||||
.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender,
|
||||
MailSender>()
|
||||
.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
|
||||
services
|
||||
.AddTransient<IBillingService, BillingService>()
|
||||
.AddTransient<ICalendarManager, CalendarManager>();
|
||||
services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ using IdentityModel;
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Yavsc;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Interfaces;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -48,6 +51,8 @@ internal class Program
|
|||
services
|
||||
.AddTransient<ITrueEmailSender, MailSender>()
|
||||
.AddTransient<IEmailSender<ApplicationUser>, MailSender>()
|
||||
.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
|
||||
services
|
||||
.AddTransient<IBillingService, BillingService>()
|
||||
.AddTransient<ICalendarManager, CalendarManager>()
|
||||
.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>()
|
||||
|
|
|
|||
71
src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs
Normal file
71
src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
using Yavsc.Interfaces;
|
||||
|
||||
namespace Yavsc.Org.Tests.Fakes
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory test double for <see cref="ISmtpClient"/>. Records
|
||||
/// every call for assertion and short-circuits the SMTP
|
||||
/// roundtrip.
|
||||
/// </summary>
|
||||
public sealed class RecordingSmtpClient : ISmtpClient
|
||||
{
|
||||
public List<RecordingSmtpCall> Calls { get; } = new();
|
||||
|
||||
public int Timeout { get; set; }
|
||||
public string? LastConnectedHost { get; private set; }
|
||||
public int LastConnectedPort { get; private set; }
|
||||
public SecureSocketOptions LastConnectedOptions { get; private set; }
|
||||
public string? LastAuthenticatedUser { get; private set; }
|
||||
public MimeMessage? LastSentMessage { get; private set; }
|
||||
public bool WasDisconnected { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Connect(string host, int port, SecureSocketOptions options)
|
||||
{
|
||||
LastConnectedHost = host;
|
||||
LastConnectedPort = port;
|
||||
LastConnectedOptions = options;
|
||||
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Connect, host, port, options));
|
||||
}
|
||||
|
||||
public void Authenticate(string userName, string password)
|
||||
{
|
||||
LastAuthenticatedUser = userName;
|
||||
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Authenticate, userName, null, null));
|
||||
}
|
||||
|
||||
public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSentMessage = message;
|
||||
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Send, message.Subject, null, null));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Disconnect(bool quit)
|
||||
{
|
||||
WasDisconnected = quit;
|
||||
Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Disconnect, null, null, null));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum RecordingSmtpCallKind
|
||||
{
|
||||
Connect,
|
||||
Authenticate,
|
||||
Send,
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
public readonly record struct RecordingSmtpCall(
|
||||
RecordingSmtpCallKind Kind,
|
||||
string? HostOrSubjectOrUser,
|
||||
int? Port,
|
||||
SecureSocketOptions? Options);
|
||||
}
|
||||
21
src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs
Normal file
21
src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Yavsc.Interfaces;
|
||||
|
||||
namespace Yavsc.Org.Tests.Fakes
|
||||
{
|
||||
/// <summary>
|
||||
/// Test double for <see cref="ISmtpClientFactory"/>. Hands out
|
||||
/// <see cref="RecordingSmtpClient"/> instances and tracks them so
|
||||
/// tests can assert on every SMTP interaction.
|
||||
/// </summary>
|
||||
public sealed class RecordingSmtpClientFactory : ISmtpClientFactory
|
||||
{
|
||||
public List<RecordingSmtpClient> Created { get; } = new();
|
||||
|
||||
public ISmtpClient CreateClient()
|
||||
{
|
||||
var client = new RecordingSmtpClient();
|
||||
Created.Add(client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Interfaces;
|
||||
using Yavsc.Org.Tests.Fakes;
|
||||
|
||||
namespace Yavsc.Org.Tests
|
||||
{
|
||||
|
|
@ -26,6 +28,8 @@ namespace Yavsc.Org.Tests
|
|||
|
||||
using IServiceScope scope = _serverFixture.Services.CreateScope();
|
||||
ITrueEmailSender mailSender = scope.ServiceProvider.GetRequiredService<ITrueEmailSender>();
|
||||
var factory = Assert.IsType<RecordingSmtpClientFactory>(
|
||||
scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>());
|
||||
|
||||
output.WriteLine("SendEMailSynchrone ...");
|
||||
mailSender.SendEmailAsync
|
||||
|
|
@ -35,6 +39,21 @@ namespace Yavsc.Org.Tests
|
|||
$"monthly email",
|
||||
"test boby monthly email").Wait();
|
||||
|
||||
// Assert the SMTP roundtrip was short-circuited by the
|
||||
// recording fake installed in WebServerFixture: exactly
|
||||
// one client was created and it saw the expected sequence
|
||||
// of Connect → Authenticate → Send → Disconnect.
|
||||
var client = Assert.Single(factory.Created);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
RecordingSmtpCallKind.Connect,
|
||||
RecordingSmtpCallKind.Authenticate,
|
||||
RecordingSmtpCallKind.Send,
|
||||
RecordingSmtpCallKind.Disconnect,
|
||||
},
|
||||
client.Calls.Select(c => c.Kind).ToArray());
|
||||
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ using System.Security.Cryptography;
|
|||
using System.Security.Cryptography.X509Certificates;
|
||||
using Yavsc;
|
||||
using Yavsc.Extensions;
|
||||
using Yavsc.Interfaces;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Client = IdentityServer8.EntityFramework.Entities.Client;
|
||||
using Yavsc.Org.Tests.Fakes;
|
||||
|
||||
|
||||
namespace Yavsc.Org.Tests
|
||||
|
|
@ -40,6 +42,7 @@ namespace Yavsc.Org.Tests
|
|||
private static string? _sharedTestingUserName;
|
||||
private static string? _sharedTestingUserPassword;
|
||||
private static string? _sharedTestingUserEmail;
|
||||
private static RecordingSmtpClientFactory? _sharedSmtpClientFactory;
|
||||
private static IServiceProvider? _sharedServices;
|
||||
private static IConfiguration? _sharedConfiguration;
|
||||
private static SiteSettings? _sharedSiteSettings;
|
||||
|
|
@ -64,6 +67,7 @@ namespace Yavsc.Org.Tests
|
|||
public SiteSettings? SiteSettings { get => siteSettings; set => siteSettings = value; }
|
||||
public string? TestClientSecret { get; set; }
|
||||
public string? TestingUserEmail { get; set; }
|
||||
public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; }
|
||||
public WebServerFixture()
|
||||
{
|
||||
lock (_sync)
|
||||
|
|
@ -100,6 +104,7 @@ namespace Yavsc.Org.Tests
|
|||
_sharedTestingUserName = null;
|
||||
_sharedTestingUserPassword = null;
|
||||
_sharedTestingUserEmail = null;
|
||||
_sharedSmtpClientFactory = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +121,7 @@ namespace Yavsc.Org.Tests
|
|||
TestingUserName = _sharedTestingUserName;
|
||||
TestingUserPassword = _sharedTestingUserPassword;
|
||||
TestingUserEmail = _sharedTestingUserEmail;
|
||||
SmtpClientFactory = _sharedSmtpClientFactory;
|
||||
}
|
||||
|
||||
public async Task SetupHost()
|
||||
|
|
@ -156,8 +162,23 @@ namespace Yavsc.Org.Tests
|
|||
// IServiceCollection.AddSingleton.
|
||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
|
||||
|
||||
// Replace the production ISmtpClientFactory (added later
|
||||
// by ConfigureWebAppServices via TryAddSingleton) with a
|
||||
// recording fake. By pre-registering here, the prod
|
||||
// TryAdd becomes a no-op and tests get a single shared
|
||||
// fake they can assert against.
|
||||
var smtpFactory = new RecordingSmtpClientFactory();
|
||||
builder.Services.AddSingleton<ISmtpClientFactory>(smtpFactory);
|
||||
_sharedSmtpClientFactory = smtpFactory;
|
||||
|
||||
_app = builder.ConfigureWebAppServices();
|
||||
|
||||
// Note: a recording ISmtpClientFactory is registered
|
||||
// BEFORE ConfigureWebAppServices() above (further up in
|
||||
// this method) so the production TryAddSingleton inside
|
||||
// ConfigureWebAppServices becomes a no-op.
|
||||
// _sharedSmtpClientFactory is captured there.
|
||||
|
||||
// The MSBuild target CopyYavscOrgStaticAssets in
|
||||
// Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static
|
||||
// assets manifest into the test bin directory. Call
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
|
@ -102,6 +103,8 @@ public static class HostingExtensions
|
|||
services.AddTransient<ITrueEmailSender, MailSender>()
|
||||
.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, MailSender>();
|
||||
|
||||
services.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
|
||||
|
||||
|
||||
services.AddTransient<IYavscMessageSender, YavscMessageSender>()
|
||||
.AddTransient<IBillingService, BillingService>()
|
||||
|
|
|
|||
25
src/Yavsc.Server/Interfaces/ISmtpClient.cs
Normal file
25
src/Yavsc.Server/Interfaces/ISmtpClient.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
|
||||
namespace Yavsc.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Yavsc's own SMTP client surface. Narrower than
|
||||
/// <c>MailKit.Net.Smtp.ISmtpClient</c>: only the calls that
|
||||
/// <c>MailSender</c> actually makes. Production wires
|
||||
/// <c>MailKitSmtpClient</c>; tests wire a recording fake.
|
||||
/// </summary>
|
||||
public interface ISmtpClient : IDisposable
|
||||
{
|
||||
int Timeout { get; set; }
|
||||
|
||||
void Connect(string host, int port, MailKit.Security.SecureSocketOptions options);
|
||||
|
||||
void Authenticate(string userName, string password);
|
||||
|
||||
Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default);
|
||||
|
||||
void Disconnect(bool quit);
|
||||
}
|
||||
}
|
||||
13
src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs
Normal file
13
src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Yavsc.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds fresh <see cref="ISmtpClient"/> instances on demand.
|
||||
/// Per-call construction is required because <c>MailSender</c>
|
||||
/// owns its client with a <c>using</c> block: a singleton or
|
||||
/// scoped client would race across concurrent sends.
|
||||
/// </summary>
|
||||
public interface ISmtpClientFactory
|
||||
{
|
||||
ISmtpClient CreateClient();
|
||||
}
|
||||
}
|
||||
53
src/Yavsc.Server/Services/MailKitSmtpClient.cs
Normal file
53
src/Yavsc.Server/Services/MailKitSmtpClient.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
using Yavsc.Interfaces;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Production adapter from <see cref="Yavsc.Interfaces.ISmtpClient"/>
|
||||
/// to MailKit's <c>SmtpClient</c>. Stateless; the underlying
|
||||
/// MailKit client is created per <c>Connect</c> cycle by the
|
||||
/// factory and disposed by <c>MailSender</c>'s <c>using</c>.
|
||||
/// </summary>
|
||||
public sealed class MailKitSmtpClient : ISmtpClient
|
||||
{
|
||||
private readonly MailKit.Net.Smtp.SmtpClient _client;
|
||||
|
||||
public MailKitSmtpClient()
|
||||
{
|
||||
_client = new MailKit.Net.Smtp.SmtpClient();
|
||||
}
|
||||
|
||||
public int Timeout
|
||||
{
|
||||
get => _client.Timeout;
|
||||
set => _client.Timeout = value;
|
||||
}
|
||||
|
||||
public void Connect(string host, int port, SecureSocketOptions options)
|
||||
{
|
||||
_client.Connect(host, port, options);
|
||||
}
|
||||
|
||||
public void Authenticate(string userName, string password)
|
||||
{
|
||||
_client.Authenticate(userName, password);
|
||||
}
|
||||
|
||||
public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _client.SendAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
public void Disconnect(bool quit)
|
||||
{
|
||||
_client.Disconnect(quit);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using System.Net;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Interfaces;
|
||||
using Yavsc.Settings;
|
||||
using Yavsc.Models;
|
||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||
|
|
@ -16,21 +16,26 @@ namespace Yavsc.Services
|
|||
{
|
||||
public class MailSender : IEmailSender<ApplicationUser>, IEmailSender, ITrueEmailSender
|
||||
{
|
||||
|
||||
private readonly IStringLocalizer<MailSender> localizer;
|
||||
readonly SiteSettings siteSettings;
|
||||
readonly SmtpSettings smtpSettings;
|
||||
private readonly ILogger logger;
|
||||
private readonly ISmtpClientFactory _smtpClientFactory;
|
||||
|
||||
public MailSender(
|
||||
IOptions<SiteSettings> sitesOptions,
|
||||
IOptions<SmtpSettings> smtpOptions,
|
||||
ILoggerFactory loggerFactory,
|
||||
IStringLocalizer<MailSender> localizer
|
||||
IStringLocalizer<MailSender> localizer,
|
||||
ISmtpClientFactory smtpClientFactory
|
||||
)
|
||||
{
|
||||
this.localizer = localizer;
|
||||
siteSettings = sitesOptions.Value;
|
||||
smtpSettings = smtpOptions.Value;
|
||||
logger = loggerFactory.CreateLogger<MailSender>();
|
||||
_smtpClientFactory = smtpClientFactory;
|
||||
}
|
||||
|
||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink)
|
||||
|
|
@ -69,7 +74,7 @@ namespace Yavsc.Services
|
|||
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
|
||||
siteSettings.Authority
|
||||
);
|
||||
using (SmtpClient sc = new())
|
||||
using ISmtpClient sc = _smtpClientFactory.CreateClient();
|
||||
{
|
||||
sc.Timeout = 30000;
|
||||
sc.Connect(
|
||||
|
|
|
|||
14
src/Yavsc.Server/Services/SmtpClientFactory.cs
Normal file
14
src/Yavsc.Server/Services/SmtpClientFactory.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Yavsc.Interfaces;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="ISmtpClientFactory"/>: hands out a
|
||||
/// fresh <see cref="MailKitSmtpClient"/> per call. Stateless;
|
||||
/// safe to register as a singleton.
|
||||
/// </summary>
|
||||
public sealed class SmtpClientFactory : ISmtpClientFactory
|
||||
{
|
||||
public ISmtpClient CreateClient() => new MailKitSmtpClient();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue