Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
15 changes: 12 additions & 3 deletions src/AppHost/MongoDbResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,23 @@ private static void WithSeedDataCommand(
var collection = database.GetCollection<BsonDocument>("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()
{
["_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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
33 changes: 29 additions & 4 deletions src/Web/Features/BlogPosts/Create/Create.razor
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
<label class="form-label">Content</label>
<InputTextArea class="form-input" rows="8" @bind-Value="_model.Content" />
</div>
<div class="form-group">
<label class="form-label flex items-center gap-2">
<InputCheckbox @bind-Value="_model.IsPublished" />
<span>Published</span>
</label>
</div>

<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
Expand All @@ -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";
Comment on lines +64 to +72
_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)}";
}
Comment on lines +64 to +79
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
Expand All @@ -77,5 +100,7 @@

[System.ComponentModel.DataAnnotations.Required]
public string Content { get; set; } = string.Empty;

public bool IsPublished { get; set; }
}
}
6 changes: 5 additions & 1 deletion src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<Guid>>;
5 changes: 5 additions & 0 deletions src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public async Task<Result<Guid>> 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<Guid>(post.Id);
Expand Down
28 changes: 25 additions & 3 deletions src/Web/Features/BlogPosts/Edit/Edit.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/blog/edit/{Id:guid}"
@using System.Security.Claims
@inject ISender Sender
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
Expand Down Expand Up @@ -45,6 +46,12 @@ else if (_model is not null)
<label class="form-label">Content</label>
<InputTextArea class="form-input" rows="8" @bind-Value="_model.Content" />
</div>
<div class="form-group">
<label class="form-label flex items-center gap-2">
<InputCheckbox @bind-Value="_model.IsPublished" />
<span>Published</span>
</label>
</div>

<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
Expand Down Expand Up @@ -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");
Comment on lines 87 to 92
var isAuthor = result.Value.AuthorId == _callerUserId;

Expand All @@ -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
{
Expand All @@ -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
Expand All @@ -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; }
}
}
3 changes: 2 additions & 1 deletion src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ internal sealed record EditBlogPostCommand(
string Title,
string Content,
string CallerUserId,
bool CallerIsAdmin) : IRequest<Result>;
bool CallerIsAdmin,
bool? IsPublished = null) : IRequest<Result>;
9 changes: 9 additions & 0 deletions src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ public async Task<Result> 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);
Expand Down
36 changes: 36 additions & 0 deletions tests/Web.Tests/Handlers/CreateBlogPostHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlogPost>(post => persistedPost = post), Arg.Any<CancellationToken>())
.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<BlogPost>(post => persistedPost = post), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);

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

// Assert
result.Success.Should().BeTrue();
persistedPost.Should().NotBeNull();
persistedPost!.IsPublished.Should().BeFalse();
}
}
35 changes: 35 additions & 0 deletions tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>()).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<CancellationToken>()).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()
{
Expand Down
Loading