From 7642e6a8aeb228ddcb18232a65d2dcd2e90be896 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:55:51 -0300 Subject: [PATCH] fix(quota): make InMemoryQuotaService constructor public so it resolves under DI With QuotaOptions.Enabled=true and no Redis configured, AddHeroQuotas registers InMemoryQuotaService as the IQuotaService. Its constructor was internal, and the default DI container only considers public constructors, so QuotaEnforcementMiddleware could not resolve the service per request -- turning every authenticated request, the login included, into a 500 ("A suitable constructor for type '...InMemoryQuotaService' could not be located"). The host disables ValidateOnBuild, so the failure surfaced at request time instead of startup. RedisQuotaService already had a public constructor, which is why only the in-memory (dev/test) path was affected. Add Framework.Tests coverage that resolves IQuotaService from a scope with quota both enabled and disabled, guarding the exact seam that broke. --- .../Quota/InMemoryQuotaService.cs | 2 +- .../Quota/QuotaExtensionsTests.cs | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/Tests/Framework.Tests/Quota/QuotaExtensionsTests.cs diff --git a/src/BuildingBlocks/Quota/InMemoryQuotaService.cs b/src/BuildingBlocks/Quota/InMemoryQuotaService.cs index af93a51db1..fd934770fe 100644 --- a/src/BuildingBlocks/Quota/InMemoryQuotaService.cs +++ b/src/BuildingBlocks/Quota/InMemoryQuotaService.cs @@ -18,7 +18,7 @@ public sealed class InMemoryQuotaService : IQuotaService private readonly Dictionary _gauges; private readonly TimeProvider _timeProvider; - internal InMemoryQuotaService( + public InMemoryQuotaService( InMemoryQuotaStore store, QuotaOptions options, QuotaPlanResolver planResolver, diff --git a/src/Tests/Framework.Tests/Quota/QuotaExtensionsTests.cs b/src/Tests/Framework.Tests/Quota/QuotaExtensionsTests.cs new file mode 100644 index 0000000000..033d516063 --- /dev/null +++ b/src/Tests/Framework.Tests/Quota/QuotaExtensionsTests.cs @@ -0,0 +1,61 @@ +using FSH.Framework.Quota; +using FSH.Framework.Shared.Quota; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Framework.Tests.Quota; + +/// +/// Guards the "quota enabled" DI wiring. registers the quota +/// service by type and the enforcement middleware resolves it per request; the host disables +/// ValidateOnBuild, so a service the container cannot construct only fails at resolution time — which +/// is why a non-public constructor turned every authenticated +/// request (login included) into a 500. These tests therefore RESOLVE the service inside a scope +/// (mirroring the middleware), not merely register it. +/// +public sealed class QuotaExtensionsTests +{ + private static ServiceProvider BuildProvider(bool enabled) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["QuotaOptions:Enabled"] = enabled ? "true" : "false", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddHeroQuotas(configuration); + return services.BuildServiceProvider(); + } + + [Fact] + public void AddHeroQuotas_Should_ResolveInMemoryService_When_EnabledWithoutRedis() + { + // Arrange + using var provider = BuildProvider(enabled: true); + using var scope = provider.CreateScope(); + + // Act — this is the exact resolution the enforcement middleware performs per request. + var quotaService = scope.ServiceProvider.GetRequiredService(); + + // Assert + quotaService.ShouldBeOfType(); + } + + [Fact] + public void AddHeroQuotas_Should_ResolveNoopService_When_Disabled() + { + // Arrange + using var provider = BuildProvider(enabled: false); + using var scope = provider.CreateScope(); + + // Act + var quotaService = scope.ServiceProvider.GetRequiredService(); + + // Assert + quotaService.ShouldBeOfType(); + } +}