diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index bd542dbe..1f1dbdff 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -10,7 +10,7 @@ var builder = DistributedApplication.CreateBuilder(args); var mongo = builder.AddMongoDB("mongodb") - .WithDataVolume("mongo-data"); + .WithDataVolume("mongo-data").WithMongoExpress(); var mongoDb = mongo.AddDatabase("myblog"); var redis = builder.AddRedis("redis"); diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index 3f64b163..504b9f1c 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -210,6 +210,15 @@ private static void WithSeedDataCommand( var collection = database.GetCollection("blogposts"); var now = DateTime.UtcNow; + var authorId = "auth0|author-matthew-paulosky"; + var authorDocument = new BsonDocument + { + ["AuthorId"] = authorId, + ["AuthorName"] = "Matthew Paulosky", + ["AuthorEmail"] = "matthew@paulosky.com", + ["AuthorRoles"] = new BsonArray { "Author", "Admin" } + }; + var seedDocuments = new BsonDocument[] { new() @@ -217,7 +226,7 @@ private static void WithSeedDataCommand( ["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), ["Title"] = "Welcome to MyBlog", ["Content"] = "This is the first post on MyBlog. Welcome!", -["Author"] = "Matthew Paulosky", +["Author"] = authorDocument.DeepClone(), ["CreatedAt"] = now, ["UpdatedAt"] = now, ["IsPublished"] = true, @@ -228,7 +237,7 @@ private static void WithSeedDataCommand( ["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), ["Title"] = "Getting Started with .NET Aspire", ["Content"] = "Learn how to build cloud-native apps with .NET Aspire.", -["Author"] = "Matthew Paulosky", +["Author"] = authorDocument.DeepClone(), ["CreatedAt"] = now, ["UpdatedAt"] = now, ["IsPublished"] = true, @@ -239,7 +248,7 @@ private static void WithSeedDataCommand( ["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), ["Title"] = "Draft: MongoDB Performance Tips", ["Content"] = "Work in progress — tips for optimising MongoDB queries.", -["Author"] = "Matthew Paulosky", +["Author"] = authorDocument.DeepClone(), ["CreatedAt"] = now, ["UpdatedAt"] = now, ["IsPublished"] = false, diff --git a/src/Web/Features/BlogPosts/Create/Create.razor b/src/Web/Features/BlogPosts/Create/Create.razor index 4f9f8965..8d69de21 100644 --- a/src/Web/Features/BlogPosts/Create/Create.razor +++ b/src/Web/Features/BlogPosts/Create/Create.razor @@ -33,6 +33,12 @@ +
+ +
@@ -54,16 +60,33 @@ { 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; + + // Extract nameidentifier claim for AuthorId, with fallbacks for Development/Testing + _authorId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value + ?? user.Identity?.Name + ?? "dev-user"; + _authorName = user.FindFirst("name")?.Value ?? user.Identity?.Name ?? "Author"; + _authorEmail = user.FindFirst(ClaimTypes.Email)?.Value + ?? user.FindFirst("email")?.Value + ?? "no-email@example.com"; _authorRoles = RoleClaimsHelper.GetRoles(user); + + // If still empty, generate sensible defaults for Development mode + if (string.IsNullOrWhiteSpace(_authorId)) + { + _authorId = $"auth0|dev-{Guid.NewGuid().ToString().Substring(0, 8)}"; + } + if (string.IsNullOrWhiteSpace(_authorName)) + { + _authorName = "Author"; + } } private async Task HandleSubmit() { var author = new PostAuthor(_authorId, _authorName, _authorEmail, _authorRoles); - var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, author)); + var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, author, _model.IsPublished)); if (result.Success) Navigation.NavigateTo("/blog"); else @@ -77,5 +100,7 @@ [System.ComponentModel.DataAnnotations.Required] public string Content { get; set; } = string.Empty; + + public bool IsPublished { get; set; } } } diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs index c63f550f..562e190c 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs @@ -12,5 +12,9 @@ namespace MyBlog.Web.Features.BlogPosts.Create; -internal sealed record CreateBlogPostCommand(string Title, string Content, PostAuthor Author) +internal sealed record CreateBlogPostCommand( + string Title, + string Content, + PostAuthor Author, + bool IsPublished = false) : IRequest>; diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs index bc0cafbe..9d133025 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs @@ -21,6 +21,11 @@ public async Task> Handle(CreateBlogPostCommand request, Cancellati try { var post = BlogPost.Create(request.Title, request.Content, request.Author); + if (request.IsPublished) + { + post.Publish(); + } + await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); return Result.Ok(post.Id); diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index a5a6ae5b..52b52bb3 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -1,4 +1,5 @@ @page "/blog/edit/{Id:guid}" +@using System.Security.Claims @inject ISender Sender @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthStateProvider @@ -45,6 +46,12 @@ else if (_model is not null)
+
+ +
@@ -79,7 +86,9 @@ else if (_model is not null) { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var user = authState.User; - _callerUserId = user.FindFirst("sub")?.Value ?? string.Empty; + _callerUserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value + ?? string.Empty; _callerIsAdmin = user.IsInRole("Admin"); var isAuthor = result.Value.AuthorId == _callerUserId; @@ -89,7 +98,12 @@ else if (_model is not null) return; } - _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; + _model = new PostFormModel + { + Title = result.Value.Title, + Content = result.Value.Content, + IsPublished = result.Value.IsPublished + }; } else { @@ -111,7 +125,13 @@ else if (_model is not null) private async Task HandleSubmit() { if (_model is null) return; - var result = await Sender.Send(new EditBlogPostCommand(Id, _model.Title, _model.Content, _callerUserId, _callerIsAdmin)); + var result = await Sender.Send(new EditBlogPostCommand( + Id, + _model.Title, + _model.Content, + _callerUserId, + _callerIsAdmin, + _model.IsPublished)); if (result.Success) Navigation.NavigateTo("/blog"); else @@ -131,5 +151,7 @@ else if (_model is not null) [System.ComponentModel.DataAnnotations.Required] public string Content { get; set; } = string.Empty; + + public bool IsPublished { get; set; } } } diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs index 1abd02a6..f2fbce94 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs @@ -16,4 +16,5 @@ internal sealed record EditBlogPostCommand( string Title, string Content, string CallerUserId, - bool CallerIsAdmin) : IRequest; + bool CallerIsAdmin, + bool? IsPublished = null) : IRequest; diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs index 0e23e549..6014989b 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs @@ -30,6 +30,15 @@ public async Task Handle(EditBlogPostCommand request, CancellationToken return Result.Fail("You are not authorized to edit this post.", ResultErrorCode.Unauthorized); post.Update(request.Title, request.Content); + if (request.IsPublished is true) + { + post.Publish(); + } + else if (request.IsPublished is false) + { + post.Unpublish(); + } + await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); diff --git a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs index 7506189f..21000a60 100644 --- a/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs @@ -99,4 +99,40 @@ public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() result.Failure.Should().BeTrue(); result.Error.Should().Be("An unexpected error occurred."); } + + [Fact] + public async Task Handle_IsPublishedTrue_PersistsPublishedPost() + { + // Arrange + BlogPost? persistedPost = null; + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", []), true); + _repo.AddAsync(Arg.Do(post => persistedPost = post), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + persistedPost.Should().NotBeNull(); + persistedPost!.IsPublished.Should().BeTrue(); + } + + [Fact] + public async Task Handle_DefaultIsPublishedFalse_PersistsUnpublishedPost() + { + // Arrange + BlogPost? persistedPost = null; + var command = new CreateBlogPostCommand("Title", "Content", new PostAuthor("", "Author", "", [])); + _repo.AddAsync(Arg.Do(post => persistedPost = post), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + persistedPost.Should().NotBeNull(); + persistedPost!.IsPublished.Should().BeFalse(); + } } diff --git a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs index c8c110b0..cafabe88 100644 --- a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -63,6 +63,41 @@ public async Task HandleEdit_AdminCanEditAnyPost_ReturnsSuccess() result.Success.Should().BeTrue(); } + [Fact] + public async Task HandleEdit_IsPublishedTrue_PublishesPost() + { + // Arrange + var authorId = "auth0|author1"; + var post = BlogPost.Create("Title", "Content", new PostAuthor(authorId, "Author 1", "", [])); + var command = new EditBlogPostCommand(post.Id, "Updated Title", "Updated Content", authorId, false, true); + _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + post.IsPublished.Should().BeTrue(); + } + + [Fact] + public async Task HandleEdit_IsPublishedFalse_UnpublishesPost() + { + // Arrange + var authorId = "auth0|author1"; + var post = BlogPost.Create("Title", "Content", new PostAuthor(authorId, "Author 1", "", [])); + post.Publish(); + var command = new EditBlogPostCommand(post.Id, "Updated Title", "Updated Content", authorId, false, false); + _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + post.IsPublished.Should().BeFalse(); + } + [Fact] public async Task HandleEdit_DifferentNonAdminUser_ReturnsUnauthorized() {