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:
Paul Schneider 2026-06-22 01:44:24 +01:00
commit 2dec799d71
13 changed files with 264 additions and 10 deletions

View file

@ -19,6 +19,6 @@ internal static class PlatformBootstrap
return;
Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri;
Platform.CreateBrowser = () => new LoopbackBrowser();
}
}

View file

@ -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>();

View file

@ -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>()

View 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);
}

View 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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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

View file

@ -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>()

View 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);
}
}

View 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();
}
}

View 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();
}
}
}

View file

@ -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(

View 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();
}
}