From 5277b667e17d25892d29cdb2278b69b5fa4bfc40 Mon Sep 17 00:00:00 2001 From: Boromir Date: Thu, 7 May 2026 17:01:44 -0700 Subject: [PATCH 1/2] test: raise Web project line coverage above 80% (issue #244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BlogPostCacheServiceTests covering all 4 methods of BlogPostCacheService: GetOrFetchAllAsync (L1 hit, L2 hit, corrupt L2, full miss), GetOrFetchByIdAsync (L1 hit, L2 hit, corrupt L2, full miss, null fetch), InvalidateAllAsync, InvalidateByIdAsync - Add OperationCanceledException rethrow + unexpected Exception tests to CreateBlogPostHandler, DeleteBlogPostHandler, GetBlogPostsHandler, and EditBlogPostHandler (both Handle methods) - Line coverage: 69.5% → 81.5% (527/646 lines) Closes #244 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Handlers/CreateBlogPostHandlerTests.cs | 31 ++ .../Handlers/DeleteBlogPostHandlerTests.cs | 33 ++ .../Handlers/EditBlogPostHandlerTests.cs | 70 +++++ .../Handlers/GetBlogPostsHandlerTests.cs | 33 ++ .../Caching/BlogPostCacheServiceTests.cs | 286 ++++++++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs diff --git a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs index 452713fc..501c9419 100644 --- a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs @@ -68,4 +68,35 @@ public async Task Handle_Success_DoesNotCallInvalidateById() await _cache.Received(1).InvalidateAllAsync(Arg.Any()); await _cache.DidNotReceive().InvalidateByIdAsync(Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Handle_OperationCanceled_Rethrows() + { + // Arrange + var command = new CreateBlogPostCommand("Title", "Content", "Author"); + _repo.AddAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Func act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() + { + // Arrange + var command = new CreateBlogPostCommand("Title", "Content", "Author"); + _repo.AddAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new TimeoutException("db timeout")); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be("An unexpected error occurred."); + } } diff --git a/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs index 475d6e97..479fbd80 100644 --- a/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs @@ -75,4 +75,37 @@ public async Task Handle_ConcurrentDelete_ReturnsConcurrencyErrorCode() result.Failure.Should().BeTrue(); result.ErrorCode.Should().Be(ResultErrorCode.Concurrency); } + + [Fact] + public async Task Handle_OperationCanceled_Rethrows() + { + // Arrange + var id = Guid.NewGuid(); + var command = new DeleteBlogPostCommand(id); + _repo.DeleteAsync(id, Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Func act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() + { + // Arrange + var id = Guid.NewGuid(); + var command = new DeleteBlogPostCommand(id); + _repo.DeleteAsync(id, Arg.Any()) + .ThrowsAsync(new TimeoutException("db timeout")); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be("An unexpected error occurred."); + } } diff --git a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs index 380e8941..60cb51ce 100644 --- a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -173,4 +173,74 @@ public async Task HandleGetById_CacheServiceThrows_ReturnsFailResult() result.Failure.Should().BeTrue(); result.Error.Should().Contain("redis down"); } + + [Fact] + public async Task HandleEdit_OperationCanceled_Rethrows() + { + // Arrange + var post = BlogPost.Create("Title", "Content", "Author"); + var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); + _repo.GetByIdAsync(post.Id, Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Func act = () => _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task HandleEdit_UnexpectedException_ReturnsUnexpectedErrorResult() + { + // Arrange + var post = BlogPost.Create("Title", "Content", "Author"); + var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); + _repo.GetByIdAsync(post.Id, Arg.Any()) + .ThrowsAsync(new TimeoutException("db timeout")); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be("An unexpected error occurred."); + } + + [Fact] + public async Task HandleGetById_OperationCanceled_Rethrows() + { + // Arrange + var id = Guid.NewGuid(); + _cache.GetOrFetchByIdAsync( + id, + Arg.Any>>(), + Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Func act = () => _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task HandleGetById_UnexpectedException_ReturnsUnexpectedErrorResult() + { + // Arrange + var id = Guid.NewGuid(); + _cache.GetOrFetchByIdAsync( + id, + Arg.Any>>(), + Arg.Any()) + .ThrowsAsync(new TimeoutException("db timeout")); + + // Act + var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be("An unexpected error occurred."); + } } diff --git a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs index 80d54767..d4b7fcfa 100644 --- a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs +++ b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs @@ -127,4 +127,37 @@ public async Task Handle_CacheServiceThrows_ReturnsFailResult() result.Failure.Should().BeTrue(); result.Error.Should().Contain("redis down"); } + + [Fact] + public async Task Handle_OperationCanceled_Rethrows() + { + // Arrange + _cache.GetOrFetchAllAsync( + Arg.Any>>>(), + Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Func act = () => _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() + { + // Arrange + _cache.GetOrFetchAllAsync( + Arg.Any>>>(), + Arg.Any()) + .ThrowsAsync(new TimeoutException("db timeout")); + + // Act + var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be("An unexpected error occurred."); + } } diff --git a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs new file mode 100644 index 00000000..ef07f563 --- /dev/null +++ b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs @@ -0,0 +1,286 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : BlogPostCacheServiceTests.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Web.Tests +//======================================================= + +using System.Text.Json; + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Web.Infrastructure.Caching; + +public class BlogPostCacheServiceTests : IDisposable +{ +private readonly MemoryCache _realLocalCache = new(new MemoryCacheOptions()); +private readonly IDistributedCache _distributedCache = Substitute.For(); +private readonly BlogPostCacheService _sut; + +public BlogPostCacheServiceTests() +{ +_sut = new BlogPostCacheService(_realLocalCache, _distributedCache); +} + +public void Dispose() => _realLocalCache.Dispose(); + +private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + +private static List MakeDtos() => +[ +new(Guid.NewGuid(), "Title1", "Content1", "Author1", DateTime.UtcNow, null, false), +new(Guid.NewGuid(), "Title2", "Content2", "Author2", DateTime.UtcNow, null, true), +]; + +// ── GetOrFetchAllAsync ──────────────────────────────────────────────────── + +[Fact] +public async Task GetOrFetchAllAsync_L1Hit_ReturnsCachedListWithoutDistributedCall() +{ +// Arrange +var cachedList = MakeDtos(); +_realLocalCache.Set(BlogPostCacheKeys.All, cachedList); + +var fetchCalled = false; +Task> fetch() { fetchCalled = true; return Task.FromResult>([]); } + +// Act +var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); + +// Assert +result.Should().HaveCount(2); +fetchCalled.Should().BeFalse(); +await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); +} + +[Fact] +public async Task GetOrFetchAllAsync_L2Hit_DeserializesAndPopulatesL1() +{ +// Arrange — no L1 entry, L2 has valid bytes +var dtos = MakeDtos(); +var bytes = JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts); +_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) +.Returns(Task.FromResult(bytes)); + +// Act +var result = await _sut.GetOrFetchAllAsync( +() => Task.FromResult>([]), CancellationToken.None); + +// Assert +result.Should().HaveCount(2); +_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); +l1Val.Should().HaveCount(2); +} + +[Fact] +public async Task GetOrFetchAllAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() +{ +// Arrange +var corruptBytes = "{ not valid json !!!"u8.ToArray(); +_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) +.Returns(Task.FromResult(corruptBytes)); +_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); +_distributedCache.SetAsync( +Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +var expected = MakeDtos(); +var fetchCalled = false; +Task> fetch() +{ +fetchCalled = true; +return Task.FromResult>(expected); +} + +// Act +var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); + +// Assert +fetchCalled.Should().BeTrue(); +result.Should().HaveCount(2); +await _distributedCache.Received().RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); +} + +[Fact] +public async Task GetOrFetchAllAsync_FullMiss_FetchesAndPopulatesBothTiers() +{ +// Arrange — nothing in L1 or L2 +_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) +.Returns(Task.FromResult(null)); +_distributedCache.SetAsync( +Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +var dtos = MakeDtos(); + +// Act +var result = await _sut.GetOrFetchAllAsync( +() => Task.FromResult>(dtos), CancellationToken.None); + +// Assert +result.Should().HaveCount(2); +_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); +l1Val.Should().HaveCount(2); +await _distributedCache.Received().SetAsync( +BlogPostCacheKeys.All, +Arg.Any(), +Arg.Any(), +Arg.Any()); +} + +// ── GetOrFetchByIdAsync ─────────────────────────────────────────────────── + +[Fact] +public async Task GetOrFetchByIdAsync_L1Hit_ReturnsCachedDtoWithoutDistributedCall() +{ +// Arrange +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); +_realLocalCache.Set(key, dto); + +// Act +var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + +// Assert +result.Should().NotBeNull(); +result!.Id.Should().Be(id); +await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); +} + +[Fact] +public async Task GetOrFetchByIdAsync_L2Hit_DeserializesAndPopulatesL1() +{ +// Arrange +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); +var bytes = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOpts); +_distributedCache.GetAsync(key, Arg.Any()) +.Returns(Task.FromResult(bytes)); + +// Act +var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + +// Assert +result.Should().NotBeNull(); +result!.Id.Should().Be(id); +_realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); +l1Val!.Id.Should().Be(id); +} + +[Fact] +public async Task GetOrFetchByIdAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() +{ +// Arrange +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +var corruptBytes = "{ not valid json !!!"u8.ToArray(); +_distributedCache.GetAsync(key, Arg.Any()) +.Returns(Task.FromResult(corruptBytes)); +_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); +_distributedCache.SetAsync( +Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); +var fetchCalled = false; +Task fetch() { fetchCalled = true; return Task.FromResult(dto); } + +// Act +var result = await _sut.GetOrFetchByIdAsync(id, fetch, CancellationToken.None); + +// Assert +fetchCalled.Should().BeTrue(); +result!.Id.Should().Be(id); +await _distributedCache.Received().RemoveAsync(key, CancellationToken.None); +} + +[Fact] +public async Task GetOrFetchByIdAsync_FullMiss_FetchesAndPopulatesBothTiers() +{ +// Arrange +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +_distributedCache.GetAsync(key, Arg.Any()) +.Returns(Task.FromResult(null)); +_distributedCache.SetAsync( +Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + +// Act +var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(dto), CancellationToken.None); + +// Assert +result!.Id.Should().Be(id); +_realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); +l1Val!.Id.Should().Be(id); +await _distributedCache.Received().SetAsync( +key, Arg.Any(), Arg.Any(), Arg.Any()); +} + +[Fact] +public async Task GetOrFetchByIdAsync_FetchReturnsNull_ReturnsNull() +{ +// Arrange +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +_distributedCache.GetAsync(key, Arg.Any()) +.Returns(Task.FromResult(null)); + +// Act +var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + +// Assert +result.Should().BeNull(); +await _distributedCache.DidNotReceive().SetAsync( +Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); +} + +// ── InvalidateAllAsync ──────────────────────────────────────────────────── + +[Fact] +public async Task InvalidateAllAsync_RemovesBothCacheTiers() +{ +// Arrange — populate L1 so we can verify removal +var dtos = MakeDtos(); +_realLocalCache.Set(BlogPostCacheKeys.All, dtos); +_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +// Act +await _sut.InvalidateAllAsync(CancellationToken.None); + +// Assert +_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? _).Should().BeFalse(); +await _distributedCache.Received(1).RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); +} + +// ── InvalidateByIdAsync ─────────────────────────────────────────────────── + +[Fact] +public async Task InvalidateByIdAsync_RemovesByKeyFromBothTiers() +{ +// Arrange — populate L1 so we can verify removal +var id = Guid.NewGuid(); +var key = BlogPostCacheKeys.ById(id); +var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); +_realLocalCache.Set(key, dto); +_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) +.Returns(Task.CompletedTask); + +// Act +await _sut.InvalidateByIdAsync(id, CancellationToken.None); + +// Assert +_realLocalCache.TryGetValue(key, out BlogPostDto? _).Should().BeFalse(); +await _distributedCache.Received(1).RemoveAsync(key, CancellationToken.None); +} +} From a7767df4c568d84f6e03d526475e6945701d7c75 Mon Sep 17 00:00:00 2001 From: Boromir Date: Thu, 7 May 2026 17:27:08 -0700 Subject: [PATCH 2/2] fix(tests): remove unused using and fix indentation in BlogPostCacheServiceTests - Remove unused 'using Microsoft.Extensions.Options;' - Re-indent entire file with tabs to match repo test-file style Closes #245 blocker items Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Caching/BlogPostCacheServiceTests.cs | 535 +++++++++--------- 1 file changed, 267 insertions(+), 268 deletions(-) diff --git a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs index ef07f563..1cd2bf2b 100644 --- a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs +++ b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs @@ -10,277 +10,276 @@ using System.Text.Json; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; namespace Web.Infrastructure.Caching; public class BlogPostCacheServiceTests : IDisposable { -private readonly MemoryCache _realLocalCache = new(new MemoryCacheOptions()); -private readonly IDistributedCache _distributedCache = Substitute.For(); -private readonly BlogPostCacheService _sut; - -public BlogPostCacheServiceTests() -{ -_sut = new BlogPostCacheService(_realLocalCache, _distributedCache); -} - -public void Dispose() => _realLocalCache.Dispose(); - -private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - -private static List MakeDtos() => -[ -new(Guid.NewGuid(), "Title1", "Content1", "Author1", DateTime.UtcNow, null, false), -new(Guid.NewGuid(), "Title2", "Content2", "Author2", DateTime.UtcNow, null, true), -]; - -// ── GetOrFetchAllAsync ──────────────────────────────────────────────────── - -[Fact] -public async Task GetOrFetchAllAsync_L1Hit_ReturnsCachedListWithoutDistributedCall() -{ -// Arrange -var cachedList = MakeDtos(); -_realLocalCache.Set(BlogPostCacheKeys.All, cachedList); - -var fetchCalled = false; -Task> fetch() { fetchCalled = true; return Task.FromResult>([]); } - -// Act -var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); - -// Assert -result.Should().HaveCount(2); -fetchCalled.Should().BeFalse(); -await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); -} - -[Fact] -public async Task GetOrFetchAllAsync_L2Hit_DeserializesAndPopulatesL1() -{ -// Arrange — no L1 entry, L2 has valid bytes -var dtos = MakeDtos(); -var bytes = JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts); -_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) -.Returns(Task.FromResult(bytes)); - -// Act -var result = await _sut.GetOrFetchAllAsync( -() => Task.FromResult>([]), CancellationToken.None); - -// Assert -result.Should().HaveCount(2); -_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); -l1Val.Should().HaveCount(2); -} - -[Fact] -public async Task GetOrFetchAllAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() -{ -// Arrange -var corruptBytes = "{ not valid json !!!"u8.ToArray(); -_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) -.Returns(Task.FromResult(corruptBytes)); -_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); -_distributedCache.SetAsync( -Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -var expected = MakeDtos(); -var fetchCalled = false; -Task> fetch() -{ -fetchCalled = true; -return Task.FromResult>(expected); -} - -// Act -var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); - -// Assert -fetchCalled.Should().BeTrue(); -result.Should().HaveCount(2); -await _distributedCache.Received().RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); -} - -[Fact] -public async Task GetOrFetchAllAsync_FullMiss_FetchesAndPopulatesBothTiers() -{ -// Arrange — nothing in L1 or L2 -_distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) -.Returns(Task.FromResult(null)); -_distributedCache.SetAsync( -Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -var dtos = MakeDtos(); - -// Act -var result = await _sut.GetOrFetchAllAsync( -() => Task.FromResult>(dtos), CancellationToken.None); - -// Assert -result.Should().HaveCount(2); -_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); -l1Val.Should().HaveCount(2); -await _distributedCache.Received().SetAsync( -BlogPostCacheKeys.All, -Arg.Any(), -Arg.Any(), -Arg.Any()); -} - -// ── GetOrFetchByIdAsync ─────────────────────────────────────────────────── - -[Fact] -public async Task GetOrFetchByIdAsync_L1Hit_ReturnsCachedDtoWithoutDistributedCall() -{ -// Arrange -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); -_realLocalCache.Set(key, dto); - -// Act -var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); - -// Assert -result.Should().NotBeNull(); -result!.Id.Should().Be(id); -await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); -} - -[Fact] -public async Task GetOrFetchByIdAsync_L2Hit_DeserializesAndPopulatesL1() -{ -// Arrange -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); -var bytes = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOpts); -_distributedCache.GetAsync(key, Arg.Any()) -.Returns(Task.FromResult(bytes)); - -// Act -var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); - -// Assert -result.Should().NotBeNull(); -result!.Id.Should().Be(id); -_realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); -l1Val!.Id.Should().Be(id); -} - -[Fact] -public async Task GetOrFetchByIdAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() -{ -// Arrange -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -var corruptBytes = "{ not valid json !!!"u8.ToArray(); -_distributedCache.GetAsync(key, Arg.Any()) -.Returns(Task.FromResult(corruptBytes)); -_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); -_distributedCache.SetAsync( -Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); -var fetchCalled = false; -Task fetch() { fetchCalled = true; return Task.FromResult(dto); } - -// Act -var result = await _sut.GetOrFetchByIdAsync(id, fetch, CancellationToken.None); - -// Assert -fetchCalled.Should().BeTrue(); -result!.Id.Should().Be(id); -await _distributedCache.Received().RemoveAsync(key, CancellationToken.None); -} - -[Fact] -public async Task GetOrFetchByIdAsync_FullMiss_FetchesAndPopulatesBothTiers() -{ -// Arrange -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -_distributedCache.GetAsync(key, Arg.Any()) -.Returns(Task.FromResult(null)); -_distributedCache.SetAsync( -Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); - -// Act -var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(dto), CancellationToken.None); - -// Assert -result!.Id.Should().Be(id); -_realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); -l1Val!.Id.Should().Be(id); -await _distributedCache.Received().SetAsync( -key, Arg.Any(), Arg.Any(), Arg.Any()); -} - -[Fact] -public async Task GetOrFetchByIdAsync_FetchReturnsNull_ReturnsNull() -{ -// Arrange -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -_distributedCache.GetAsync(key, Arg.Any()) -.Returns(Task.FromResult(null)); - -// Act -var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); - -// Assert -result.Should().BeNull(); -await _distributedCache.DidNotReceive().SetAsync( -Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); -} - -// ── InvalidateAllAsync ──────────────────────────────────────────────────── - -[Fact] -public async Task InvalidateAllAsync_RemovesBothCacheTiers() -{ -// Arrange — populate L1 so we can verify removal -var dtos = MakeDtos(); -_realLocalCache.Set(BlogPostCacheKeys.All, dtos); -_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -// Act -await _sut.InvalidateAllAsync(CancellationToken.None); - -// Assert -_realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? _).Should().BeFalse(); -await _distributedCache.Received(1).RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); -} - -// ── InvalidateByIdAsync ─────────────────────────────────────────────────── - -[Fact] -public async Task InvalidateByIdAsync_RemovesByKeyFromBothTiers() -{ -// Arrange — populate L1 so we can verify removal -var id = Guid.NewGuid(); -var key = BlogPostCacheKeys.ById(id); -var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); -_realLocalCache.Set(key, dto); -_distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) -.Returns(Task.CompletedTask); - -// Act -await _sut.InvalidateByIdAsync(id, CancellationToken.None); - -// Assert -_realLocalCache.TryGetValue(key, out BlogPostDto? _).Should().BeFalse(); -await _distributedCache.Received(1).RemoveAsync(key, CancellationToken.None); -} + private readonly MemoryCache _realLocalCache = new(new MemoryCacheOptions()); + private readonly IDistributedCache _distributedCache = Substitute.For(); + private readonly BlogPostCacheService _sut; + + public BlogPostCacheServiceTests() + { + _sut = new BlogPostCacheService(_realLocalCache, _distributedCache); + } + + public void Dispose() => _realLocalCache.Dispose(); + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + private static List MakeDtos() => + [ + new(Guid.NewGuid(), "Title1", "Content1", "Author1", DateTime.UtcNow, null, false), + new(Guid.NewGuid(), "Title2", "Content2", "Author2", DateTime.UtcNow, null, true), + ]; + + // ── GetOrFetchAllAsync ──────────────────────────────────────────────── + + [Fact] + public async Task GetOrFetchAllAsync_L1Hit_ReturnsCachedListWithoutDistributedCall() + { + // Arrange + var cachedList = MakeDtos(); + _realLocalCache.Set(BlogPostCacheKeys.All, cachedList); + + var fetchCalled = false; + Task> fetch() { fetchCalled = true; return Task.FromResult>([]); } + + // Act + var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); + + // Assert + result.Should().HaveCount(2); + fetchCalled.Should().BeFalse(); + await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetOrFetchAllAsync_L2Hit_DeserializesAndPopulatesL1() + { + // Arrange — no L1 entry, L2 has valid bytes + var dtos = MakeDtos(); + var bytes = JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts); + _distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) + .Returns(Task.FromResult(bytes)); + + // Act + var result = await _sut.GetOrFetchAllAsync( + () => Task.FromResult>([]), CancellationToken.None); + + // Assert + result.Should().HaveCount(2); + _realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); + l1Val.Should().HaveCount(2); + } + + [Fact] + public async Task GetOrFetchAllAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() + { + // Arrange + var corruptBytes = "{ not valid json !!!"u8.ToArray(); + _distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) + .Returns(Task.FromResult(corruptBytes)); + _distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + _distributedCache.SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var expected = MakeDtos(); + var fetchCalled = false; + Task> fetch() + { + fetchCalled = true; + return Task.FromResult>(expected); + } + + // Act + var result = await _sut.GetOrFetchAllAsync(fetch, CancellationToken.None); + + // Assert + fetchCalled.Should().BeTrue(); + result.Should().HaveCount(2); + await _distributedCache.Received().RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); + } + + [Fact] + public async Task GetOrFetchAllAsync_FullMiss_FetchesAndPopulatesBothTiers() + { + // Arrange — nothing in L1 or L2 + _distributedCache.GetAsync(BlogPostCacheKeys.All, Arg.Any()) + .Returns(Task.FromResult(null)); + _distributedCache.SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var dtos = MakeDtos(); + + // Act + var result = await _sut.GetOrFetchAllAsync( + () => Task.FromResult>(dtos), CancellationToken.None); + + // Assert + result.Should().HaveCount(2); + _realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? l1Val).Should().BeTrue(); + l1Val.Should().HaveCount(2); + await _distributedCache.Received().SetAsync( + BlogPostCacheKeys.All, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + // ── GetOrFetchByIdAsync ─────────────────────────────────────────────── + + [Fact] + public async Task GetOrFetchByIdAsync_L1Hit_ReturnsCachedDtoWithoutDistributedCall() + { + // Arrange + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + _realLocalCache.Set(key, dto); + + // Act + var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(id); + await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetOrFetchByIdAsync_L2Hit_DeserializesAndPopulatesL1() + { + // Arrange + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var bytes = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOpts); + _distributedCache.GetAsync(key, Arg.Any()) + .Returns(Task.FromResult(bytes)); + + // Act + var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(id); + _realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); + l1Val!.Id.Should().Be(id); + } + + [Fact] + public async Task GetOrFetchByIdAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetch() + { + // Arrange + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + var corruptBytes = "{ not valid json !!!"u8.ToArray(); + _distributedCache.GetAsync(key, Arg.Any()) + .Returns(Task.FromResult(corruptBytes)); + _distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + _distributedCache.SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var fetchCalled = false; + Task fetch() { fetchCalled = true; return Task.FromResult(dto); } + + // Act + var result = await _sut.GetOrFetchByIdAsync(id, fetch, CancellationToken.None); + + // Assert + fetchCalled.Should().BeTrue(); + result!.Id.Should().Be(id); + await _distributedCache.Received().RemoveAsync(key, CancellationToken.None); + } + + [Fact] + public async Task GetOrFetchByIdAsync_FullMiss_FetchesAndPopulatesBothTiers() + { + // Arrange + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + _distributedCache.GetAsync(key, Arg.Any()) + .Returns(Task.FromResult(null)); + _distributedCache.SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + + // Act + var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(dto), CancellationToken.None); + + // Assert + result!.Id.Should().Be(id); + _realLocalCache.TryGetValue(key, out BlogPostDto? l1Val).Should().BeTrue(); + l1Val!.Id.Should().Be(id); + await _distributedCache.Received().SetAsync( + key, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetOrFetchByIdAsync_FetchReturnsNull_ReturnsNull() + { + // Arrange + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + _distributedCache.GetAsync(key, Arg.Any()) + .Returns(Task.FromResult(null)); + + // Act + var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(null), CancellationToken.None); + + // Assert + result.Should().BeNull(); + await _distributedCache.DidNotReceive().SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ── InvalidateAllAsync ──────────────────────────────────────────────── + + [Fact] + public async Task InvalidateAllAsync_RemovesBothCacheTiers() + { + // Arrange — populate L1 so we can verify removal + var dtos = MakeDtos(); + _realLocalCache.Set(BlogPostCacheKeys.All, dtos); + _distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.InvalidateAllAsync(CancellationToken.None); + + // Assert + _realLocalCache.TryGetValue(BlogPostCacheKeys.All, out List? _).Should().BeFalse(); + await _distributedCache.Received(1).RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); + } + + // ── InvalidateByIdAsync ─────────────────────────────────────────────── + + [Fact] + public async Task InvalidateByIdAsync_RemovesByKeyFromBothTiers() + { + // Arrange — populate L1 so we can verify removal + var id = Guid.NewGuid(); + var key = BlogPostCacheKeys.ById(id); + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + _realLocalCache.Set(key, dto); + _distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.InvalidateByIdAsync(id, CancellationToken.None); + + // Assert + _realLocalCache.TryGetValue(key, out BlogPostDto? _).Should().BeFalse(); + await _distributedCache.Received(1).RemoveAsync(key, CancellationToken.None); + } }