diff --git a/EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs b/EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs index 8b43c5b8..f85be0c2 100644 --- a/EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs @@ -1,24 +1,24 @@ using EssentialCSharp.Web.Services; -using Microsoft.Extensions.Caching.Memory; namespace EssentialCSharp.Web.Tests; -public class ResponseIdValidationServiceTests +public class ResponseIdValidationServiceTests : IDisposable { // Match production SizeLimit so SetSize(1) is exercised in tests, not silently ignored. - private static MemoryCache CreateCache() => new(new MemoryCacheOptions { SizeLimit = 10_000 }); + private readonly ResponseIdValidationService _service = new(); - private static ResponseIdValidationService CreateService(MemoryCache cache) => new(cache); + public void Dispose() + { + _service.Dispose(); + GC.SuppressFinalize(this); + } [Test] [Arguments(null)] [Arguments("")] public async Task ValidateResponseId_BlankResponseId_AllowsNewConversation(string? responseId) { - using var cache = CreateCache(); - var service = CreateService(cache); - - bool result = service.ValidateResponseId("user1", responseId); + bool result = _service.ValidateResponseId("user1", responseId); await Assert.That(result).IsTrue(); } @@ -28,10 +28,7 @@ public async Task ValidateResponseId_BlankResponseId_AllowsNewConversation(strin [Arguments("")] public async Task ValidateResponseId_BlankUserId_Rejects(string? userId) { - using var cache = CreateCache(); - var service = CreateService(cache); - - bool result = service.ValidateResponseId(userId, "resp_123"); + bool result = _service.ValidateResponseId(userId, "resp_123"); await Assert.That(result).IsFalse(); } @@ -39,11 +36,8 @@ public async Task ValidateResponseId_BlankUserId_Rejects(string? userId) [Test] public async Task ValidateResponseId_CacheMiss_AllowsGracefulDegradation() { - using var cache = CreateCache(); - var service = CreateService(cache); // No RecordResponseId call — simulate server restart / different instance - - bool result = service.ValidateResponseId("user1", "resp_unknown"); + bool result = _service.ValidateResponseId("user1", "resp_unknown"); await Assert.That(result).IsTrue(); } @@ -51,11 +45,9 @@ public async Task ValidateResponseId_CacheMiss_AllowsGracefulDegradation() [Test] public async Task ValidateResponseId_RecordedByOwner_Validates() { - using var cache = CreateCache(); - var service = CreateService(cache); - service.RecordResponseId("user1", "resp_abc"); + _service.RecordResponseId("user1", "resp_abc"); - bool result = service.ValidateResponseId("user1", "resp_abc"); + bool result = _service.ValidateResponseId("user1", "resp_abc"); await Assert.That(result).IsTrue(); } @@ -63,11 +55,9 @@ public async Task ValidateResponseId_RecordedByOwner_Validates() [Test] public async Task ValidateResponseId_RecordedByDifferentUser_Rejects() { - using var cache = CreateCache(); - var service = CreateService(cache); - service.RecordResponseId("user1", "resp_abc"); + _service.RecordResponseId("user1", "resp_abc"); - bool result = service.ValidateResponseId("user2", "resp_abc"); + bool result = _service.ValidateResponseId("user2", "resp_abc"); await Assert.That(result).IsFalse(); } @@ -75,56 +65,46 @@ public async Task ValidateResponseId_RecordedByDifferentUser_Rejects() [Test] public async Task RecordResponseId_NullInputs_DoesNotThrow() { - using var cache = CreateCache(); - var service = CreateService(cache); - - service.RecordResponseId(null, "resp_abc"); - service.RecordResponseId("user1", null); - service.RecordResponseId(null, null); + _service.RecordResponseId(null, "resp_abc"); + _service.RecordResponseId("user1", null); + _service.RecordResponseId(null, null); // Verify the service is still functional after no-op calls - service.RecordResponseId("user1", "resp_abc"); - await Assert.That(service.ValidateResponseId("user1", "resp_abc")).IsTrue(); + _service.RecordResponseId("user1", "resp_abc"); + await Assert.That(_service.ValidateResponseId("user1", "resp_abc")).IsTrue(); } [Test] public async Task ValidateResponseId_MultipleResponseIds_EachValidatedIndependently() { - using var cache = CreateCache(); - var service = CreateService(cache); - service.RecordResponseId("user1", "resp_001"); - service.RecordResponseId("user1", "resp_002"); + _service.RecordResponseId("user1", "resp_001"); + _service.RecordResponseId("user1", "resp_002"); - await Assert.That(service.ValidateResponseId("user1", "resp_001")).IsTrue(); - await Assert.That(service.ValidateResponseId("user1", "resp_002")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user1", "resp_001")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user1", "resp_002")).IsTrue(); // Unrecorded ID for same user → cache miss → allow - await Assert.That(service.ValidateResponseId("user1", "resp_003")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user1", "resp_003")).IsTrue(); } [Test] public async Task ValidateResponseId_TwoUsers_IsolatedFromEachOther() { - using var cache = CreateCache(); - var service = CreateService(cache); - service.RecordResponseId("user1", "resp_A"); - service.RecordResponseId("user2", "resp_B"); - - await Assert.That(service.ValidateResponseId("user1", "resp_A")).IsTrue(); - await Assert.That(service.ValidateResponseId("user2", "resp_B")).IsTrue(); - await Assert.That(service.ValidateResponseId("user2", "resp_A")).IsFalse(); - await Assert.That(service.ValidateResponseId("user1", "resp_B")).IsFalse(); + _service.RecordResponseId("user1", "resp_A"); + _service.RecordResponseId("user2", "resp_B"); + + await Assert.That(_service.ValidateResponseId("user1", "resp_A")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user2", "resp_B")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user2", "resp_A")).IsFalse(); + await Assert.That(_service.ValidateResponseId("user1", "resp_B")).IsFalse(); } [Test] public async Task RecordResponseId_SizeLimitEnforced_EntryCountedInCache() { - using var cache = CreateCache(); - var service = CreateService(cache); - // Record an entry — with SizeLimit set, SetSize(1) should count toward the cache size. - service.RecordResponseId("user1", "resp_size_test"); + _service.RecordResponseId("user1", "resp_size_test"); // Verify it was recorded (i.e., not silently evicted due to misconfiguration). - await Assert.That(service.ValidateResponseId("user1", "resp_size_test")).IsTrue(); + await Assert.That(_service.ValidateResponseId("user1", "resp_size_test")).IsTrue(); } } diff --git a/EssentialCSharp.Web/Services/ResponseIdValidationService.cs b/EssentialCSharp.Web/Services/ResponseIdValidationService.cs index 8b3ffefd..8fd787e9 100644 --- a/EssentialCSharp.Web/Services/ResponseIdValidationService.cs +++ b/EssentialCSharp.Web/Services/ResponseIdValidationService.cs @@ -31,6 +31,7 @@ public sealed class ResponseIdValidationService : IDisposable private readonly IMemoryCache _cache; private readonly bool _ownsCache; + private bool _disposed; /// /// Production constructor. Creates and owns a dedicated with a bounded @@ -54,8 +55,16 @@ private ResponseIdValidationService(IMemoryCache cache, bool ownsCache) /// public void Dispose() { + if (_disposed) + { + return; + } + _disposed = true; + if (_ownsCache && _cache is IDisposable disposable) + { disposable.Dispose(); + } } ///