Skip to content

feat(handlers): refactor all BlogPost handlers to use IBlogPostCacheService (#110) - #118

Merged
mpaulosky merged 3 commits into
sprint/5-redis-cachingfrom
squad/110-handler-refactor
Apr 24, 2026
Merged

feat(handlers): refactor all BlogPost handlers to use IBlogPostCacheService (#110)#118
mpaulosky merged 3 commits into
sprint/5-redis-cachingfrom
squad/110-handler-refactor

Conversation

@mpaulosky

Copy link
Copy Markdown
Owner

Summary

Closes #110

Working as Sam (Backend Developer)

Refactors all 4 BlogPost handlers to use the IBlogPostCacheService abstraction introduced in #109 (PR #115).

Changes

File Change
GetBlogPostsHandler.cs Inject IBlogPostCacheService, delegate to GetOrFetchAllAsync
EditBlogPostHandler.cs Use GetOrFetchByIdAsync, InvalidateAllAsync, InvalidateByIdAsync
CreateBlogPostHandler.cs Fix fire-and-forget bug: await cache.InvalidateAllAsync(ct)
DeleteBlogPostHandler.cs await InvalidateAllAsync + await InvalidateByIdAsync
src/Web/GlobalUsings.cs Add global using MyBlog.Web.Infrastructure.Caching
tests/Web.Tests/GlobalUsings.cs Same — expose IBlogPostCacheService to all test files
4 handler test files Rewrite to mock IBlogPostCacheService via NSubstitute ValueTask<T> patterns

Test Results

  • Web.Tests — 102/102 passed
  • Architecture.Tests — 9/9 passed
  • Web.Tests.Bunit — 61/61 passed
  • ⏭️ Integration tests — skipped (Docker required)

⚠️ Test changes flagged for Gimli (Tester) review before merging.

Follow-up

After this PR merges, remove Skip from both [Fact(Skip=...)] in tests/Architecture.Tests/CachingLayerTests.cs on branch squad/113-arch-caching-test (PR #116) to activate the caching arch enforcement tests.

Boromir and others added 3 commits April 23, 2026 18:10
…print5)

- Add docs/adr/sprint5-caching-abstraction.md (ADR for IBlogPostCacheService)
- Add docs/sprint5-xml-doc-stubs.md (XML doc stubs for Sam to apply)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ze cache keys (#109)

- Add BlogPostCacheKeys with All constant and ById method
- Add IBlogPostCacheService interface (L1+L2 two-tier cache abstraction)
- Add BlogPostCacheService implementation (L1=1min, L2=5min, JsonException handling)
- Add CachingServiceExtensions.AddBlogPostCaching() DI registration
- Register AddBlogPostCaching() in Program.cs after AddMemoryCache()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ervice (#110)

- GetBlogPostsHandler: inject IBlogPostCacheService, use GetOrFetchAllAsync
- EditBlogPostHandler: use GetOrFetchByIdAsync, InvalidateAllAsync, InvalidateByIdAsync
- CreateBlogPostHandler: await InvalidateAllAsync (fixes fire-and-forget bug)
- DeleteBlogPostHandler: await InvalidateAllAsync + InvalidateByIdAsync
- Add IBlogPostCacheService global using to Web and Web.Tests projects
- Update handler unit tests to mock IBlogPostCacheService via NSubstitute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 24, 2026 01:43
@github-actions github-actions Bot added the squad Squad triage inbox — Lead will assign to a member label Apr 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ PR Added to Squad Triage Queue

This PR has been labeled with squad and added to the triage queue.

Next steps:

  • The squad Lead will review and assign to an appropriate team member
  • A squad:member label will be added after triage

If you know which squad member should handle this, you can add the appropriate squad:member label yourself.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results Summary

201 tests  ±0   201 ✅ ±0   14s ⏱️ ±0s
  5 suites ±0     0 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit 1736f81. ± Comparison against base commit 7813f99.

This pull request removes 2 and adds 2 tests. Note that renamed tests count towards both.
Web.Handlers.EditBlogPostHandlerTests ‑ HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndCachesBoth
Web.Handlers.GetBlogPostsHandlerTests ‑ Handle_L2CacheHit_DeserializesAndPopulatesL1
Web.Handlers.EditBlogPostHandlerTests ‑ HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndPopulatesCache
Web.Handlers.GetBlogPostsHandlerTests ‑ Handle_L2CacheHit_ReturnsCachedDataWithoutCallingRepo

@mpaulosky
mpaulosky merged commit 7409725 into sprint/5-redis-caching Apr 24, 2026
14 of 15 checks passed
@mpaulosky
mpaulosky deleted the squad/110-handler-refactor branch April 24, 2026 01:47
@codecov

codecov Bot commented Apr 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 46.77419% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.38%. Comparing base (7813f99) to head (1736f81).
⚠️ Report is 2 commits behind head on sprint/5-redis-caching.

Files with missing lines Patch % Lines
...Web/Infrastructure/Caching/BlogPostCacheService.cs 0.00% 51 Missing and 8 partials ⚠️
...Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs 85.71% 4 Missing ⚠️
...Infrastructure/Caching/CachingServiceExtensions.cs 0.00% 2 Missing ⚠️
...rc/Web/Infrastructure/Caching/BlogPostCacheKeys.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                     @@
##           sprint/5-redis-caching     #118      +/-   ##
==========================================================
- Coverage                   78.19%   70.38%   -7.81%     
==========================================================
  Files                          40       43       +3     
  Lines                         642      672      +30     
  Branches                      107      111       +4     
==========================================================
- Hits                          502      473      -29     
- Misses                         95      147      +52     
- Partials                       45       52       +7     
Files with missing lines Coverage Δ
...Features/BlogPosts/Create/CreateBlogPostHandler.cs 100.00% <100.00%> (ø)
...Features/BlogPosts/Delete/DeleteBlogPostHandler.cs 100.00% <100.00%> (ø)
...Web/Features/BlogPosts/List/GetBlogPostsHandler.cs 100.00% <100.00%> (ø)
...rc/Web/Infrastructure/Caching/BlogPostCacheKeys.cs 0.00% <0.00%> (ø)
...Infrastructure/Caching/CachingServiceExtensions.cs 0.00% <0.00%> (ø)
...Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs 86.20% <85.71%> (+4.38%) ⬆️
...Web/Infrastructure/Caching/BlogPostCacheService.cs 0.00% <0.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors BlogPost MediatR handlers to delegate all caching behavior to the new IBlogPostCacheService abstraction, removing direct IMemoryCache/IDistributedCache usage from handlers and updating unit tests accordingly.

Changes:

  • Refactor all 4 BlogPost handlers to inject/use IBlogPostCacheService for read-through caching and invalidation.
  • Add/register caching abstraction types (IBlogPostCacheService, BlogPostCacheService, BlogPostCacheKeys, DI extension) and wire them up in Program.cs.
  • Update Web handler unit tests to mock IBlogPostCacheService (ValueTask-based APIs) and add global using for caching in tests/Web.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs Replace direct cache usage with IBlogPostCacheService.GetOrFetchAllAsync.
src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs Use GetOrFetchByIdAsync for reads; invalidate via cache service on edits.
src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs Create then invalidate via cache service (awaited).
src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs Delete then invalidate both all + by-id via cache service.
src/Web/Infrastructure/Caching/IBlogPostCacheService.cs Define cache abstraction contract for list + by-id read-through and invalidation.
src/Web/Infrastructure/Caching/BlogPostCacheService.cs Implement two-tier cache (L1 memory + L2 distributed) with JSON serialization.
src/Web/Infrastructure/Caching/BlogPostCacheKeys.cs Centralize cache keys for “all” and “by id”.
src/Web/Infrastructure/Caching/CachingServiceExtensions.cs Add DI registration helper for cache service.
src/Web/Program.cs Register cache service after AddMemoryCache() (and after Redis distributed cache).
src/Web/GlobalUsings.cs Add global using for caching namespace.
tests/Web.Tests/GlobalUsings.cs Add global using for caching namespace in tests.
tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs Update tests to mock IBlogPostCacheService for list reads.
tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs Update tests to mock IBlogPostCacheService for by-id reads + invalidation.
tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs Update tests to assert cache invalidation via cache service.
tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs Update tests to assert invalidation via cache service.
docs/adr/sprint5-caching-abstraction.md Add ADR describing the cache abstraction decision.
docs/sprint5-xml-doc-stubs.md Add documentation stubs for XML docs/README updates (currently non-compliant).

{
localCache.Set(BlogPostCacheKeys.All, fromRedis, LocalOpts);
return fromRedis;
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Redis contains bytes that deserialize to null (e.g., cached JSON null/empty payload), this code falls through to fetch() but leaves the bad L2 entry in place. For by-id lookups where fetch() returns null, that means every request will keep hitting Redis + DB. Consider treating fromRedis is null as a corrupted/stale entry and removing it (similar to the JsonException path).

Suggested change
}
}
// Stale or corrupt bytes that deserialize to null — remove and
// fall through to the DB
await distributedCache.RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None);

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +54
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly EditBlogPostHandler _handler;

public EditBlogPostHandlerTests()
{
_handler = new EditBlogPostHandler(_repo, _cache);
}

// ── Edit tests ────────────────────────────────────────────────────────────

[Fact]
public async Task HandleEdit_Success_UpdatesPostAndInvalidatesBothCaches()
{
// Arrange
var post = BlogPost.Create("Old Title", "Old Content", "Author");
var command = new EditBlogPostCommand(post.Id, "New Title", "New Content");
_repo.GetByIdAsync(post.Id, Arg.Any<CancellationToken>()).Returns(post);

// Act
var result = await _handler.Handle(command, CancellationToken.None);

// Assert
result.Success.Should().BeTrue();
await _repo.Received(1).UpdateAsync(post, Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateByIdAsync(post.Id, Arg.Any<CancellationToken>());
post.Title.Should().Be("New Title");
post.Content.Should().Be("New Content");
}

[Fact]
public async Task HandleEdit_NotFound_ReturnsFailResult()
{
// Arrange
var id = Guid.NewGuid();

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation in *.cs files is configured as tabs in this repo (see .editorconfig [*.{cs,csproj}] indent_style = tab). This test file currently has no indentation/spaces throughout; please format it to match the configured style.

Suggested change
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly EditBlogPostHandler _handler;
public EditBlogPostHandlerTests()
{
_handler = new EditBlogPostHandler(_repo, _cache);
}
// ── Edit tests ────────────────────────────────────────────────────────────
[Fact]
public async Task HandleEdit_Success_UpdatesPostAndInvalidatesBothCaches()
{
// Arrange
var post = BlogPost.Create("Old Title", "Old Content", "Author");
var command = new EditBlogPostCommand(post.Id, "New Title", "New Content");
_repo.GetByIdAsync(post.Id, Arg.Any<CancellationToken>()).Returns(post);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
await _repo.Received(1).UpdateAsync(post, Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateByIdAsync(post.Id, Arg.Any<CancellationToken>());
post.Title.Should().Be("New Title");
post.Content.Should().Be("New Content");
}
[Fact]
public async Task HandleEdit_NotFound_ReturnsFailResult()
{
// Arrange
var id = Guid.NewGuid();
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly EditBlogPostHandler _handler;
public EditBlogPostHandlerTests()
{
_handler = new EditBlogPostHandler(_repo, _cache);
}
// ── Edit tests ────────────────────────────────────────────────────────────
[Fact]
public async Task HandleEdit_Success_UpdatesPostAndInvalidatesBothCaches()
{
// Arrange
var post = BlogPost.Create("Old Title", "Old Content", "Author");
var command = new EditBlogPostCommand(post.Id, "New Title", "New Content");
_repo.GetByIdAsync(post.Id, Arg.Any<CancellationToken>()).Returns(post);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
await _repo.Received(1).UpdateAsync(post, Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateByIdAsync(post.Id, Arg.Any<CancellationToken>());
post.Title.Should().Be("New Title");
post.Content.Should().Be("New Content");
}
[Fact]
public async Task HandleEdit_NotFound_ReturnsFailResult()
{
// Arrange
var id = Guid.NewGuid();

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +2
# Sprint 5 — XML Doc Comment Stubs

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This markdown file violates the repo markdown validation rules: it starts with an H1 and does not include the required YAML front matter block. Update it to include front matter at the top and start content headings at H2 (no leading # ...).

Suggested change
# Sprint 5 — XML Doc Comment Stubs
---
post_title: "Sprint 5 — XML Doc Comment Stubs"
author1: "Frodo"
post_slug: "sprint5-xml-doc-stubs"
microsoft_alias: "frodo"
featured_image: "https://learn.microsoft.com/media/open-graph-image.png"
categories:
- "Engineering"
tags:
- "xml-docs"
- "csharp"
- "caching"
- "redis"
ai_note: "AI-assisted"
summary: "XML documentation comment stubs for Sprint 5 blog post caching abstractions and related README update notes."
post_date: "2026-04-23"
---
## Sprint 5 — XML Doc Comment Stubs

Copilot uses AI. Check for mistakes.
{
localCache.Set(key, dto, LocalOpts);
return dto;
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the all-posts path: when Deserialize<BlogPostDto> returns null without throwing, the code treats it as a miss but does not remove the invalid Redis entry. If fetch() returns null (not found), the invalid Redis payload will persist and cause repeated Redis+DB work. Consider removing the key when deserialization returns null.

Suggested change
}
}
// Invalid payload for a single-post cache entry — remove and fall through to the DB
await distributedCache.RemoveAsync(key, CancellationToken.None);

Copilot uses AI. Check for mistakes.
JsonSerializer.SerializeToUtf8Bytes(list, JsonOpts),
RedisOpts,
ct);
return result;

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method materializes the fetched results into list and stores that in caches, but then returns result instead of the materialized list. If fetch() returns a non-List<T> implementation, callers will get a different instance than what was cached (and potentially a less efficient type). Consider returning list for consistency with what is cached.

Suggested change
return result;
return list;

Copilot uses AI. Check for mistakes.

### Registration

`BlogPostCacheService` is registered in `Program.cs` as a scoped or singleton service alongside `IMemoryCache` and the Redis `IDistributedCache`.

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ADR states the cache service is registered as "scoped or singleton", but the actual registration added in this PR is AddSingleton<IBlogPostCacheService, BlogPostCacheService>() (via AddBlogPostCaching). Please update the ADR to reflect the chosen lifetime to avoid architectural documentation drifting from reality.

Suggested change
`BlogPostCacheService` is registered in `Program.cs` as a scoped or singleton service alongside `IMemoryCache` and the Redis `IDistributedCache`.
`BlogPostCacheService` is registered in `Program.cs` as a singleton service alongside `IMemoryCache` and the Redis `IDistributedCache`.

Copilot uses AI. Check for mistakes.
Comment on lines 15 to +22
public sealed class CreateBlogPostHandler(
IBlogPostRepository repo,
IMemoryCache localCache,
IDistributedCache distributedCache) : IRequestHandler<CreateBlogPostCommand, Result<Guid>>
IBlogPostRepository repo,
IBlogPostCacheService cache) : IRequestHandler<CreateBlogPostCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateBlogPostCommand request, CancellationToken ct)
{
try
{
var post = BlogPost.Create(request.Title, request.Content, request.Author);
await repo.AddAsync(post, ct);
localCache.Remove("blog:all");
_ = distributedCache.RemoveAsync("blog:all", ct);
return Result.Ok<Guid>(post.Id);
}
catch (Exception ex)
{
return Result.Fail<Guid>(ex.Message);
}
}
public async Task<Result<Guid>> Handle(CreateBlogPostCommand request, CancellationToken ct)
{
try
{

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation in *.cs files is configured as tabs in this repo (see .editorconfig [*.{cs,csproj}] indent_style = tab). This handler was reformatted with no indentation/spaces; please format it to match repo conventions.

Copilot uses AI. Check for mistakes.
Comment on lines 15 to +22
public sealed class DeleteBlogPostHandler(
IBlogPostRepository repo,
IMemoryCache localCache,
IDistributedCache distributedCache) : IRequestHandler<DeleteBlogPostCommand, Result>
IBlogPostRepository repo,
IBlogPostCacheService cache) : IRequestHandler<DeleteBlogPostCommand, Result>
{
public async Task<Result> Handle(DeleteBlogPostCommand request, CancellationToken ct)
{
try
{
await repo.DeleteAsync(request.Id, ct);
localCache.Remove("blog:all");
localCache.Remove($"blog:{request.Id}");
await distributedCache.RemoveAsync("blog:all", ct);
await distributedCache.RemoveAsync($"blog:{request.Id}", ct);
return Result.Ok();
}
catch (DbUpdateConcurrencyException)
{
return Result.Fail(
"This post was modified by another user. Please reload and try again.",
ResultErrorCode.Concurrency);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> Handle(DeleteBlogPostCommand request, CancellationToken ct)
{
try
{

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation in *.cs files is configured as tabs in this repo (see .editorconfig [*.{cs,csproj}] indent_style = tab). This handler currently has no indentation/spaces; please format it to match repo conventions.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to 56
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly CreateBlogPostHandler _handler;

public CreateBlogPostHandlerTests()
{
_handler = new CreateBlogPostHandler(_repo, _cache);
}

[Fact]
public async Task Handle_Success_CreatesPostInvalidatesCacheAndReturnsGuid()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");

// Act
var result = await _handler.Handle(command, CancellationToken.None);

// Assert
result.Success.Should().BeTrue();
result.Value.Should().NotBeEmpty();
await _repo.Received(1).AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_RepoThrows_ReturnsFailResult()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
_repo.AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("insert failed"));

// Act
var result = await _handler.Handle(command, CancellationToken.None);

// Assert
result.Failure.Should().BeTrue();
result.Error.Should().Contain("insert failed");
}
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation in *.cs files is configured as tabs in this repo (see .editorconfig [*.{cs,csproj}] indent_style = tab). This test file currently has no indentation/spaces; please format it to match repo conventions.

Suggested change
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly CreateBlogPostHandler _handler;
public CreateBlogPostHandlerTests()
{
_handler = new CreateBlogPostHandler(_repo, _cache);
}
[Fact]
public async Task Handle_Success_CreatesPostInvalidatesCacheAndReturnsGuid()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.Value.Should().NotBeEmpty();
await _repo.Received(1).AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_RepoThrows_ReturnsFailResult()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
_repo.AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("insert failed"));
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Failure.Should().BeTrue();
result.Error.Should().Contain("insert failed");
}
}
private readonly IBlogPostRepository _repo = Substitute.For<IBlogPostRepository>();
private readonly IBlogPostCacheService _cache = Substitute.For<IBlogPostCacheService>();
private readonly CreateBlogPostHandler _handler;
public CreateBlogPostHandlerTests()
{
_handler = new CreateBlogPostHandler(_repo, _cache);
}
[Fact]
public async Task Handle_Success_CreatesPostInvalidatesCacheAndReturnsGuid()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.Value.Should().NotBeEmpty();
await _repo.Received(1).AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>());
await _cache.Received(1).InvalidateAllAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_RepoThrows_ReturnsFailResult()
{
// Arrange
var command = new CreateBlogPostCommand("Title", "Content", "Author");
_repo.AddAsync(Arg.Any<BlogPost>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("insert failed"));
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Failure.Should().BeTrue();
result.Error.Should().Contain("insert failed");
}
}

Copilot uses AI. Check for mistakes.
.Returns<ValueTask<BlogPostDto?>>(ci =>
{
var fetch = ci.Arg<Func<Task<BlogPostDto?>>>();
return new ValueTask<BlogPostDto?>(fetch().GetAwaiter().GetResult());

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same sync-over-async pattern here (GetAwaiter().GetResult()); please return a ValueTask from the fetch() task instead of blocking.

Suggested change
return new ValueTask<BlogPostDto?>(fetch().GetAwaiter().GetResult());
return new ValueTask<BlogPostDto?>(fetch());

Copilot uses AI. Check for mistakes.
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
Sam's #115 and #118 have merged, so handlers now use IBlogPostCacheService.
Both arch tests pass green:
  - Features_Should_Not_Reference_IDistributedCache_Directly
  - Features_Should_Not_Reference_IMemoryCache_Directly

Closes #113
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
… slnx

Sam's #115 (IBlogPostCacheService) and #118 (handler refactor) have merged.
Changes:
- Add Unit.Tests to MyBlog.slnx (safe now that IBlogPostCacheService exists)
- Fix 3 NSubstitute mock setups: async lambda → ValueTask<T>(task) ctor
  (NSubstitute cannot convert async lambdas to ValueTask<T> return type)

All 16 unit tests pass green across 5 handler test classes.

Closes #111
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mpaulosky pushed a commit that referenced this pull request Apr 24, 2026
… slnx

Sam's #115 (IBlogPostCacheService) and #118 (handler refactor) have merged.
Changes:
- Add Unit.Tests to MyBlog.slnx (safe now that IBlogPostCacheService exists)
- Fix 3 NSubstitute mock setups: async lambda → ValueTask<T>(task) ctor
  (NSubstitute cannot convert async lambdas to ValueTask<T> return type)

All 16 unit tests pass green across 5 handler test classes.

Closes #111
Working as Gimli (Tester)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

squad Squad triage inbox — Lead will assign to a member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants