diff --git a/Compendium.sln b/Compendium.sln index cdce03f..ffc591c 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -179,6 +179,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Sto EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage.Tests", "tests\Unit\Compendium.Abstractions.Storage.Tests\Compendium.Abstractions.Storage.Tests.csproj", "{585E082A-E775-426B-BEA5-D41D3AF53DE7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching", "src\Abstractions\Compendium.Abstractions.Caching\Compendium.Abstractions.Caching.csproj", "{4A6496B8-5833-428F-92D7-3EB331F5CA32}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching.Tests", "tests\Unit\Compendium.Abstractions.Caching.Tests\Compendium.Abstractions.Caching.Tests.csproj", "{262B8E91-B53C-49BC-833D-739DD4CFC2E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -460,6 +464,14 @@ Global {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.Build.0 = Debug|Any CPU {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.Build.0 = Release|Any CPU + {4A6496B8-5833-428F-92D7-3EB331F5CA32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A6496B8-5833-428F-92D7-3EB331F5CA32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A6496B8-5833-428F-92D7-3EB331F5CA32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A6496B8-5833-428F-92D7-3EB331F5CA32}.Release|Any CPU.Build.0 = Release|Any CPU + {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6} @@ -543,5 +555,7 @@ Global {D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B} = {A2517BFF-352C-4123-81B2-2D848F3FB497} {B4108100-52A7-4680-A0C4-7EB6955DB5F6} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} {585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} + {4A6496B8-5833-428F-92D7-3EB331F5CA32} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} + {262B8E91-B53C-49BC-833D-739DD4CFC2E1} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props index 11c00ee..9788d79 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,8 @@ + + diff --git a/src/Abstractions/Compendium.Abstractions.Caching/CachingErrors.cs b/src/Abstractions/Compendium.Abstractions.Caching/CachingErrors.cs new file mode 100644 index 0000000..40de478 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Caching/CachingErrors.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Caching; + +/// +/// Provides standardized error definitions for caching operations. +/// +public static class CachingErrors +{ + /// + /// Gets the error code prefix for caching errors. + /// + public const string Prefix = "Caching"; + + /// + /// The supplied TTL was zero or negative. + /// + public static Error InvalidTtl(TimeSpan ttl) => + Error.Validation( + $"{Prefix}.InvalidTtl", + $"TTL must be positive; received {ttl}."); + + /// + /// The cache backend failed to satisfy the request and the failure is non-retriable + /// from the caller's perspective. + /// + public static Error BackendFailure(string message) => + Error.Failure($"{Prefix}.BackendFailure", message); +} diff --git a/src/Abstractions/Compendium.Abstractions.Caching/Compendium.Abstractions.Caching.csproj b/src/Abstractions/Compendium.Abstractions.Caching/Compendium.Abstractions.Caching.csproj new file mode 100644 index 0000000..abbf91b --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Caching/Compendium.Abstractions.Caching.csproj @@ -0,0 +1,22 @@ + + + + Compendium.Abstractions.Caching + Compendium.Abstractions.Caching + Compendium.Abstractions.Caching + Caching abstractions for Compendium Framework: ICache port for provider-agnostic key/value caching (Get / Set / Remove / Exists) with TTL. + + + + + + + + + + + + + + + diff --git a/src/Abstractions/Compendium.Abstractions.Caching/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Caching/GlobalUsings.cs new file mode 100644 index 0000000..d2393ca --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Caching/GlobalUsings.cs @@ -0,0 +1,8 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; diff --git a/src/Abstractions/Compendium.Abstractions.Caching/ICache.cs b/src/Abstractions/Compendium.Abstractions.Caching/ICache.cs new file mode 100644 index 0000000..e40444a --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Caching/ICache.cs @@ -0,0 +1,69 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Caching; + +/// +/// Provides provider-agnostic key/value caching operations with TTL. +/// Implementations may target backends such as in-memory dictionaries, Redis, Memcached, +/// or distributed caches behind IDistributedCache. +/// +/// +/// +/// Implementations should never throw for ordinary cache misses or transient backend failures; +/// they MUST surface failures via instead. Argument-validation errors +/// (null / whitespace key) are permitted to throw. +/// +/// +/// Tenant isolation, key namespacing, and serialization are implementation concerns and +/// MUST be transparent to callers. +/// +/// +public interface ICache +{ + /// + /// Retrieves the cached value at , or null when no + /// live entry exists. A missing key is a successful result with a null value, + /// not a failure. + /// + /// The expected value type. + /// The cache key. Must be non-null and non-whitespace. + /// The cancellation token. + /// A result containing the cached value (or null), or an error. + Task> GetAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Stores at , replacing any existing entry. + /// + /// The value type. + /// The cache key. Must be non-null and non-whitespace. + /// The value to store. + /// Optional absolute time-to-live. When null, the entry has no expiry. + /// The cancellation token. + /// A result indicating success or an error. + Task SetAsync( + string key, + T value, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default); + + /// + /// Removes the entry at . Removing a missing key is a no-op success. + /// + /// The cache key. Must be non-null and non-whitespace. + /// The cancellation token. + /// A result indicating success or an error. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Determines whether a live entry exists at . + /// + /// The cache key. Must be non-null and non-whitespace. + /// The cancellation token. + /// A result containing true when an entry exists, otherwise false; or an error. + Task> ExistsAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/Compendium.Infrastructure/Caching/InMemoryCache.cs b/src/Infrastructure/Compendium.Infrastructure/Caching/InMemoryCache.cs new file mode 100644 index 0000000..13a834f --- /dev/null +++ b/src/Infrastructure/Compendium.Infrastructure/Caching/InMemoryCache.cs @@ -0,0 +1,114 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Caching; +using Compendium.Multitenancy; +using Microsoft.Extensions.Caching.Memory; + +namespace Compendium.Infrastructure.Caching; + +/// +/// In-memory implementation of backed by +/// . Suitable for single-process scenarios, tests, +/// and framework E2E samples. For multi-instance deployments, use a distributed +/// adapter (e.g. Compendium.Adapters.Redis). +/// +/// +/// Tenant isolation. When an with a non-empty +/// is resolved from DI, every key is prefixed +/// with {tenantId}: before being stored. With no tenant context (null or empty +/// TenantId), keys are written verbatim. The prefix is applied transparently +/// — callers always see the original key. +/// Thread safety. is thread-safe, so this +/// adapter is also thread-safe. +/// Result contract. Cache-miss is a successful +/// with a null value, never a failure. Argument validation (null/whitespace +/// key) is permitted to throw . +/// +public sealed class InMemoryCache : ICache +{ + private readonly IMemoryCache _memoryCache; + private readonly ITenantContext? _tenantContext; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying . + /// Optional tenant context used to scope keys per tenant. + public InMemoryCache(IMemoryCache memoryCache, ITenantContext? tenantContext = null) + { + _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + _tenantContext = tenantContext; + } + + /// + public Task> GetAsync(string key, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var scopedKey = ScopeKey(key); + + if (_memoryCache.TryGetValue(scopedKey, out var raw) && raw is T typed) + { + return Task.FromResult(Result.Success(typed)); + } + + return Task.FromResult(Result.Success(default)); + } + + /// + public Task SetAsync( + string key, + T value, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (ttl.HasValue && ttl.Value <= TimeSpan.Zero) + { + return Task.FromResult(Result.Failure(CachingErrors.InvalidTtl(ttl.Value))); + } + + var options = new MemoryCacheEntryOptions(); + if (ttl.HasValue) + { + options.AbsoluteExpirationRelativeToNow = ttl.Value; + } + + _memoryCache.Set(ScopeKey(key), value, options); + return Task.FromResult(Result.Success()); + } + + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + _memoryCache.Remove(ScopeKey(key)); + return Task.FromResult(Result.Success()); + } + + /// + public Task> ExistsAsync(string key, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var exists = _memoryCache.TryGetValue(ScopeKey(key), out _); + return Task.FromResult(Result.Success(exists)); + } + + /// + /// Applies the active tenant's prefix to , or returns it unchanged + /// when no tenant context is bound. + /// + private string ScopeKey(string key) + { + var tenantId = _tenantContext?.TenantId; + return string.IsNullOrEmpty(tenantId) ? key : $"{tenantId}:{key}"; + } +} diff --git a/src/Infrastructure/Compendium.Infrastructure/Caching/ServiceCollectionExtensions.cs b/src/Infrastructure/Compendium.Infrastructure/Caching/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b95ece2 --- /dev/null +++ b/src/Infrastructure/Compendium.Infrastructure/Caching/ServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Caching; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Compendium.Infrastructure.Caching; + +/// +/// DI extension methods for registering the in-memory adapter. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers as the implementation, + /// backed by . The underlying is + /// also registered (via ) + /// when no other registration exists. + /// + /// The service collection. + /// Optional configuration for the underlying . + /// The service collection for chaining. + public static IServiceCollection AddInMemoryCache( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configure is null) + { + services.AddMemoryCache(); + } + else + { + services.AddMemoryCache(configure); + } + + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Infrastructure/Compendium.Infrastructure/Compendium.Infrastructure.csproj b/src/Infrastructure/Compendium.Infrastructure/Compendium.Infrastructure.csproj index f3b404b..3983072 100644 --- a/src/Infrastructure/Compendium.Infrastructure/Compendium.Infrastructure.csproj +++ b/src/Infrastructure/Compendium.Infrastructure/Compendium.Infrastructure.csproj @@ -9,6 +9,7 @@ + @@ -24,6 +25,8 @@ + + diff --git a/tests/Unit/Compendium.Abstractions.Caching.Tests/CachingErrorsTests.cs b/tests/Unit/Compendium.Abstractions.Caching.Tests/CachingErrorsTests.cs new file mode 100644 index 0000000..b67e0eb --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Caching.Tests/CachingErrorsTests.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Caching.Tests; + +public class CachingErrorsTests +{ + [Fact] + public void Prefix_IsCaching() + { + CachingErrors.Prefix.Should().Be("Caching"); + } + + [Fact] + public void InvalidTtl_ReturnsValidationErrorIncludingTtl() + { + // Arrange + var ttl = TimeSpan.FromSeconds(-5); + + // Act + var error = CachingErrors.InvalidTtl(ttl); + + // Assert + error.Code.Should().Be("Caching.InvalidTtl"); + error.Type.Should().Be(ErrorType.Validation); + error.Message.Should().Contain(ttl.ToString()); + } + + [Fact] + public void BackendFailure_ReturnsFailureWithMessage() + { + // Act + var error = CachingErrors.BackendFailure("Redis unreachable"); + + // Assert + error.Code.Should().Be("Caching.BackendFailure"); + error.Type.Should().Be(ErrorType.Failure); + error.Message.Should().Be("Redis unreachable"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Caching.Tests/Compendium.Abstractions.Caching.Tests.csproj b/tests/Unit/Compendium.Abstractions.Caching.Tests/Compendium.Abstractions.Caching.Tests.csproj new file mode 100644 index 0000000..74765e6 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Caching.Tests/Compendium.Abstractions.Caching.Tests.csproj @@ -0,0 +1,33 @@ + + + + Compendium.Abstractions.Caching.Tests + Compendium.Abstractions.Caching.Tests + false + true + enable + enable + false + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Abstractions.Caching.Tests/GlobalUsings.cs b/tests/Unit/Compendium.Abstractions.Caching.Tests/GlobalUsings.cs new file mode 100644 index 0000000..beac420 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Caching.Tests/GlobalUsings.cs @@ -0,0 +1,12 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Xunit; +global using FluentAssertions; +global using NSubstitute; +global using Compendium.Abstractions.Caching; +global using Compendium.Core.Results; diff --git a/tests/Unit/Compendium.Infrastructure.Tests/Caching/InMemoryCacheTests.cs b/tests/Unit/Compendium.Infrastructure.Tests/Caching/InMemoryCacheTests.cs new file mode 100644 index 0000000..0072780 --- /dev/null +++ b/tests/Unit/Compendium.Infrastructure.Tests/Caching/InMemoryCacheTests.cs @@ -0,0 +1,344 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Caching; +using Compendium.Infrastructure.Caching; +using Compendium.Multitenancy; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Compendium.Infrastructure.Tests.Caching; + +public sealed class InMemoryCacheTests +{ + private static InMemoryCache CreateSut(out IMemoryCache memoryCache, ITenantContext? tenant = null) + { + memoryCache = new MemoryCache(new MemoryCacheOptions()); + return new InMemoryCache(memoryCache, tenant); + } + + [Fact] + public void Ctor_NullMemoryCache_Throws() + { + Action act = () => _ = new InMemoryCache(null!); + act.Should().Throw(); + } + + [Fact] + public async Task GetAsync_MissingKey_ReturnsSuccessWithNull() + { + var sut = CreateSut(out _); + + var result = await sut.GetAsync("missing"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task SetAndGet_String_RoundTrip() + { + var sut = CreateSut(out _); + + await sut.SetAsync("k", "hello"); + var result = await sut.GetAsync("k"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("hello"); + } + + [Fact] + public async Task SetAndGet_Int_RoundTrip() + { + var sut = CreateSut(out _); + + await sut.SetAsync("n", 42); + var result = await sut.GetAsync("n"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + public sealed record Person(string Name, int Age); + + [Fact] + public async Task SetAndGet_ComplexRecord_RoundTrip() + { + var sut = CreateSut(out _); + var person = new Person("Ada", 36); + + await sut.SetAsync("person", person); + var result = await sut.GetAsync("person"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(person); + } + + [Fact] + public async Task GetAsync_WhenStoredTypeDiffers_ReturnsNull() + { + var sut = CreateSut(out _); + + await sut.SetAsync("k", 42); + + // Stored an int; requesting a string → contract returns null (not a failure). + var result = await sut.GetAsync("k"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_ConfiguresAbsoluteExpirationRelativeToNow_OnEntryOptions() + { + // Arrange — substitute IMemoryCache so we can inspect the configured options + // without relying on wall-clock waits. + var memoryCache = Substitute.For(); + var entry = Substitute.For(); + entry.AbsoluteExpirationRelativeToNow.Returns((TimeSpan?)null); + memoryCache.CreateEntry(Arg.Any()).Returns(entry); + + var sut = new InMemoryCache(memoryCache); + + // Act + var ttl = TimeSpan.FromMinutes(5); + var result = await sut.SetAsync("k", "v", ttl); + + // Assert + result.IsSuccess.Should().BeTrue(); + entry.Received().AbsoluteExpirationRelativeToNow = ttl; + } + + [Fact] + public async Task SetAsync_NoTtl_LeavesAbsoluteExpirationNull() + { + // The Set overload always copies options onto the ICacheEntry — what we care + // about is that AbsoluteExpirationRelativeToNow stays null (no expiration). + TimeSpan? captured = TimeSpan.FromDays(999); // sentinel + var memoryCache = Substitute.For(); + var entry = Substitute.For(); + entry.When(e => e.AbsoluteExpirationRelativeToNow = Arg.Any()) + .Do(call => captured = call.Arg()); + memoryCache.CreateEntry(Arg.Any()).Returns(entry); + + var sut = new InMemoryCache(memoryCache); + + var result = await sut.SetAsync("k", "v"); + + result.IsSuccess.Should().BeTrue(); + captured.Should().BeNull("entries with no TTL must not have an absolute expiration"); + } + + [Fact] + public async Task SetAsync_LiveEntry_PersistsValueWithoutTtl() + { + // End-to-end check using a real MemoryCache: a value stored without TTL + // can be retrieved immediately and on subsequent reads. + var sut = CreateSut(out _); + + await sut.SetAsync("k", "v"); + + (await sut.GetAsync("k")).Value.Should().Be("v"); + (await sut.ExistsAsync("k")).Value.Should().BeTrue(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task SetAsync_NonPositiveTtl_ReturnsInvalidTtlFailure(int seconds) + { + var sut = CreateSut(out _); + + var result = await sut.SetAsync("k", "v", TimeSpan.FromSeconds(seconds)); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Caching.InvalidTtl"); + } + + [Fact] + public async Task SetAsync_ShortTtl_ExpiresAfterWait() + { + var sut = CreateSut(out _); + + await sut.SetAsync("k", "v", TimeSpan.FromMilliseconds(50)); + await Task.Delay(200); + + var result = await sut.GetAsync("k"); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_AfterSet_GetReturnsNull() + { + var sut = CreateSut(out _); + + await sut.SetAsync("k", "v"); + var remove = await sut.RemoveAsync("k"); + var get = await sut.GetAsync("k"); + + remove.IsSuccess.Should().BeTrue(); + get.Value.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_MissingKey_IsSuccess() + { + var sut = CreateSut(out _); + + var result = await sut.RemoveAsync("never-set"); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_MissingKey_ReturnsFalse() + { + var sut = CreateSut(out _); + + var result = await sut.ExistsAsync("missing"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeFalse(); + } + + [Fact] + public async Task ExistsAsync_AfterSet_ReturnsTrue() + { + var sut = CreateSut(out _); + + await sut.SetAsync("k", "v"); + var result = await sut.ExistsAsync("k"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task NullOrWhitespaceKey_AcrossAllOps_Throws(string? key) + { + var sut = CreateSut(out _); + + var get = () => sut.GetAsync(key!); + var set = () => sut.SetAsync(key!, "v"); + var remove = () => sut.RemoveAsync(key!); + var exists = () => sut.ExistsAsync(key!); + + await get.Should().ThrowAsync(); + await set.Should().ThrowAsync(); + await remove.Should().ThrowAsync(); + await exists.Should().ThrowAsync(); + } + + [Fact] + public async Task TenantPrefix_KeysAreIsolatedBetweenTenants() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var tenantA = new TenantContext(); + tenantA.SetTenant(new TenantInfo { Id = "tenant-a" }); + var tenantB = new TenantContext(); + tenantB.SetTenant(new TenantInfo { Id = "tenant-b" }); + + var cacheA = new InMemoryCache(memoryCache, tenantA); + var cacheB = new InMemoryCache(memoryCache, tenantB); + + await cacheA.SetAsync("x", "from-A"); + + (await cacheA.GetAsync("x")).Value.Should().Be("from-A"); + (await cacheB.GetAsync("x")).Value.Should().BeNull(); + (await cacheB.ExistsAsync("x")).Value.Should().BeFalse(); + } + + [Fact] + public async Task NoTenantContext_WritesAndReadsWithoutPrefix() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var sut = new InMemoryCache(memoryCache); + + await sut.SetAsync("x", "raw"); + + // Sanity check: the underlying entry is stored at the unprefixed key. + memoryCache.TryGetValue("x", out var raw).Should().BeTrue(); + raw.Should().Be("raw"); + + (await sut.GetAsync("x")).Value.Should().Be("raw"); + } + + [Fact] + public async Task EmptyTenantId_IsTreatedAsNoTenant() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var emptyTenant = new TenantContext(); + emptyTenant.SetTenant(new TenantInfo { Id = string.Empty }); + + var sut = new InMemoryCache(memoryCache, emptyTenant); + + await sut.SetAsync("x", "raw"); + + memoryCache.TryGetValue("x", out var raw).Should().BeTrue(); + raw.Should().Be("raw"); + } + + [Fact] + public async Task TenantPrefix_AppearsInUnderlyingKey() + { + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var tenant = new TenantContext(); + tenant.SetTenant(new TenantInfo { Id = "acme" }); + + var sut = new InMemoryCache(memoryCache, tenant); + + await sut.SetAsync("x", "v"); + + memoryCache.TryGetValue("acme:x", out var raw).Should().BeTrue(); + raw.Should().Be("v"); + // The unprefixed key alone is not present. + memoryCache.TryGetValue("x", out _).Should().BeFalse(); + } + + [Fact] + public void AddInMemoryCache_RegistersICache_ResolvableFromServiceProvider() + { + var services = new ServiceCollection(); + services.AddInMemoryCache(); + + using var sp = services.BuildServiceProvider(); + + var cache = sp.GetService(); + cache.Should().NotBeNull(); + cache.Should().BeOfType(); + + var memory = sp.GetService(); + memory.Should().NotBeNull(); + } + + [Fact] + public void AddInMemoryCache_WithConfigure_AppliesMemoryCacheOptions() + { + var services = new ServiceCollection(); + services.AddInMemoryCache(opts => opts.SizeLimit = 123); + + using var sp = services.BuildServiceProvider(); + + var options = sp.GetRequiredService>(); + options.Value.SizeLimit.Should().Be(123); + + sp.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddInMemoryCache_NullServices_Throws() + { + IServiceCollection? services = null; + Action act = () => services!.AddInMemoryCache(); + act.Should().Throw(); + } +}