diff --git a/.squad/agents/aragorn/history.md b/.squad/agents/aragorn/history.md index 5bc53f5a..d1d8b0ed 100644 --- a/.squad/agents/aragorn/history.md +++ b/.squad/agents/aragorn/history.md @@ -1148,3 +1148,31 @@ value object into the command. **EF Core MongoDB provider handles owned entities via `OwnsOne` — no BsonElement attributes needed.** The mapping is declared in `OnModelCreating` exactly like SQL EF Core. Primitive collection properties (e.g., `IReadOnlyList Roles`) are supported on owned types. **String-to-embedded-object is a breaking MongoDB schema change.** Even in dev, existing documents will cause deserialization failures. Drop/recreate in dev; migration script required for any environment with live data. +## 2026-05-11 — PR #297 Review + Merge: L1+L2 caching for UserManagement Auth0 API + +**Requested by:** Boromir (Ralph work-check cycle Round 2) +**Issue:** #293 — Member roles page making N+1 Auth0 Management API calls on every page load +**PR:** #297 `squad/293-member-roles-caching` → `dev` + +### Review Findings + +**Files reviewed:** + +- `UserManagementCacheKeys.cs` — const string keys `usermgmt:users` / `usermgmt:roles`. Clean, follows BlogPostCacheKeys pattern exactly. +- `IUserManagementCacheService.cs` — interface with `GetOrFetchUsersAsync`, `GetOrFetchRolesAsync`, `InvalidateUsersAsync`, `InvalidateRolesAsync`. `ValueTask` return type correct (L1 hits complete synchronously without heap allocation). `CancellationToken.None` on Redis removal post-mutation documented in XML remarks. +- `UserManagementCacheService.cs` — L1 30s / L2 2min, JSON serialization with `JsonSerializerDefaults.Web`, corrupt-L2 catch block with fallthrough. Matches BlogPostCacheService implementation exactly. +- `CachingServiceExtensions.cs` — `AddUserManagementCaching()` registers as `Singleton`. Correct: both `IMemoryCache` and `IDistributedCache` are singletons; no captive-dependency violation. +- `Program.cs` — `AddUserManagementCaching()` called immediately after `AddBlogPostCaching()`. Clean placement. +- `UserManagementHandler.cs` — `GetOrFetchUsersAsync` and `GetOrFetchRolesAsync` wrap Auth0 API calls. `InvalidateUsersAsync` called on `AssignRole` and `RemoveRole`. ✅ `InvalidateRolesAsync` NOT called on assign/remove — correct, because available roles in Auth0 are static; assigning/removing a user's role doesn't change which roles exist. +- `UserManagementHandlerTests.cs` — `BuildPassThroughCache()` helper is well-designed: NSubstitute mock delegates `GetOrFetchUsersAsync`/`GetOrFetchRolesAsync` to the caller-supplied `Func>`, so all existing config-missing and HTTP-failure assertions still exercise the real fetch logic. All five static builder helpers threaded correctly. + +**CI status at review:** All 17 checks green (7 test suites, CodeQL, Codecov, markdownlint, build). + +**Verdict: ✅ APPROVED** — pattern conformance exact, cache invalidation semantically correct, DI registration clean. + +### Outcome + +- **GitHub approve** rejected (cannot approve own PR — established protocol). +- Squash-merged to `dev`: "feat(app): add L1+L2 caching to UserManagement Auth0 API calls (#297)" +- Branch `squad/293-member-roles-caching` deleted (local + remote). +- Closes #293. diff --git a/.squad/agents/legolas/history.md b/.squad/agents/legolas/history.md index adc97302..97c70697 100644 --- a/.squad/agents/legolas/history.md +++ b/.squad/agents/legolas/history.md @@ -776,3 +776,26 @@ Then each variant only declares its colour-specific overrides. This is idiomatic 6. **`form-input`/`form-label` bump to `text-lg font-semibold`** — Unusually large/bold for form field text; worth a visual QA check. **Verdict:** Approved with concerns (items 1, 2, 3 are the meaningful ones for follow-up). + +### 2025-07 — Issue #296: Auto-fill Author on Create Post page + +**What I implemented:** + +- Replaced the manual `Author` text input in `Create.razor` with auto-population from `AuthenticationStateProvider` +- Injected `AuthenticationStateProvider` + `RoleClaimsHelper` (via `@using MyBlog.Web.Security`) +- `OnInitializedAsync` reads `sub`, `name`, `email`, and roles from claims; `_authorName` displayed as read-only above the form +- `HandleSubmit` builds `PostAuthor` from the private fields and passes it to `CreateBlogPostCommand` +- `PostFormModel` has no `Author` property; Title + Content only + +**bUnit test fixes:** + +- Created `TestAuthenticationStateProvider` (implements `AuthenticationStateProvider`) in `tests/Web.Tests.Bunit/Testing/` to satisfy the DI injection that `Create.razor` requires +- Registered it as a singleton in `RazorSmokeTests` constructor +- Updated `RenderWithUser` to call `_authProvider.SetUser(principal)` before rendering +- Updated Create tests: removed `FindAll("input")[1]` stale references (Author input no longer exists); adjusted `BeGreaterThanOrEqualTo(2)` → `(1)` + +**Key patterns to remember:** + +- When a Blazor component injects `AuthenticationStateProvider` directly (not just cascading state), bUnit tests need it registered as a DI service — the cascading `Task` alone is not enough +- `RoleClaimsHelper.GetRoles(user)` handles all Auth0 role claim namespace variations automatically; always prefer it over manual `ClaimTypes.Role` filtering +- Auth0 `name` claim is "name" (not `ClaimTypes.Name`); fallback to `user.Identity?.Name` handles standard auth diff --git a/.squad/agents/sam/history.md b/.squad/agents/sam/history.md index cbe094fb..d887d75f 100644 --- a/.squad/agents/sam/history.md +++ b/.squad/agents/sam/history.md @@ -178,3 +178,51 @@ the field guards all three dev commands (Clear, Seed, Stats), not just Clear. - ✅ Architecture.Tests: 15/15, Domain.Tests: 42/42, Integration.Tests: 12/12 ### PR: #267 + +## 2026-05-xx — Issue #296: PostAuthor Value Object + +### Task + +Replace `string Author` on `BlogPost` with an immutable `PostAuthor` value object, per Aragorn's ADR (`aragorn-296-post-author-adr.md`). + +### Changes Made + +1. **`src/Domain/ValueObjects/PostAuthor.cs`** — New `sealed record PostAuthor(string Id, string Name, string Email, IReadOnlyList Roles)` with `PostAuthor.Empty` helper +2. **`src/Domain/Entities/BlogPost.cs`** — `Author` property changed from `string` to `PostAuthor`; `Create()` guards: `ArgumentNullException.ThrowIfNull(author)` then `ArgumentException.ThrowIfNullOrWhiteSpace(author.Name)` +3. **`src/Web/Data/BlogDbContext.cs`** — Added `entity.OwnsOne(p => p.Author, ...)` with `HasElementName` for each field to control MongoDB sub-document field names +4. **`src/Web/Data/BlogPostDto.cs`** — Replaced `string Author` with `string AuthorId, string AuthorName, string AuthorEmail, IReadOnlyList AuthorRoles` (positional record — all call sites updated) +5. **`src/Web/Data/BlogPostMappings.cs`** — Updated `ToDto()` to map flat author fields +6. **`src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs`** — `PostAuthor Author` replaces `string Author` +7. **`src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs`** — `NotNull()` on Author + `NotEmpty().When(author not null)` on Author.Name +8. **`src/Web/Features/BlogPosts/Create/Create.razor`** — Temporary stub builds `PostAuthor` from `_model.Author` string field; Legolas must replace with `AuthenticationStateProvider` injection +9. **`src/Web/Features/BlogPosts/List/Index.razor`** — `@post.Author` → `@post.AuthorName` +10. **All test projects** — GlobalUsings, entity construction, DTO construction updated + +### Build Validation + +- ✅ Release build: 0 errors, 0 warnings +- ✅ Architecture.Tests: 16/16 +- ✅ Domain.Tests: 42/42 +- ✅ Web.Tests: 151/151 +- ✅ Integration.Tests: 12/12 (Docker) +- ✅ All pre-push gates passed + +### PR: #298 + +### Learnings + +#### `PostAuthor.Empty` is a testing convenience — not valid for `BlogPost.Create()` + +- `PostAuthor.Empty` has `Name = ""`. The `BlogPost.Create()` guard rejects empty Name. +- Tests that exercise handler behavior (not null/empty author validation) must use `new PostAuthor("", "Test Author", "", [])`, not `PostAuthor.Empty`. +- Only use `PostAuthor.Empty` in command/validator tests that specifically test the "empty author" failure path. + +#### Razor files need explicit `@using` for value objects + +- Razor components do not automatically inherit global usings from `GlobalUsings.cs` files in the same project for all scenarios. +- Add `@using MyBlog.Domain.ValueObjects` explicitly at the top of any `.razor` file that references `PostAuthor`. + +#### Breaking schema changes need a migration note in the PR + +- MongoDB `OwnsOne` with `HasElementName` changes the stored field name from a flat `"Author"` string to a sub-document `{"AuthorId":...,"AuthorName":...}`. +- Existing documents fail to deserialize; always document the migration strategy (drop/recreate in dev; script in prod) in the PR. diff --git a/src/Domain/Entities/BlogPost.cs b/src/Domain/Entities/BlogPost.cs index 36964f18..e04cda12 100644 --- a/src/Domain/Entities/BlogPost.cs +++ b/src/Domain/Entities/BlogPost.cs @@ -7,6 +7,8 @@ //Project Name : Domain //======================================================= +using MyBlog.Domain.ValueObjects; + namespace MyBlog.Domain.Entities; public sealed class BlogPost @@ -14,7 +16,7 @@ public sealed class BlogPost public Guid Id { get; private set; } public string Title { get; private set; } = string.Empty; public string Content { get; private set; } = string.Empty; - public string Author { get; private set; } = string.Empty; + public PostAuthor Author { get; private set; } = PostAuthor.Empty; public DateTime CreatedAt { get; private set; } public DateTime? UpdatedAt { get; private set; } public bool IsPublished { get; private set; } @@ -22,11 +24,12 @@ public sealed class BlogPost private BlogPost() { } - public static BlogPost Create(string title, string content, string author) + public static BlogPost Create(string title, string content, PostAuthor author) { ArgumentException.ThrowIfNullOrWhiteSpace(title); ArgumentException.ThrowIfNullOrWhiteSpace(content); - ArgumentException.ThrowIfNullOrWhiteSpace(author); + ArgumentNullException.ThrowIfNull(author); + ArgumentException.ThrowIfNullOrWhiteSpace(author.Name); return new BlogPost { diff --git a/src/Domain/ValueObjects/PostAuthor.cs b/src/Domain/ValueObjects/PostAuthor.cs new file mode 100644 index 00000000..6cd0ab65 --- /dev/null +++ b/src/Domain/ValueObjects/PostAuthor.cs @@ -0,0 +1,28 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : PostAuthor.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Domain +//======================================================= + +namespace MyBlog.Domain.ValueObjects; + +/// +/// Immutable snapshot of the post author's identity at the time the post was created. +/// Stored as an embedded document in MongoDB. +/// +public sealed record PostAuthor( + string Id, + string Name, + string Email, + IReadOnlyList Roles) +{ + /// Empty/anonymous author placeholder for testing and migration purposes. + public static readonly PostAuthor Empty = new( + string.Empty, + string.Empty, + string.Empty, + []); +} diff --git a/src/Web/Data/BlogDbContext.cs b/src/Web/Data/BlogDbContext.cs index e9c7232b..33715d5b 100644 --- a/src/Web/Data/BlogDbContext.cs +++ b/src/Web/Data/BlogDbContext.cs @@ -26,5 +26,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToCollection("blogposts"); entity.HasKey(p => p.Id); entity.Property(p => p.Version).IsConcurrencyToken(); + entity.OwnsOne(p => p.Author, a => + { + a.Property(x => x.Id).HasElementName("AuthorId"); + a.Property(x => x.Name).HasElementName("AuthorName"); + a.Property(x => x.Email).HasElementName("AuthorEmail"); + a.Property(x => x.Roles).HasElementName("AuthorRoles"); + }); } } diff --git a/src/Web/Data/BlogPostDto.cs b/src/Web/Data/BlogPostDto.cs index 37943351..2da47754 100644 --- a/src/Web/Data/BlogPostDto.cs +++ b/src/Web/Data/BlogPostDto.cs @@ -13,7 +13,10 @@ internal sealed record BlogPostDto( Guid Id, string Title, string Content, - string Author, + string AuthorId, + string AuthorName, + string AuthorEmail, + IReadOnlyList AuthorRoles, DateTime CreatedAt, DateTime? UpdatedAt, bool IsPublished); diff --git a/src/Web/Data/BlogPostMappings.cs b/src/Web/Data/BlogPostMappings.cs index f612619c..3c1d04b7 100644 --- a/src/Web/Data/BlogPostMappings.cs +++ b/src/Web/Data/BlogPostMappings.cs @@ -12,6 +12,7 @@ namespace MyBlog.Web.Data; internal static class BlogPostMappings { internal static BlogPostDto ToDto(this BlogPost post) => new( - post.Id, post.Title, post.Content, post.Author, + post.Id, post.Title, post.Content, + post.Author.Id, post.Author.Name, post.Author.Email, post.Author.Roles, post.CreatedAt, post.UpdatedAt, post.IsPublished); } diff --git a/src/Web/Features/BlogPosts/Create/Create.razor b/src/Web/Features/BlogPosts/Create/Create.razor index 18322cb3..4f9f8965 100644 --- a/src/Web/Features/BlogPosts/Create/Create.razor +++ b/src/Web/Features/BlogPosts/Create/Create.razor @@ -1,6 +1,10 @@ @page "/blog/create" +@using MyBlog.Domain.ValueObjects +@using MyBlog.Web.Security +@using System.Security.Claims @inject ISender Sender @inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider @rendermode InteractiveServer @attribute [Authorize(Roles = "Author,Admin")] @@ -14,6 +18,8 @@ } +

Author: @_authorName

+
@@ -23,10 +29,6 @@
-
- - -
@@ -43,9 +45,25 @@ private readonly PostFormModel _model = new(); private string? _error; + private string _authorId = string.Empty; + private string _authorName = string.Empty; + private string _authorEmail = string.Empty; + private IReadOnlyList _authorRoles = []; + + protected override async Task OnInitializedAsync() + { + var state = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = state.User; + _authorId = user.FindFirst("sub")?.Value ?? string.Empty; + _authorName = user.FindFirst("name")?.Value ?? user.Identity?.Name ?? string.Empty; + _authorEmail = user.FindFirst("email")?.Value ?? string.Empty; + _authorRoles = RoleClaimsHelper.GetRoles(user); + } + private async Task HandleSubmit() { - var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, _model.Author)); + var author = new PostAuthor(_authorId, _authorName, _authorEmail, _authorRoles); + var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, author)); if (result.Success) Navigation.NavigateTo("/blog"); else @@ -57,9 +75,6 @@ [System.ComponentModel.DataAnnotations.Required] public string Title { get; set; } = string.Empty; - [System.ComponentModel.DataAnnotations.Required] - public string Author { get; set; } = string.Empty; - [System.ComponentModel.DataAnnotations.Required] public string Content { get; set; } = string.Empty; } diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs index 699d8ca0..c63f550f 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs @@ -8,8 +8,9 @@ //======================================================= using MyBlog.Domain.Abstractions; +using MyBlog.Domain.ValueObjects; namespace MyBlog.Web.Features.BlogPosts.Create; -internal sealed record CreateBlogPostCommand(string Title, string Content, string Author) +internal sealed record CreateBlogPostCommand(string Title, string Content, PostAuthor Author) : IRequest>; diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs index 25acd03c..570c1efa 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs @@ -23,7 +23,11 @@ public CreateBlogPostCommandValidator() .NotEmpty().WithMessage("Content is required."); RuleFor(x => x.Author) - .NotEmpty().WithMessage("Author is required.") - .MaximumLength(100).WithMessage("Author must not exceed 100 characters."); + .NotNull() + .WithMessage("Author is required."); + RuleFor(x => x.Author.Name) + .NotEmpty() + .WithMessage("Author name is required.") + .When(x => x.Author is not null); } } diff --git a/src/Web/Features/BlogPosts/List/Index.razor b/src/Web/Features/BlogPosts/List/Index.razor index 224fe71a..9a6f088f 100644 --- a/src/Web/Features/BlogPosts/List/Index.razor +++ b/src/Web/Features/BlogPosts/List/Index.razor @@ -61,7 +61,7 @@ else { @post.Title - @post.Author + @post.AuthorName @(post.IsPublished ? "Yes" : "No") @post.CreatedAt.ToShortDateString() diff --git a/tests/Domain.Tests/Entities/BlogPostTests.cs b/tests/Domain.Tests/Entities/BlogPostTests.cs index a8aad774..be66d0f3 100644 --- a/tests/Domain.Tests/Entities/BlogPostTests.cs +++ b/tests/Domain.Tests/Entities/BlogPostTests.cs @@ -11,28 +11,25 @@ namespace Tests.Domain.Entities; public class BlogPostTests { + private static readonly PostAuthor TestAuthor = new("test-id", "Test Author", "test@example.com", []); + [Fact] public void Create_ValidArguments_ReturnsEntityWithCorrectFields() { - // Arrange - const string title = "Test Title"; - const string content = "Test Content"; - const string author = "Test Author"; - - // Act - var post = BlogPost.Create(title, content, author); + // Arrange / Act + var post = BlogPost.Create("Test Title", "Test Content", TestAuthor); // Assert - post.Title.Should().Be(title); - post.Content.Should().Be(content); - post.Author.Should().Be(author); + post.Title.Should().Be("Test Title"); + post.Content.Should().Be("Test Content"); + post.Author.Name.Should().Be("Test Author"); } [Fact] public void Create_ValidArguments_IdIsNonEmptyGuid() { // Arrange / Act - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Assert post.Id.Should().NotBeEmpty(); @@ -45,7 +42,7 @@ public void Create_ValidArguments_CreatedAtIsSet() var before = DateTime.UtcNow; // Act - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Assert post.CreatedAt.Should().BeOnOrAfter(before); @@ -56,7 +53,7 @@ public void Create_ValidArguments_CreatedAtIsSet() public void Create_ValidArguments_UpdatedAtIsNull() { // Arrange / Act - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Assert post.UpdatedAt.Should().BeNull(); @@ -69,7 +66,7 @@ public void Create_ValidArguments_UpdatedAtIsNull() public void Create_NullOrWhiteSpaceTitle_ThrowsArgumentException(string? title) { // Arrange / Act - var act = () => BlogPost.Create(title!, "Content", "Author"); + var act = () => BlogPost.Create(title!, "Content", TestAuthor); // Assert act.Should().Throw(); @@ -82,20 +79,29 @@ public void Create_NullOrWhiteSpaceTitle_ThrowsArgumentException(string? title) public void Create_NullOrWhiteSpaceContent_ThrowsArgumentException(string? content) { // Arrange / Act - var act = () => BlogPost.Create("Title", content!, "Author"); + var act = () => BlogPost.Create("Title", content!, TestAuthor); // Assert act.Should().Throw(); } + [Fact] + public void Create_NullAuthor_ThrowsArgumentNullException() + { + // Arrange / Act + var act = () => BlogPost.Create("Title", "Content", null!); + + // Assert + act.Should().Throw(); + } + [Theory] - [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Create_NullOrWhiteSpaceAuthor_ThrowsArgumentException(string? author) + public void Create_WhiteSpaceAuthorName_ThrowsArgumentException(string? authorName) { // Arrange / Act - var act = () => BlogPost.Create("Title", "Content", author!); + var act = () => BlogPost.Create("Title", "Content", new PostAuthor("", authorName!, "", [])); // Assert act.Should().Throw(); @@ -105,7 +111,7 @@ public void Create_NullOrWhiteSpaceAuthor_ThrowsArgumentException(string? author public void Update_ValidArguments_UpdatesFieldsAndSetsUpdatedAt() { // Arrange - var post = BlogPost.Create("Original Title", "Original Content", "Author"); + var post = BlogPost.Create("Original Title", "Original Content", TestAuthor); var before = DateTime.UtcNow; // Act @@ -122,7 +128,7 @@ public void Update_ValidArguments_UpdatesFieldsAndSetsUpdatedAt() public void Update_ValidArguments_UpdatedAtIsNullBeforeUpdate_NonNullAfter() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); post.UpdatedAt.Should().BeNull(); // Act @@ -139,7 +145,7 @@ public void Update_ValidArguments_UpdatedAtIsNullBeforeUpdate_NonNullAfter() public void Update_NullOrWhiteSpaceTitle_ThrowsArgumentException(string? title) { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Act var act = () => post.Update(title!, "New Content"); @@ -152,7 +158,7 @@ public void Update_NullOrWhiteSpaceTitle_ThrowsArgumentException(string? title) public void Publish_SetsIsPublishedTrue() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Act post.Publish(); @@ -165,7 +171,7 @@ public void Publish_SetsIsPublishedTrue() public void Unpublish_SetsIsPublishedFalse() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); post.Publish(); // Act @@ -179,7 +185,7 @@ public void Unpublish_SetsIsPublishedFalse() public void Create_NewPost_IsPublishedIsFalse() { // Arrange / Act - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", TestAuthor); // Assert post.IsPublished.Should().BeFalse(); diff --git a/tests/Domain.Tests/GlobalUsings.cs b/tests/Domain.Tests/GlobalUsings.cs index dea26d27..fc976477 100644 --- a/tests/Domain.Tests/GlobalUsings.cs +++ b/tests/Domain.Tests/GlobalUsings.cs @@ -17,5 +17,6 @@ global using MyBlog.Domain.Abstractions; global using MyBlog.Domain.Behaviors; global using MyBlog.Domain.Entities; +global using MyBlog.Domain.ValueObjects; global using NSubstitute; diff --git a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs index bb0032ab..a1d0b547 100644 --- a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs +++ b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs @@ -29,10 +29,13 @@ namespace Web.Components; public class RazorSmokeTests : BunitContext { + private readonly TestAuthenticationStateProvider _authProvider = new(); + public RazorSmokeTests() { Services.AddAuthorizationCore(); Services.AddSingleton(); + Services.AddSingleton(_authProvider); } [Fact] @@ -143,7 +146,7 @@ public void BlogIndexRendersPostsForAuthorizedUserAndCanOpenDeleteDialog() var sender = Substitute.For(); var posts = new[] { - new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) + new BlogPostDto(Guid.NewGuid(), "First", "Content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false) }; sender.Send(Arg.Any(), Arg.Any()) @@ -172,7 +175,7 @@ public void BlogIndexUsesPrimaryAndSecondaryButtonVariantsForAuthorActions() var sender = Substitute.For(); var posts = new[] { - new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) + new BlogPostDto(Guid.NewGuid(), "First", "Content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false) }; sender.Send(Arg.Any(), Arg.Any()) @@ -197,7 +200,7 @@ public void BlogIndexUsesBtnDestructiveForInlineDeleteButton() var sender = Substitute.For(); var posts = new[] { - new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) + new BlogPostDto(Guid.NewGuid(), "First", "Content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false) }; sender.Send(Arg.Any(), Arg.Any()) @@ -259,7 +262,7 @@ public void BlogIndexConfirmDeleteSendsDeleteCommandAndRefreshesList() var postId = Guid.NewGuid(); var posts = new[] { - new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + new BlogPostDto(postId, "First", "Content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false) }; sender.Send(Arg.Any(), Arg.Any()) @@ -290,7 +293,7 @@ public void BlogIndexShowsConcurrencyWarningWhenDeleteFailsWithConcurrency() var postId = Guid.NewGuid(); var posts = new[] { - new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + new BlogPostDto(postId, "First", "Content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false) }; sender.Send(Arg.Any(), Arg.Any()) @@ -328,7 +331,7 @@ public void CreatePostRendersForm() heading.TextContent.Trim().Should().Be("Create Post"); heading.GetAttribute("class").Should().Contain("text-primary-900").And.Contain("dark:text-primary-50"); - cut.FindAll("input").Count.Should().BeGreaterThanOrEqualTo(2); + cut.FindAll("input").Count.Should().BeGreaterThanOrEqualTo(1); cut.Find("textarea"); } @@ -363,14 +366,13 @@ public void CreatePostSubmitsAndNavigatesToBlogWhenCommandSucceeds() var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); cut.FindAll("input")[0].Change("My title"); - cut.FindAll("input")[1].Change("Alice"); cut.Find("textarea").Change("Hello world"); cut.Find("form").Submit(); // Assert sender.Received(1).Send(Arg.Is(command => command.Title == "My title" && - command.Author == "Alice" && + command.Author.Name == "Alice" && command.Content == "Hello world"), Arg.Any()); navigation.Uri.Should().EndWith("/blog"); } @@ -388,7 +390,6 @@ public void CreatePostShowsDismissibleErrorWhenCommandFails() var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); cut.FindAll("input")[0].Change("My title"); - cut.FindAll("input")[1].Change("Alice"); cut.Find("textarea").Change("Hello world"); cut.Find("form").Submit(); @@ -404,7 +405,7 @@ public void EditPostLoadsExistingPost() // Arrange var sender = Substitute.For(); var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + var post = new BlogPostDto(postId, "Existing title", "Existing content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false); sender.Send(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Ok(post))); @@ -426,7 +427,7 @@ public void EditPostUsesPrimaryAndSecondaryButtonVariants() // Arrange var sender = Substitute.For(); var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + var post = new BlogPostDto(postId, "Existing title", "Existing content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false); sender.Send(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Ok(post))); @@ -449,7 +450,7 @@ public void EditPostShowsConcurrencyMessageWhenSaveFailsWithConcurrency() // Arrange var sender = Substitute.For(); var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + var post = new BlogPostDto(postId, "Existing title", "Existing content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false); sender.Send(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Ok(post))); @@ -473,7 +474,7 @@ public void EditPostSubmitsAndNavigatesToBlogWhenSaveSucceeds() // Arrange var sender = Substitute.For(); var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + var post = new BlogPostDto(postId, "Existing title", "Existing content", string.Empty, "Alice", string.Empty, [], DateTime.UtcNow, null, false); sender.Send(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Ok(post))); @@ -664,6 +665,7 @@ private IRenderedComponent RenderWithUser( Action>? configure = null) where TComponent : IComponent { + _authProvider.SetUser(principal); return Render(parameters => { parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal))); diff --git a/tests/Web.Tests.Bunit/GlobalUsings.cs b/tests/Web.Tests.Bunit/GlobalUsings.cs index c245cc6c..d53edeec 100644 --- a/tests/Web.Tests.Bunit/GlobalUsings.cs +++ b/tests/Web.Tests.Bunit/GlobalUsings.cs @@ -20,6 +20,7 @@ global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; +global using MyBlog.Domain.ValueObjects; global using MyBlog.Web.Data; global using NSubstitute; diff --git a/tests/Web.Tests.Bunit/Testing/TestAuthenticationStateProvider.cs b/tests/Web.Tests.Bunit/Testing/TestAuthenticationStateProvider.cs new file mode 100644 index 00000000..8e45fdde --- /dev/null +++ b/tests/Web.Tests.Bunit/Testing/TestAuthenticationStateProvider.cs @@ -0,0 +1,15 @@ +namespace Web.Testing; + +internal sealed class TestAuthenticationStateProvider : AuthenticationStateProvider +{ + private AuthenticationState _state = new(new ClaimsPrincipal()); + + public void SetUser(ClaimsPrincipal principal) + { + _state = new AuthenticationState(principal); + NotifyAuthenticationStateChanged(Task.FromResult(_state)); + } + + public override Task GetAuthenticationStateAsync() => + Task.FromResult(_state); +} diff --git a/tests/Web.Tests.Integration/BlogPosts/MongoDbBlogPostRepositoryTests.cs b/tests/Web.Tests.Integration/BlogPosts/MongoDbBlogPostRepositoryTests.cs index 3cf24dc7..324ad6b6 100644 --- a/tests/Web.Tests.Integration/BlogPosts/MongoDbBlogPostRepositoryTests.cs +++ b/tests/Web.Tests.Integration/BlogPosts/MongoDbBlogPostRepositoryTests.cs @@ -23,7 +23,7 @@ public async Task AddAsync_persists_post_to_MongoDB() // Arrange var ct = TestContext.Current.CancellationToken; var repo = CreateRepo(); - var post = BlogPost.Create("Hello World", "Some content", "Author A"); + var post = BlogPost.Create("Hello World", "Some content", new PostAuthor(string.Empty, "Author A", string.Empty, [])); // Act await repo.AddAsync(post, ct); @@ -55,7 +55,7 @@ public async Task GetByIdAsync_returns_post_when_found() // Arrange var ct = TestContext.Current.CancellationToken; var repo = CreateRepo(); - var post = BlogPost.Create("My Title", "My Content", "My Author"); + var post = BlogPost.Create("My Title", "My Content", new PostAuthor(string.Empty, "My Author", string.Empty, [])); await repo.AddAsync(post, ct); // Act @@ -64,7 +64,7 @@ public async Task GetByIdAsync_returns_post_when_found() // Assert result.Should().NotBeNull(); result!.Title.Should().Be("My Title"); - result.Author.Should().Be("My Author"); + result.Author.Name.Should().Be("My Author"); result.Content.Should().Be("My Content"); } @@ -74,12 +74,12 @@ public async Task GetAllAsync_returns_posts_ordered_by_newest_first() // Arrange var ct = TestContext.Current.CancellationToken; var repo = CreateRepo(); - var older = BlogPost.Create("Older Post", "Content", "Author"); + var older = BlogPost.Create("Older Post", "Content", new PostAuthor(string.Empty, "Author", string.Empty, [])); await repo.AddAsync(older, ct); await Task.Delay(20, ct); - var newer = BlogPost.Create("Newer Post", "Content", "Author"); + var newer = BlogPost.Create("Newer Post", "Content", new PostAuthor(string.Empty, "Author", string.Empty, [])); await repo.AddAsync(newer, ct); // Act @@ -97,7 +97,7 @@ public async Task UpdateAsync_modifies_post_in_MongoDB() // Arrange var ct = TestContext.Current.CancellationToken; var repo = CreateRepo(); - var post = BlogPost.Create("Original Title", "Original Content", "Author"); + var post = BlogPost.Create("Original Title", "Original Content", new PostAuthor(string.Empty, "Author", string.Empty, [])); await repo.AddAsync(post, ct); post.Update("Updated Title", "Updated Content"); @@ -117,7 +117,7 @@ public async Task DeleteAsync_removes_post_from_MongoDB() // Arrange var ct = TestContext.Current.CancellationToken; var repo = CreateRepo(); - var post = BlogPost.Create("To Delete", "Content", "Author"); + var post = BlogPost.Create("To Delete", "Content", new PostAuthor(string.Empty, "Author", string.Empty, [])); await repo.AddAsync(post, ct); // Act @@ -164,7 +164,7 @@ public async Task UpdateAsync_throws_when_version_conflicts_with_concurrent_upda var dbName = $"T{Guid.NewGuid():N}"; var repo1 = CreateRepo(dbName); var repo2 = CreateRepo(dbName); - var post = BlogPost.Create("Original", "Content", "Author"); + var post = BlogPost.Create("Original", "Content", new PostAuthor(string.Empty, "Author", string.Empty, [])); await repo1.AddAsync(post, ct); var winner = await repo2.GetByIdAsync(post.Id, ct) ?? throw new InvalidOperationException("post not found"); diff --git a/tests/Web.Tests.Integration/Caching/BlogPostCacheServiceTests.cs b/tests/Web.Tests.Integration/Caching/BlogPostCacheServiceTests.cs index adbc03be..de751ca5 100644 --- a/tests/Web.Tests.Integration/Caching/BlogPostCacheServiceTests.cs +++ b/tests/Web.Tests.Integration/Caching/BlogPostCacheServiceTests.cs @@ -17,7 +17,7 @@ public sealed class BlogPostCacheServiceTests(RedisFixture fixture) // ------------------------------------------------------------------ helpers private static BlogPostDto MakeDto(string title = "Test Post") => - new(Guid.NewGuid(), title, "Content", "Author", DateTime.UtcNow, null, true); + new(Guid.NewGuid(), title, "Content", string.Empty, "Author", string.Empty, [], DateTime.UtcNow, null, true); // ------------------------------------------------------------------ tests diff --git a/tests/Web.Tests.Integration/GlobalUsings.cs b/tests/Web.Tests.Integration/GlobalUsings.cs index 9b9fc2c1..a20b203f 100644 --- a/tests/Web.Tests.Integration/GlobalUsings.cs +++ b/tests/Web.Tests.Integration/GlobalUsings.cs @@ -12,6 +12,7 @@ global using Microsoft.EntityFrameworkCore; global using MyBlog.Domain.Entities; +global using MyBlog.Domain.ValueObjects; global using MyBlog.Web.Data; global using MyBlog.Web.Infrastructure.Caching; diff --git a/tests/Web.Tests/Behaviors/ValidationBehaviorTests.cs b/tests/Web.Tests/Behaviors/ValidationBehaviorTests.cs index 88fa4349..6a5571cb 100644 --- a/tests/Web.Tests/Behaviors/ValidationBehaviorTests.cs +++ b/tests/Web.Tests/Behaviors/ValidationBehaviorTests.cs @@ -31,7 +31,7 @@ public async Task Handle_NoValidators_CallsNext() // Act var result = await behavior.Handle( - new CreateBlogPostCommand("T", "C", "A"), next, CancellationToken.None); + new CreateBlogPostCommand("T", "C", PostAuthor.Empty), next, CancellationToken.None); // Assert result.Success.Should().BeTrue(); @@ -49,7 +49,7 @@ public async Task Handle_ValidRequest_CallsNext() // Act var result = await behavior.Handle( - new CreateBlogPostCommand("Title", "Content", "Author"), next, CancellationToken.None); + new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])), next, CancellationToken.None); // Assert result.Success.Should().BeTrue(); @@ -66,7 +66,7 @@ public async Task Handle_InvalidRequest_ReturnsValidationFailWithoutCallingNext( // Act var result = await behavior.Handle( - new CreateBlogPostCommand("", "", ""), next, CancellationToken.None); + new CreateBlogPostCommand("", "", PostAuthor.Empty), next, CancellationToken.None); // Assert result.Success.Should().BeFalse(); @@ -84,7 +84,7 @@ public async Task Handle_InvalidRequest_ErrorMessageContainsValidationDetails() // Act var result = await behavior.Handle( - new CreateBlogPostCommand("", "Content", ""), next, CancellationToken.None); + new CreateBlogPostCommand("", "Content", PostAuthor.Empty), next, CancellationToken.None); // Assert result.Error.Should().NotBeNullOrEmpty(); @@ -102,7 +102,7 @@ public async Task Handle_MultipleValidators_AllAreExecuted() // Act var result = await behavior.Handle( - new CreateBlogPostCommand("Title", "Content", "Author"), next, CancellationToken.None); + new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])), next, CancellationToken.None); // Assert result.Success.Should().BeTrue(); @@ -120,7 +120,7 @@ public async Task Handle_MultipleValidatorsOneInvalid_ReturnsFail() // Act var result = await behavior.Handle( - new CreateBlogPostCommand("", "", ""), next, CancellationToken.None); + new CreateBlogPostCommand("", "", PostAuthor.Empty), next, CancellationToken.None); // Assert result.Success.Should().BeFalse(); diff --git a/tests/Web.Tests/BlogPostTests.cs b/tests/Web.Tests/BlogPostTests.cs index 5ce24b07..55cbbe74 100644 --- a/tests/Web.Tests/BlogPostTests.cs +++ b/tests/Web.Tests/BlogPostTests.cs @@ -11,31 +11,52 @@ namespace Web; public class BlogPostTests { + private static readonly PostAuthor TestAuthor = new("test-id", "Test Author", "test@example.com", []); + [Fact] public void Create_WithValidArgs_ReturnsBlogPost() { // Arrange (none) // Act - var post = BlogPost.Create("Test Title", "Test Content", "Test Author"); + var post = BlogPost.Create("Test Title", "Test Content", TestAuthor); // Assert post.Id.Should().NotBeEmpty(); post.Title.Should().Be("Test Title"); post.Content.Should().Be("Test Content"); - post.Author.Should().Be("Test Author"); + post.Author.Name.Should().Be("Test Author"); post.IsPublished.Should().BeFalse(); post.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Theory] - [InlineData("", "content", "author")] - [InlineData("title", "", "author")] - [InlineData("title", "content", "")] - public void Create_WithBlankArgs_ThrowsArgumentException(string title, string content, string author) + [InlineData("", "content")] + [InlineData("title", "")] + public void Create_WithBlankTitleOrContent_ThrowsArgumentException(string title, string content) + { + // Arrange + var act = () => BlogPost.Create(title, content, TestAuthor); + + // Act & Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithNullAuthor_ThrowsArgumentNullException() + { + // Arrange + var act = () => BlogPost.Create("title", "content", null!); + + // Act & Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithEmptyAuthorName_ThrowsArgumentException() { // Arrange - var act = () => BlogPost.Create(title, content, author); + var act = () => BlogPost.Create("title", "content", new PostAuthor("", "", "", [])); // Act & Assert act.Should().Throw(); @@ -45,7 +66,7 @@ public void Create_WithBlankArgs_ThrowsArgumentException(string title, string co public void Update_ChangesTitle_AndContent() { // Arrange - var post = BlogPost.Create("Old Title", "Old Content", "Author"); + var post = BlogPost.Create("Old Title", "Old Content", TestAuthor); // Act post.Update("New Title", "New Content"); @@ -60,7 +81,7 @@ public void Update_ChangesTitle_AndContent() public void Publish_SetsIsPublished_True() { // Arrange - var post = BlogPost.Create("T", "C", "A"); + var post = BlogPost.Create("T", "C", TestAuthor); // Act post.Publish(); @@ -73,7 +94,7 @@ public void Publish_SetsIsPublished_True() public void Unpublish_SetsIsPublished_False() { // Arrange - var post = BlogPost.Create("T", "C", "A"); + var post = BlogPost.Create("T", "C", TestAuthor); post.Publish(); // Act diff --git a/tests/Web.Tests/Data/BlogPostMappingsTests.cs b/tests/Web.Tests/Data/BlogPostMappingsTests.cs index 798c3f3d..31d7a645 100644 --- a/tests/Web.Tests/Data/BlogPostMappingsTests.cs +++ b/tests/Web.Tests/Data/BlogPostMappingsTests.cs @@ -11,11 +11,13 @@ namespace Unit.Data; public class BlogPostMappingsTests { + private static readonly PostAuthor TestAuthor = new("test-id", "Test Author", "test@example.com", ["Author"]); + [Fact] public void ToDto_MapsAllFields_Correctly() { // Arrange - var post = BlogPost.Create("Test Title", "Test Content", "Test Author"); + var post = BlogPost.Create("Test Title", "Test Content", TestAuthor); // Act var dto = post.ToDto(); @@ -24,7 +26,10 @@ public void ToDto_MapsAllFields_Correctly() dto.Id.Should().Be(post.Id); dto.Title.Should().Be(post.Title); dto.Content.Should().Be(post.Content); - dto.Author.Should().Be(post.Author); + dto.AuthorId.Should().Be(post.Author.Id); + dto.AuthorName.Should().Be(post.Author.Name); + dto.AuthorEmail.Should().Be(post.Author.Email); + dto.AuthorRoles.Should().BeEquivalentTo(post.Author.Roles); dto.CreatedAt.Should().Be(post.CreatedAt); } @@ -32,7 +37,7 @@ public void ToDto_MapsAllFields_Correctly() public void ToDto_UpdatedAt_IsNullOnNewPost() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); // Act var dto = post.ToDto(); @@ -45,7 +50,7 @@ public void ToDto_UpdatedAt_IsNullOnNewPost() public void ToDto_UpdatedAt_IsSetAfterUpdate() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); post.Update("New Title", "New Content"); // Act @@ -60,7 +65,7 @@ public void ToDto_UpdatedAt_IsSetAfterUpdate() public void ToDto_IsPublished_FalseOnNewPost() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); // Act var dto = post.ToDto(); @@ -73,7 +78,7 @@ public void ToDto_IsPublished_FalseOnNewPost() public void ToDto_IsPublished_TrueAfterPublish() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); post.Publish(); // Act @@ -87,7 +92,7 @@ public void ToDto_IsPublished_TrueAfterPublish() public void ToDto_IsPublished_FalseAfterUnpublish() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); post.Publish(); post.Unpublish(); diff --git a/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostCommandValidatorTests.cs b/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostCommandValidatorTests.cs index adb83b1a..734d2625 100644 --- a/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostCommandValidatorTests.cs +++ b/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostCommandValidatorTests.cs @@ -15,11 +15,13 @@ public class CreateBlogPostCommandValidatorTests { private readonly CreateBlogPostCommandValidator _sut = new(); + private static readonly PostAuthor ValidAuthor = new("id", "Valid Author", "author@example.com", []); + [Fact] public void Validate_ValidCommand_ReturnsNoErrors() { // Arrange - var command = new CreateBlogPostCommand("Valid Title", "Valid Content", "Valid Author"); + var command = new CreateBlogPostCommand("Valid Title", "Valid Content", ValidAuthor); // Act var result = _sut.Validate(command); @@ -28,14 +30,11 @@ public void Validate_ValidCommand_ReturnsNoErrors() result.IsValid.Should().BeTrue(); } - [Theory] - [InlineData("", "Content", "Author")] - [InlineData("Title", "", "Author")] - [InlineData("Title", "Content", "")] - public void Validate_MissingRequiredFields_ReturnsErrors(string title, string content, string author) + [Fact] + public void Validate_MissingTitle_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand(title, content, author); + var command = new CreateBlogPostCommand("", "Content", ValidAuthor); // Act var result = _sut.Validate(command); @@ -45,92 +44,91 @@ public void Validate_MissingRequiredFields_ReturnsErrors(string title, string co } [Fact] - public void Validate_TitleExceedsMaxLength_ReturnsError() + public void Validate_MissingContent_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand(new string('A', 201), "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "", ValidAuthor); // Act var result = _sut.Validate(command); // Assert result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.PropertyName == "Title"); } [Fact] - public void Validate_AuthorExceedsMaxLength_ReturnsError() + public void Validate_NullAuthor_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", new string('A', 101)); + var command = new CreateBlogPostCommand("Title", "Content", null!); // Act var result = _sut.Validate(command); // Assert result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.PropertyName == "Author"); } [Fact] - public void Validate_TitleAtMaxLength_ReturnsNoErrors() + public void Validate_AuthorWithEmptyName_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand(new string('A', 200), "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "", "", [])); // Act var result = _sut.Validate(command); // Assert - result.IsValid.Should().BeTrue(); + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Author.Name"); } [Fact] - public void Validate_AuthorAtMaxLength_ReturnsNoErrors() + public void Validate_TitleExceedsMaxLength_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", new string('A', 100)); + var command = new CreateBlogPostCommand(new string('A', 201), "Content", ValidAuthor); // Act var result = _sut.Validate(command); // Assert - result.IsValid.Should().BeTrue(); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.PropertyName == "Title"); } [Fact] - public void Validate_WhitespaceTitle_ReturnsError() + public void Validate_TitleAtMaxLength_ReturnsNoErrors() { // Arrange - var command = new CreateBlogPostCommand(" ", "Content", "Author"); + var command = new CreateBlogPostCommand(new string('A', 200), "Content", ValidAuthor); // Act var result = _sut.Validate(command); // Assert - result.IsValid.Should().BeFalse(); - result.Errors.Should().Contain(e => e.PropertyName == "Title"); + result.IsValid.Should().BeTrue(); } [Fact] - public void Validate_WhitespaceAuthor_ReturnsError() + public void Validate_WhitespaceTitle_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", " "); + var command = new CreateBlogPostCommand(" ", "Content", ValidAuthor); // Act var result = _sut.Validate(command); // Assert result.IsValid.Should().BeFalse(); - result.Errors.Should().Contain(e => e.PropertyName == "Author"); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); } [Fact] public void Validate_WhitespaceContent_ReturnsError() { // Arrange - var command = new CreateBlogPostCommand("Title", " ", "Author"); + var command = new CreateBlogPostCommand("Title", " ", ValidAuthor); // Act var result = _sut.Validate(command); diff --git a/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostDomainCommandValidatorTests.cs b/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostDomainCommandValidatorTests.cs index a79dcc84..c4988fe6 100644 --- a/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostDomainCommandValidatorTests.cs +++ b/tests/Web.Tests/Features/BlogPosts/Commands/CreateBlogPostDomainCommandValidatorTests.cs @@ -17,10 +17,12 @@ public class CreateBlogPostDomainCommandValidatorTests { private readonly CreateBlogPostCommandValidator _validator = new(); + private static readonly PostAuthor ValidAuthor = new("id", "Valid Author", "author@example.com", []); + [Fact] public void Validate_ValidCommand_PassesValidation() { - var command = new CreateBlogPostCommand("Valid Title", "Valid Content", "Valid Author"); + var command = new CreateBlogPostCommand("Valid Title", "Valid Content", ValidAuthor); var result = _validator.TestValidate(command); @@ -32,7 +34,7 @@ public void Validate_ValidCommand_PassesValidation() [InlineData(null!)] public void Validate_EmptyTitle_FailsValidation(string? title) { - var command = new CreateBlogPostCommand(title!, "Content", "Author"); + var command = new CreateBlogPostCommand(title!, "Content", ValidAuthor); var result = _validator.TestValidate(command); @@ -42,7 +44,7 @@ public void Validate_EmptyTitle_FailsValidation(string? title) [Fact] public void Validate_TitleExceedsMaxLength_FailsValidation() { - var command = new CreateBlogPostCommand(new string('a', 201), "Content", "Author"); + var command = new CreateBlogPostCommand(new string('a', 201), "Content", ValidAuthor); var result = _validator.TestValidate(command); @@ -54,19 +56,17 @@ public void Validate_TitleExceedsMaxLength_FailsValidation() [InlineData(null!)] public void Validate_EmptyContent_FailsValidation(string? content) { - var command = new CreateBlogPostCommand("Title", content!, "Author"); + var command = new CreateBlogPostCommand("Title", content!, ValidAuthor); var result = _validator.TestValidate(command); result.ShouldHaveValidationErrorFor(x => x.Content); } - [Theory] - [InlineData("")] - [InlineData(null!)] - public void Validate_EmptyAuthor_FailsValidation(string? author) + [Fact] + public void Validate_NullAuthor_FailsValidation() { - var command = new CreateBlogPostCommand("Title", "Content", author!); + var command = new CreateBlogPostCommand("Title", "Content", null!); var result = _validator.TestValidate(command); @@ -74,12 +74,12 @@ public void Validate_EmptyAuthor_FailsValidation(string? author) } [Fact] - public void Validate_AuthorExceedsMaxLength_FailsValidation() + public void Validate_AuthorWithEmptyName_FailsValidation() { - var command = new CreateBlogPostCommand("Title", "Content", new string('a', 101)); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "", "", [])); var result = _validator.TestValidate(command); - result.ShouldHaveValidationErrorFor(x => x.Author); + result.ShouldHaveValidationErrorFor(x => x.Author.Name); } } diff --git a/tests/Web.Tests/GlobalUsings.cs b/tests/Web.Tests/GlobalUsings.cs index 88d19831..de0a1d8c 100644 --- a/tests/Web.Tests/GlobalUsings.cs +++ b/tests/Web.Tests/GlobalUsings.cs @@ -18,6 +18,7 @@ global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; +global using MyBlog.Domain.ValueObjects; global using MyBlog.Web.Data; global using MyBlog.Web.Infrastructure.Caching; diff --git a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs index 501c9419..7506189f 100644 --- a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs @@ -26,7 +26,7 @@ public CreateBlogPostHandlerTests() public async Task Handle_Success_CreatesPostInvalidatesCacheAndReturnsGuid() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); // Act var result = await _handler.Handle(command, CancellationToken.None); @@ -42,7 +42,7 @@ public async Task Handle_Success_CreatesPostInvalidatesCacheAndReturnsGuid() public async Task Handle_RepoThrows_ReturnsFailResult() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); _repo.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("insert failed")); @@ -58,7 +58,7 @@ public async Task Handle_RepoThrows_ReturnsFailResult() public async Task Handle_Success_DoesNotCallInvalidateById() { // Arrange — create should only bust the "all" list, not a specific post key - var command = new CreateBlogPostCommand("Title", "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); // Act var result = await _handler.Handle(command, CancellationToken.None); @@ -73,7 +73,7 @@ public async Task Handle_Success_DoesNotCallInvalidateById() public async Task Handle_OperationCanceled_Rethrows() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); _repo.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new OperationCanceledException()); @@ -88,7 +88,7 @@ public async Task Handle_OperationCanceled_Rethrows() public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() { // Arrange - var command = new CreateBlogPostCommand("Title", "Content", "Author"); + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); _repo.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new TimeoutException("db timeout")); diff --git a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs index 60cb51ce..e27cc41f 100644 --- a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -31,7 +31,7 @@ public EditBlogPostHandlerTests() public async Task HandleEdit_Success_UpdatesPostAndInvalidatesBothCaches() { // Arrange - var post = BlogPost.Create("Old Title", "Old Content", "Author"); + var post = BlogPost.Create("Old Title", "Old Content", new PostAuthor("", "Test Author", "", [])); var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); @@ -71,7 +71,7 @@ public async Task HandleGetById_L1CacheHit_ReturnsCachedDtoWithoutRepo() { // Arrange var id = Guid.NewGuid(); - var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); _cache.GetOrFetchByIdAsync( Arg.Any(), Arg.Any>>(), @@ -116,7 +116,7 @@ public async Task HandleGetById_CacheMissRepoReturnsNull_ReturnsOkWithNull() public async Task HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndPopulatesCache() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); _cache.GetOrFetchByIdAsync( Arg.Any(), @@ -141,7 +141,7 @@ public async Task HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndPopulatesCa public async Task HandleEdit_ConcurrentUpdate_ReturnsConcurrencyErrorCode() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); _repo.UpdateAsync(Arg.Any(), Arg.Any()) @@ -178,7 +178,7 @@ public async Task HandleGetById_CacheServiceThrows_ReturnsFailResult() public async Task HandleEdit_OperationCanceled_Rethrows() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); _repo.GetByIdAsync(post.Id, Arg.Any()) .ThrowsAsync(new OperationCanceledException()); @@ -194,7 +194,7 @@ public async Task HandleEdit_OperationCanceled_Rethrows() public async Task HandleEdit_UnexpectedException_ReturnsUnexpectedErrorResult() { // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); + var post = BlogPost.Create("Title", "Content", new PostAuthor("", "Test Author", "", [])); var command = new EditBlogPostCommand(post.Id, "New Title", "New Content"); _repo.GetByIdAsync(post.Id, Arg.Any()) .ThrowsAsync(new TimeoutException("db timeout")); diff --git a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs index d4b7fcfa..ae765e64 100644 --- a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs +++ b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs @@ -24,8 +24,8 @@ public GetBlogPostsHandlerTests() private static List MakeDtos() => [ - new(Guid.NewGuid(), "T1", "C1", "A1", DateTime.UtcNow, null, false), - new(Guid.NewGuid(), "T2", "C2", "A2", DateTime.UtcNow, null, true), + new(Guid.NewGuid(), "T1", "C1", string.Empty, "A1", string.Empty, [], DateTime.UtcNow, null, false), + new(Guid.NewGuid(), "T2", "C2", string.Empty, "A2", string.Empty, [], DateTime.UtcNow, null, true), ]; [Fact] @@ -70,8 +70,8 @@ public async Task Handle_L2CacheHit_ReturnsCachedDataWithoutCallingRepo() public async Task Handle_CacheMiss_CallsRepoAndPopulatesBothCaches() { // Arrange - var post1 = BlogPost.Create("T1", "C1", "A1"); - var post2 = BlogPost.Create("T2", "C2", "A2"); + var post1 = BlogPost.Create("T1", "C1", new PostAuthor("", "Test Author", "", [])); + var post2 = BlogPost.Create("T2", "C2", new PostAuthor("", "Test Author", "", [])); _repo.GetAllAsync(Arg.Any()) .Returns(new List { post1, post2 }); _cache.GetOrFetchAllAsync( diff --git a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs index 1cd2bf2b..4a447422 100644 --- a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs +++ b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs @@ -30,8 +30,8 @@ public BlogPostCacheServiceTests() private static List MakeDtos() => [ - new(Guid.NewGuid(), "Title1", "Content1", "Author1", DateTime.UtcNow, null, false), - new(Guid.NewGuid(), "Title2", "Content2", "Author2", DateTime.UtcNow, null, true), + new(Guid.NewGuid(), "Title1", "Content1", string.Empty, "Author1", string.Empty, [], DateTime.UtcNow, null, false), + new(Guid.NewGuid(), "Title2", "Content2", string.Empty, "Author2", string.Empty, [], DateTime.UtcNow, null, true), ]; // ── GetOrFetchAllAsync ──────────────────────────────────────────────── @@ -139,7 +139,7 @@ public async Task GetOrFetchByIdAsync_L1Hit_ReturnsCachedDtoWithoutDistributedCa // Arrange var id = Guid.NewGuid(); var key = BlogPostCacheKeys.ById(id); - var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); _realLocalCache.Set(key, dto); // Act @@ -157,7 +157,7 @@ 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 dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); var bytes = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOpts); _distributedCache.GetAsync(key, Arg.Any()) .Returns(Task.FromResult(bytes)); @@ -187,7 +187,7 @@ public async Task GetOrFetchByIdAsync_L2JsonCorrupt_RemovesAndFallsThroughToFetc Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); - var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); var fetchCalled = false; Task fetch() { fetchCalled = true; return Task.FromResult(dto); } @@ -212,7 +212,7 @@ public async Task GetOrFetchByIdAsync_FullMiss_FetchesAndPopulatesBothTiers() Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); - var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); // Act var result = await _sut.GetOrFetchByIdAsync(id, () => Task.FromResult(dto), CancellationToken.None); @@ -270,7 +270,7 @@ 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); + var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false); _realLocalCache.Set(key, dto); _distributedCache.RemoveAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask);