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..1cd2bf2b --- /dev/null +++ b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs @@ -0,0 +1,285 @@ +//======================================================= +//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; + +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); + } +}