fix: test infrastructure with in-memory DB, SMTP mocking, and thread-safe billing configuration

- Add in-memory database support for test isolation in WebServerFixture
- Implement TestMailSender fake SMTP provider for email test support
- Add thread synchronization to billing service registration to prevent race conditions
- Make RegisterBilling<T> idempotent to safely handle reconfiguration
- Configure test environment via in-memory settings (UseTestEmailSender, UseInMemoryDatabase)
- Add regression tests for billing module idempotency and duplicate registration detection
- Fix tests: EMaillingTests.SendEMailSynchrone, BillingServiceTests (2 tests), HaveConfigurationRoot (3 tests)

All core test infrastructure tests now passing.
This commit is contained in:
Paul Schneider 2026-04-19 14:40:40 +01:00
commit 87d62791b8
10 changed files with 223 additions and 58 deletions

View file

@ -1,5 +1,4 @@
namespace Yavsc.Helpers
{
using System.Collections.Generic;
@ -16,6 +15,9 @@ namespace Yavsc.Helpers
public static class WorkflowHelpers
{
// Synchronization lock for billing service configuration
private static readonly object _billingLock = new object();
public static async Task<List<PerformerProfileViewModel>>
ListPerformersAsync(this ApplicationDbContext context,
IBillingService billing,
@ -41,54 +43,79 @@ namespace Yavsc.Helpers
public static void RegisterBilling<T>(string code, Func<ApplicationDbContext, long,
IDecidableQuery> getter) where T : IBillable
{
if (BillingService.Billing.ContainsKey(code)
|| BillingService.GlobalBillingMap.ContainsKey(code))
lock (_billingLock)
{
throw new InvalidOperationException("Billing setup");
string typeName = typeof(T).Name;
// Only add if not already present (idempotent operation)
if (!BillingService.Billing.ContainsKey(code))
{
BillingService.Billing.Add(code, getter);
}
else if (!BillingService.GlobalBillingMap.ContainsKey(typeName) ||
BillingService.GlobalBillingMap[typeName] != code)
{
throw new InvalidOperationException($"Billing setup: code '{code}' already registered");
}
if (!BillingService.GlobalBillingMap.ContainsKey(typeName))
{
BillingService.GlobalBillingMap.Add(typeName, code);
}
else if (BillingService.GlobalBillingMap[typeName] != code)
{
throw new InvalidOperationException($"Billing setup: type '{typeName}' already registered with different code");
}
}
BillingService.Billing.Add(code, getter);
BillingService.GlobalBillingMap.Add(typeof(T).Name, code);
}
public static void ConfigureBillingService()
{
foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies())
lock (_billingLock)
{
foreach (var c in a.GetTypes())
BillingService.Billing.Clear();
BillingService.GlobalBillingMap.Clear();
BillingService.UserSettings.Clear();
Config.ProfileTypes.Clear();
foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies())
{
if (c.IsClass && !c.IsAbstract &&
c.GetInterface("ISpecializationSettings") != null)
foreach (var c in a.GetTypes())
{
Config.ProfileTypes.Add(c);
if (c.IsClass && !c.IsAbstract &&
c.GetInterface("ISpecializationSettings") != null)
{
Config.ProfileTypes.Add(c);
}
}
}
}
foreach (var propertyInfo in typeof(ApplicationDbContext).GetProperties())
{
foreach (var attr in propertyInfo.CustomAttributes)
foreach (var propertyInfo in typeof(ApplicationDbContext).GetProperties())
{
// something like a DbSet?
if (typeof(Yavsc.Attributes.ActivitySettingsAttribute).IsAssignableFrom(attr.AttributeType))
foreach (var attr in propertyInfo.CustomAttributes)
{
BillingService.UserSettings.Add(propertyInfo);
// something like a DbSet?
if (typeof(Yavsc.Attributes.ActivitySettingsAttribute).IsAssignableFrom(attr.AttributeType))
{
BillingService.UserSettings.Add(propertyInfo);
}
}
}
RegisterBilling<HairCutQuery>(BillingCodes.Brush, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) =>
{
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id);
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
return query;
}));
RegisterBilling<HairMultiCutQuery>(BillingCodes.MBrush, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id)));
RegisterBilling<RdvQuery>(BillingCodes.Rdv, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id)));
}
RegisterBilling<HairCutQuery>(BillingCodes.Brush, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) =>
{
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id);
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
return query;
}));
RegisterBilling<HairMultiCutQuery>(BillingCodes.MBrush, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id)));
RegisterBilling<RdvQuery>(BillingCodes.Rdv, new Func<ApplicationDbContext, long, IDecidableQuery>
((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id)));
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Logging;
using Yavsc.Interface;
namespace Yavsc.Services
{
public class TestMailSender : ITrueEmailSender, IEmailSender
{
private readonly ILogger<TestMailSender> logger;
public TestMailSender(ILoggerFactory loggerFactory)
{
logger = loggerFactory.CreateLogger<TestMailSender>();
}
public Task SendEmailAsync(string email, string subject, string htmlMessage)
{
logger.LogInformation("[TestMailSender] SendEmailAsync to {Email} subject={Subject}", email, subject);
return Task.CompletedTask;
}
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
logger.LogInformation("[TestMailSender] SendEmailAsync to {Email} subject={Subject} name={Name}", email, subject, name);
return Task.FromResult($"test-message-{Guid.NewGuid()}");
}
}
}

View file

@ -3,6 +3,12 @@ namespace Yavsc.Settings
public class SmtpSettings
{
public string Server { get; set; }
public string Host
{
get => Server;
set => Server = value;
}
public int Port { get; set; }
public string SenderName { get; set; }