diff --git a/Directory.Packages.props b/Directory.Packages.props index 076da407..ea5905fd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 4af832b0..f75cf7e7 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -99,9 +99,20 @@ public static class HostingExtensions options.ResourcesPath = "Resources"; }).AddDataAnnotationsLocalization(); - services.AddTransient() - .AddTransient() - .AddTransient() + bool useTestEmailSender = builder.Configuration.GetValue("UseTestEmailSender", false); + + if (useTestEmailSender) + { + services.AddTransient() + .AddTransient(); + } + else + { + services.AddTransient() + .AddTransient(); + } + + services.AddTransient() .AddTransient() .AddTransient((sp) => new FileDataStore("googledatastore", false)) .AddTransient() @@ -150,11 +161,21 @@ public static class HostingExtensions public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder) { IServiceCollection services = builder.Services; - services.AddDbContext(options => + bool useInMemory = builder.Configuration.GetValue("UseInMemoryDatabase", false); + + if (useInMemory) { - options.UseNpgsql(builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName), - options => options.MigrationsAssembly(typeof(Program).Assembly)); - }); + services.AddDbContext(options => + options.UseInMemoryDatabase("YavscInMemory")); + } + else + { + services.AddDbContext(options => + { + options.UseNpgsql(builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName), + options => options.MigrationsAssembly(typeof(Program).Assembly)); + }); + } return services.AddIdentity( options => @@ -269,6 +290,8 @@ public static class HostingExtensions }); var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name; var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName); + bool useInMemory = builder.Configuration.GetValue("UseInMemoryDatabase", false); + string inMemoryDatabaseName = "YavscInMemory"; var identityServerBuilder = builder.Services.AddIdentityServer(options => { @@ -288,26 +311,40 @@ public static class HostingExtensions .AddResourceStore() .AddConfigurationStore(options => { - options.ConfigureDbContext = b => b.UseNpgsql(connectionString, - sql => sql.MigrationsAssembly(migrationsAssembly)) - .UseSeeding((context, _) => - { - foreach (String scope in new string[] { "blog", "admin", "contract", "com"}) - { - var testBlog = context.Set().FirstOrDefault(b => b.Name == scope); - if (testBlog == null) + if (useInMemory) { - context.Set().Add(new ApiScope { Name = scope }); - context.SaveChanges(); + options.ConfigureDbContext = b => b.UseInMemoryDatabase(inMemoryDatabaseName); } - } + else + { + options.ConfigureDbContext = b => b.UseNpgsql(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)) + .UseSeeding((context, _) => + { + foreach (String scope in new string[] { "blog", "admin", "contract", "com"}) + { + var testBlog = context.Set().FirstOrDefault(b => b.Name == scope); + if (testBlog == null) + { + context.Set().Add(new ApiScope { Name = scope }); + context.SaveChanges(); + } + } - }); + }); + } }) .AddOperationalStore(options => { - options.ConfigureDbContext = b => b.UseNpgsql(connectionString, - sql => sql.MigrationsAssembly(migrationsAssembly)); + if (useInMemory) + { + options.ConfigureDbContext = b => b.UseInMemoryDatabase(inMemoryDatabaseName); + } + else + { + options.ConfigureDbContext = b => b.UseNpgsql(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)); + } }); if (builder.Environment.IsDevelopment()) diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 43e927d6..b388fc76 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -31,6 +31,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs index a1180ed2..c0531ae6 100644 --- a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs +++ b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs @@ -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> ListPerformersAsync(this ApplicationDbContext context, IBillingService billing, @@ -41,54 +43,79 @@ namespace Yavsc.Helpers public static void RegisterBilling(string code, Func 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(BillingCodes.Brush, new Func + ((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(BillingCodes.MBrush, new Func + ((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); + + RegisterBilling(BillingCodes.Rdv, new Func + ((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); } - - RegisterBilling(BillingCodes.Brush, new Func - ((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(BillingCodes.MBrush, new Func - ((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); - - RegisterBilling(BillingCodes.Rdv, new Func - ((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); } } diff --git a/src/Yavsc.Server/Services/TestMailSender.cs b/src/Yavsc.Server/Services/TestMailSender.cs new file mode 100644 index 00000000..9e91dfef --- /dev/null +++ b/src/Yavsc.Server/Services/TestMailSender.cs @@ -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 logger; + + public TestMailSender(ILoggerFactory loggerFactory) + { + logger = loggerFactory.CreateLogger(); + } + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + logger.LogInformation("[TestMailSender] SendEmailAsync to {Email} subject={Subject}", email, subject); + return Task.CompletedTask; + } + + public Task 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()}"); + } + } +} diff --git a/src/Yavsc.Server/Settings/SmtpSettings.cs b/src/Yavsc.Server/Settings/SmtpSettings.cs index c744cae6..ca0cdd1d 100644 --- a/src/Yavsc.Server/Settings/SmtpSettings.cs +++ b/src/Yavsc.Server/Settings/SmtpSettings.cs @@ -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; } diff --git a/test/yavscTests/NonRegression/BillingServiceTests.cs b/test/yavscTests/NonRegression/BillingServiceTests.cs new file mode 100644 index 00000000..7b4f2705 --- /dev/null +++ b/test/yavscTests/NonRegression/BillingServiceTests.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Xunit; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Helpers; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Haircut; +using Yavsc.Services; + +namespace yavscTests +{ + [Trait("regression", "II")] + public class BillingServiceTests + { + [Fact] + public void ConfigureBillingService_CanBeCalledTwiceWithoutThrowing() + { + // First initialization should populate the billing registry. + WorkflowHelpers.ConfigureBillingService(); + + int firstBillingCount = BillingService.Billing.Count; + int firstSettingsCount = BillingService.UserSettings.Count; + int firstProfileTypesCount = Config.ProfileTypes.Count; + + // Second call should be idempotent and not throw. + WorkflowHelpers.ConfigureBillingService(); + + Assert.Equal(firstBillingCount, BillingService.Billing.Count); + Assert.Equal(firstSettingsCount, BillingService.UserSettings.Count); + Assert.Equal(firstProfileTypesCount, Config.ProfileTypes.Count); + } + + [Fact] + public void RegisterBilling_DuplicateRegistrationThrowsInvalidOperationException() + { + WorkflowHelpers.ConfigureBillingService(); + + var firstRegistrar = new Func((db, id) => + db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id)); + + const string testCode = "TestBrush"; + + Assert.Throws(() => + WorkflowHelpers.RegisterBilling(testCode, firstRegistrar)); + } + } +} diff --git a/test/yavscTests/WebServerFixture.cs b/test/yavscTests/WebServerFixture.cs index a822a342..758078d6 100644 --- a/test/yavscTests/WebServerFixture.cs +++ b/test/yavscTests/WebServerFixture.cs @@ -28,7 +28,7 @@ namespace isnd.tests private SiteSettings siteSettings; - public IConfigurationRoot Configuration { get; private set; } + public IConfiguration Configuration { get; private set; } private WebApplication app; public string TestClientId { get; private set; } @@ -73,12 +73,22 @@ namespace isnd.tests { var builder = WebApplication.CreateBuilder(); + builder.Environment.EnvironmentName = "Development"; ConfigureLogger(); - Configuration = builder.Configuration - .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() - .Build(); + .AddInMemoryCollection(new Dictionary + { + ["UseInMemoryDatabase"] = "true", + ["UseTestEmailSender"] = "true", + ["Smtp:Host"] = "localhost", + ["Smtp:Port"] = "25", + ["Smtp:SenderName"] = "Yavsc Test", + ["Smtp:SenderEmail"] = "test@example.com" + }); + Configuration = builder.Configuration; this.app = builder.ConfigureWebAppServices(); Services = app.Services; diff --git a/test/yavscTests/appsettings.json b/test/yavscTests/appsettings.json index e0b05b6e..becfb6ea 100644 --- a/test/yavscTests/appsettings.json +++ b/test/yavscTests/appsettings.json @@ -41,7 +41,7 @@ } }, "ConnectionStrings": { - "YavscConnection": "Server=lame-NpgsqlHostName;Port=5432;Database=lame-DataBase;Username=lame-Username;Password=lame-dbPassword;", + "YavscConnection": "Server=lame-NpgsqlHostName;Port=5432;Database=lame-DataBase;Username=lame-Username;Password=lame-dbPassword;" }, "DataProtection": { "Keys": { diff --git a/test/yavscTests/yavscTests.csproj b/test/yavscTests/yavscTests.csproj index 2b405e86..0845605c 100644 --- a/test/yavscTests/yavscTests.csproj +++ b/test/yavscTests/yavscTests.csproj @@ -24,6 +24,11 @@ + + + Always + +