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

@ -1,10 +1,10 @@
/*
Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/
Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/
Copyright (c) 2018, Brock Allen & Dominick Baier. All rights reserved.
Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
Source code and license this software can be found
Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
Source code and license this software can be found
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
@ -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;
@ -79,11 +81,13 @@ internal class Program
{
options.ResourcesPath = "Resources";
});
//
//
services.AddTransient<Microsoft.AspNetCore.Identity.IEmailSender<ApplicationUser>, MailSender>();
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>()
@ -89,7 +94,7 @@ internal class Program
new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value }))
);
app.UseSession();
app.UseSession();
await app.RunAsync();
}
}

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