diff --git a/src/Web/Data/BlogDbContext.cs b/src/Web/Data/BlogDbContext.cs index 75f170f4..ce84e3b2 100644 --- a/src/Web/Data/BlogDbContext.cs +++ b/src/Web/Data/BlogDbContext.cs @@ -1,18 +1,16 @@ -using Microsoft.EntityFrameworkCore; using MongoDB.EntityFrameworkCore.Extensions; -using MyBlog.Domain.Entities; namespace MyBlog.Web.Data; public sealed class BlogDbContext(DbContextOptions options) : DbContext(options) { - public DbSet BlogPosts => Set(); + public DbSet BlogPosts => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var entity = modelBuilder.Entity(); - entity.ToCollection("blogposts"); - entity.HasKey(p => p.Id); - entity.Property(p => p.Version).IsConcurrencyToken(); - } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entity = modelBuilder.Entity(); + entity.ToCollection("blogposts"); + entity.HasKey(p => p.Id); + entity.Property(p => p.Version).IsConcurrencyToken(); + } } diff --git a/src/Web/Data/BlogPostMappings.cs b/src/Web/Data/BlogPostMappings.cs index 076eb372..20073ef5 100644 --- a/src/Web/Data/BlogPostMappings.cs +++ b/src/Web/Data/BlogPostMappings.cs @@ -1,10 +1,8 @@ -using MyBlog.Domain.Entities; - 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.CreatedAt, post.UpdatedAt, post.IsPublished); + internal static BlogPostDto ToDto(this BlogPost post) => new( + post.Id, post.Title, post.Content, post.Author, + post.CreatedAt, post.UpdatedAt, post.IsPublished); } diff --git a/src/Web/Data/MongoDbBlogPostRepository.cs b/src/Web/Data/MongoDbBlogPostRepository.cs index 3421dcf3..f8c3ff7e 100644 --- a/src/Web/Data/MongoDbBlogPostRepository.cs +++ b/src/Web/Data/MongoDbBlogPostRepository.cs @@ -1,53 +1,49 @@ -using Microsoft.EntityFrameworkCore; -using MyBlog.Domain.Entities; -using MyBlog.Domain.Interfaces; - namespace MyBlog.Web.Data; public sealed class MongoDbBlogPostRepository(IDbContextFactory contextFactory) - : IBlogPostRepository + : IBlogPostRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - return await ctx.BlogPosts.AsNoTracking() - .FirstOrDefaultAsync(p => p.Id == id, ct); - } + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + await using var ctx = await contextFactory.CreateDbContextAsync(ct); + return await ctx.BlogPosts.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id, ct); + } - public async Task> GetAllAsync(CancellationToken ct = default) - { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - return await ctx.BlogPosts.AsNoTracking() - .OrderByDescending(p => p.CreatedAt) - .ToListAsync(ct); - } + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var ctx = await contextFactory.CreateDbContextAsync(ct); + return await ctx.BlogPosts.AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(ct); + } - public async Task AddAsync(BlogPost post, CancellationToken ct = default) - { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - await ctx.BlogPosts.AddAsync(post, ct); - await ctx.SaveChangesAsync(ct); - } + public async Task AddAsync(BlogPost post, CancellationToken ct = default) + { + await using var ctx = await contextFactory.CreateDbContextAsync(ct); + await ctx.BlogPosts.AddAsync(post, ct); + await ctx.SaveChangesAsync(ct); + } - public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) - { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - var entry = ctx.Attach(post); - // Version was incremented by post.Update(); the original value in the DB is Version - 1. - // EF Core uses OriginalValue in the WHERE filter to detect concurrent modifications. - entry.Property(p => p.Version).OriginalValue = post.Version - 1; - entry.State = EntityState.Modified; - await ctx.SaveChangesAsync(ct); - } + public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) + { + await using var ctx = await contextFactory.CreateDbContextAsync(ct); + var entry = ctx.Attach(post); + // Version was incremented by post.Update(); the original value in the DB is Version - 1. + // EF Core uses OriginalValue in the WHERE filter to detect concurrent modifications. + entry.Property(p => p.Version).OriginalValue = post.Version - 1; + entry.State = EntityState.Modified; + await ctx.SaveChangesAsync(ct); + } - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - var post = await ctx.BlogPosts.FindAsync([id], ct); - if (post is not null) - { - ctx.BlogPosts.Remove(post); - await ctx.SaveChangesAsync(ct); - } - } + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + await using var ctx = await contextFactory.CreateDbContextAsync(ct); + var post = await ctx.BlogPosts.FindAsync([id], ct); + if (post is not null) + { + ctx.BlogPosts.Remove(post); + await ctx.SaveChangesAsync(ct); + } + } } diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs index 9bd07bf3..bf4600d3 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs @@ -1,7 +1,4 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Create; public sealed record CreateBlogPostCommand(string Title, string Content, string Author) - : IRequest>; + : IRequest>; diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs index 05966256..3d2514c6 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs @@ -1,30 +1,23 @@ -using MediatR; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Entities; -using MyBlog.Domain.Interfaces; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Create; public sealed class CreateBlogPostHandler( - IBlogPostRepository repo, - IMemoryCache localCache, - IDistributedCache distributedCache) : IRequestHandler> + IBlogPostRepository repo, + IMemoryCache localCache, + IDistributedCache distributedCache) : IRequestHandler> { - public async Task> 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(post.Id); - } - catch (Exception ex) - { - return Result.Fail(ex.Message); - } - } + public async Task> 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(post.Id); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } } diff --git a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostCommand.cs b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostCommand.cs index 539ef0cb..1b09c5b2 100644 --- a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostCommand.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Delete; public sealed record DeleteBlogPostCommand(Guid Id) : IRequest; diff --git a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs index 08d06688..3335db8f 100644 --- a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs @@ -1,37 +1,30 @@ -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Interfaces; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Delete; public sealed class DeleteBlogPostHandler( - IBlogPostRepository repo, - IMemoryCache localCache, - IDistributedCache distributedCache) : IRequestHandler + IBlogPostRepository repo, + IMemoryCache localCache, + IDistributedCache distributedCache) : IRequestHandler { - public async Task 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 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); + } + } } diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs index 3ba133e6..b055c140 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostCommand.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Edit; public sealed record EditBlogPostCommand(Guid Id, string Title, string Content) : IRequest; diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs index 3ff27b01..f6fd6a09 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs @@ -1,81 +1,74 @@ using System.Text.Json; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Interfaces; -using MyBlog.Web.Data; -using Domain.Abstractions; namespace MyBlog.Web.Features.BlogPosts.Edit; public sealed class EditBlogPostHandler( - IBlogPostRepository repo, - IMemoryCache localCache, - IDistributedCache distributedCache) - : IRequestHandler, - IRequestHandler> + IBlogPostRepository repo, + IMemoryCache localCache, + IDistributedCache distributedCache) + : IRequestHandler, + IRequestHandler> { - private static readonly MemoryCacheEntryOptions LocalOpts = - new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(1)); - private static readonly DistributedCacheEntryOptions RedisOpts = - new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + private static readonly MemoryCacheEntryOptions LocalOpts = + new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(1)); + private static readonly DistributedCacheEntryOptions RedisOpts = + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - public async Task Handle(EditBlogPostCommand request, CancellationToken ct) - { - try - { - var post = await repo.GetByIdAsync(request.Id, ct); - if (post is null) - return Result.Fail($"BlogPost {request.Id} not found."); - post.Update(request.Title, request.Content); - await repo.UpdateAsync(post, 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 Handle(EditBlogPostCommand request, CancellationToken ct) + { + try + { + var post = await repo.GetByIdAsync(request.Id, ct); + if (post is null) + return Result.Fail($"BlogPost {request.Id} not found."); + post.Update(request.Title, request.Content); + await repo.UpdateAsync(post, 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> Handle(GetBlogPostByIdQuery request, CancellationToken ct) - { - try - { - var key = $"blog:{request.Id}"; - if (localCache.TryGetValue(key, out BlogPostDto? cached) && cached is not null) - return Result.Ok(cached); + public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken ct) + { + try + { + var key = $"blog:{request.Id}"; + if (localCache.TryGetValue(key, out BlogPostDto? cached) && cached is not null) + return Result.Ok(cached); - var bytes = await distributedCache.GetAsync(key, ct); - if (bytes is not null) - { - var dto = JsonSerializer.Deserialize(bytes, JsonOpts)!; - localCache.Set(key, dto, LocalOpts); - return Result.Ok(dto); - } + var bytes = await distributedCache.GetAsync(key, ct); + if (bytes is not null) + { + var dto = JsonSerializer.Deserialize(bytes, JsonOpts)!; + localCache.Set(key, dto, LocalOpts); + return Result.Ok(dto); + } - var post = await repo.GetByIdAsync(request.Id, ct); - if (post is null) return Result.Ok(null); - var result = post.ToDto(); - localCache.Set(key, result, LocalOpts); - await distributedCache.SetAsync(key, - JsonSerializer.SerializeToUtf8Bytes(result, JsonOpts), RedisOpts, ct); - return Result.Ok(result); - } - catch (Exception ex) - { - return Result.Fail(ex.Message); - } - } + var post = await repo.GetByIdAsync(request.Id, ct); + if (post is null) return Result.Ok(null); + var result = post.ToDto(); + localCache.Set(key, result, LocalOpts); + await distributedCache.SetAsync(key, + JsonSerializer.SerializeToUtf8Bytes(result, JsonOpts), RedisOpts, ct); + return Result.Ok(result); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } } diff --git a/src/Web/Features/BlogPosts/Edit/GetBlogPostByIdQuery.cs b/src/Web/Features/BlogPosts/Edit/GetBlogPostByIdQuery.cs index a539820d..aea75887 100644 --- a/src/Web/Features/BlogPosts/Edit/GetBlogPostByIdQuery.cs +++ b/src/Web/Features/BlogPosts/Edit/GetBlogPostByIdQuery.cs @@ -1,7 +1,3 @@ -using MediatR; -using MyBlog.Web.Data; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.Edit; public sealed record GetBlogPostByIdQuery(Guid Id) : IRequest>; diff --git a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs index 2aff6f21..f25b55eb 100644 --- a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs +++ b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs @@ -1,51 +1,45 @@ using System.Text.Json; -using MediatR; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Interfaces; -using MyBlog.Web.Data; -using Domain.Abstractions; namespace MyBlog.Web.Features.BlogPosts.List; public sealed class GetBlogPostsHandler( - IBlogPostRepository repo, - IMemoryCache localCache, - IDistributedCache distributedCache) : IRequestHandler>> + IBlogPostRepository repo, + IMemoryCache localCache, + IDistributedCache distributedCache) : IRequestHandler>> { - private static readonly MemoryCacheEntryOptions LocalOpts = - new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(1)); - private static readonly DistributedCacheEntryOptions RedisOpts = - new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - private const string CacheKey = "blog:all"; + private static readonly MemoryCacheEntryOptions LocalOpts = + new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(1)); + private static readonly DistributedCacheEntryOptions RedisOpts = + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + private const string CacheKey = "blog:all"; - public async Task>> Handle( - GetBlogPostsQuery request, CancellationToken ct) - { - try - { - if (localCache.TryGetValue(CacheKey, out List? cached) && cached is not null) - return Result.Ok>(cached); + public async Task>> Handle( + GetBlogPostsQuery request, CancellationToken ct) + { + try + { + if (localCache.TryGetValue(CacheKey, out List? cached) && cached is not null) + return Result.Ok>(cached); - var bytes = await distributedCache.GetAsync(CacheKey, ct); - if (bytes is not null) - { - var fromRedis = JsonSerializer.Deserialize>(bytes, JsonOpts)!; - localCache.Set(CacheKey, fromRedis, LocalOpts); - return Result.Ok>(fromRedis); - } + var bytes = await distributedCache.GetAsync(CacheKey, ct); + if (bytes is not null) + { + var fromRedis = JsonSerializer.Deserialize>(bytes, JsonOpts)!; + localCache.Set(CacheKey, fromRedis, LocalOpts); + return Result.Ok>(fromRedis); + } - var posts = await repo.GetAllAsync(ct); - var dtos = posts.Select(p => p.ToDto()).ToList(); - localCache.Set(CacheKey, dtos, LocalOpts); - await distributedCache.SetAsync(CacheKey, - JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts), RedisOpts, ct); - return Result.Ok>(dtos); - } - catch (Exception ex) - { - return Result.Fail>(ex.Message); - } - } + var posts = await repo.GetAllAsync(ct); + var dtos = posts.Select(p => p.ToDto()).ToList(); + localCache.Set(CacheKey, dtos, LocalOpts); + await distributedCache.SetAsync(CacheKey, + JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts), RedisOpts, ct); + return Result.Ok>(dtos); + } + catch (Exception ex) + { + return Result.Fail>(ex.Message); + } + } } diff --git a/src/Web/Features/BlogPosts/List/GetBlogPostsQuery.cs b/src/Web/Features/BlogPosts/List/GetBlogPostsQuery.cs index 9148b25b..45afad5e 100644 --- a/src/Web/Features/BlogPosts/List/GetBlogPostsQuery.cs +++ b/src/Web/Features/BlogPosts/List/GetBlogPostsQuery.cs @@ -1,7 +1,3 @@ -using MediatR; -using MyBlog.Web.Data; -using Domain.Abstractions; - namespace MyBlog.Web.Features.BlogPosts.List; public sealed record GetBlogPostsQuery : IRequest>>; diff --git a/src/Web/Features/UserManagement/AssignRoleCommand.cs b/src/Web/Features/UserManagement/AssignRoleCommand.cs index de09082e..ddedcc35 100644 --- a/src/Web/Features/UserManagement/AssignRoleCommand.cs +++ b/src/Web/Features/UserManagement/AssignRoleCommand.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.UserManagement; public sealed record AssignRoleCommand(string UserId, string RoleId) : IRequest; diff --git a/src/Web/Features/UserManagement/GetAvailableRolesQuery.cs b/src/Web/Features/UserManagement/GetAvailableRolesQuery.cs index 07c36668..d978cb5f 100644 --- a/src/Web/Features/UserManagement/GetAvailableRolesQuery.cs +++ b/src/Web/Features/UserManagement/GetAvailableRolesQuery.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.UserManagement; public sealed record GetAvailableRolesQuery : IRequest>>; diff --git a/src/Web/Features/UserManagement/GetUsersWithRolesQuery.cs b/src/Web/Features/UserManagement/GetUsersWithRolesQuery.cs index ec7d3861..5216e448 100644 --- a/src/Web/Features/UserManagement/GetUsersWithRolesQuery.cs +++ b/src/Web/Features/UserManagement/GetUsersWithRolesQuery.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.UserManagement; public sealed record GetUsersWithRolesQuery : IRequest>>; diff --git a/src/Web/Features/UserManagement/RemoveRoleCommand.cs b/src/Web/Features/UserManagement/RemoveRoleCommand.cs index 9e32274f..87b665ad 100644 --- a/src/Web/Features/UserManagement/RemoveRoleCommand.cs +++ b/src/Web/Features/UserManagement/RemoveRoleCommand.cs @@ -1,6 +1,3 @@ -using MediatR; -using Domain.Abstractions; - namespace MyBlog.Web.Features.UserManagement; public sealed record RemoveRoleCommand(string UserId, string RoleId) : IRequest; diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 99ddde79..da6c7b1d 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -1,116 +1,113 @@ using Auth0.ManagementApi; using Auth0.ManagementApi.Models; using Auth0.ManagementApi.Paging; -using MediatR; -using Domain.Abstractions; -using System.Net.Http.Json; namespace MyBlog.Web.Features.UserManagement; public sealed class UserManagementHandler( - IConfiguration configuration, - IHttpClientFactory httpClientFactory) - : IRequestHandler>>, - IRequestHandler, - IRequestHandler, - IRequestHandler>> + IConfiguration configuration, + IHttpClientFactory httpClientFactory) + : IRequestHandler>>, + IRequestHandler, + IRequestHandler, + IRequestHandler>> { - public async Task>> Handle( - GetUsersWithRolesQuery request, CancellationToken ct) - { - try - { - var client = await GetManagementClientAsync(ct); - var users = await client.Users.GetAllAsync(new GetUsersRequest(), new PaginationInfo(), ct); - var result = new List(); - foreach (var user in users) - { - var roles = await client.Users.GetRolesAsync(user.UserId, new PaginationInfo(), ct); - result.Add(new UserWithRolesDto( - user.UserId, - user.Email ?? string.Empty, - user.FullName ?? user.Email ?? string.Empty, - roles.Select(r => r.Name ?? string.Empty).ToList())); - } - return Result.Ok>(result); - } - catch (Exception ex) - { - return Result.Fail>(ex.Message); - } - } + public async Task>> Handle( + GetUsersWithRolesQuery request, CancellationToken ct) + { + try + { + var client = await GetManagementClientAsync(ct); + var users = await client.Users.GetAllAsync(new GetUsersRequest(), new PaginationInfo(), ct); + var result = new List(); + foreach (var user in users) + { + var roles = await client.Users.GetRolesAsync(user.UserId, new PaginationInfo(), ct); + result.Add(new UserWithRolesDto( + user.UserId, + user.Email ?? string.Empty, + user.FullName ?? user.Email ?? string.Empty, + roles.Select(r => r.Name ?? string.Empty).ToList())); + } + return Result.Ok>(result); + } + catch (Exception ex) + { + return Result.Fail>(ex.Message); + } + } - public async Task Handle(AssignRoleCommand request, CancellationToken ct) - { - try - { - var client = await GetManagementClientAsync(ct); - await client.Users.AssignRolesAsync(request.UserId, - new AssignRolesRequest { Roles = [request.RoleId] }, ct); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail(ex.Message); - } - } + public async Task Handle(AssignRoleCommand request, CancellationToken ct) + { + try + { + var client = await GetManagementClientAsync(ct); + await client.Users.AssignRolesAsync(request.UserId, + new AssignRolesRequest { Roles = [request.RoleId] }, ct); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } - public async Task Handle(RemoveRoleCommand request, CancellationToken ct) - { - try - { - var client = await GetManagementClientAsync(ct); - await client.Users.RemoveRolesAsync(request.UserId, - new AssignRolesRequest { Roles = [request.RoleId] }, ct); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail(ex.Message); - } - } + public async Task Handle(RemoveRoleCommand request, CancellationToken ct) + { + try + { + var client = await GetManagementClientAsync(ct); + await client.Users.RemoveRolesAsync(request.UserId, + new AssignRolesRequest { Roles = [request.RoleId] }, ct); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } - public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken ct) - { - try - { - var client = await GetManagementClientAsync(ct); - var roles = await client.Roles.GetAllAsync(new GetRolesRequest(), new PaginationInfo(), ct); - return Result.Ok>( - roles.Select(r => new RoleDto(r.Id ?? string.Empty, r.Name ?? string.Empty)).ToList()); - } - catch (Exception ex) - { - return Result.Fail>(ex.Message); - } - } + public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken ct) + { + try + { + var client = await GetManagementClientAsync(ct); + var roles = await client.Roles.GetAllAsync(new GetRolesRequest(), new PaginationInfo(), ct); + return Result.Ok>( + roles.Select(r => new RoleDto(r.Id ?? string.Empty, r.Name ?? string.Empty)).ToList()); + } + catch (Exception ex) + { + return Result.Fail>(ex.Message); + } + } - private async Task GetManagementClientAsync(CancellationToken ct) - { - var domain = configuration["Auth0:ManagementApiDomain"] - ?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); - var clientId = configuration["Auth0:ManagementApiClientId"] - ?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); - var clientSecret = configuration["Auth0:ManagementApiClientSecret"] - ?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); + private async Task GetManagementClientAsync(CancellationToken ct) + { + var domain = configuration["Auth0:ManagementApiDomain"] + ?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); + var clientId = configuration["Auth0:ManagementApiClientId"] + ?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); + var clientSecret = configuration["Auth0:ManagementApiClientSecret"] + ?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); - var httpClient = httpClientFactory.CreateClient(); - var tokenResponse = await httpClient.PostAsJsonAsync( - $"https://{domain}/oauth/token", - new - { - client_id = clientId, - client_secret = clientSecret, - audience = $"https://{domain}/api/v2/", - grant_type = "client_credentials" - }, ct); - tokenResponse.EnsureSuccessStatusCode(); - var tokenData = await tokenResponse.Content.ReadFromJsonAsync(ct); - return new ManagementApiClient(tokenData!.AccessToken, domain); - } + var httpClient = httpClientFactory.CreateClient(); + var tokenResponse = await httpClient.PostAsJsonAsync( + $"https://{domain}/oauth/token", + new + { + client_id = clientId, + client_secret = clientSecret, + audience = $"https://{domain}/api/v2/", + grant_type = "client_credentials" + }, ct); + tokenResponse.EnsureSuccessStatusCode(); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(ct); + return new ManagementApiClient(tokenData!.AccessToken, domain); + } - private sealed class TokenResponse - { - public string AccessToken { get; init; } = string.Empty; - } + private sealed class TokenResponse + { + public string AccessToken { get; init; } = string.Empty; + } } diff --git a/src/Web/GlobalUsings.cs b/src/Web/GlobalUsings.cs new file mode 100644 index 00000000..28150916 --- /dev/null +++ b/src/Web/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using Domain.Abstractions; +global using MediatR; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Caching.Distributed; +global using Microsoft.Extensions.Caching.Memory; +global using MyBlog.Domain.Entities; +global using MyBlog.Domain.Interfaces; +global using MyBlog.Web.Data; diff --git a/src/Web/Program.cs b/src/Web/Program.cs index f8471afd..1689629d 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,12 +1,13 @@ +using System.Security.Claims; + using Auth0.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.EntityFrameworkCore; -using MyBlog.Domain.Interfaces; + using MyBlog.Web.Components; -using MyBlog.Web.Data; using MyBlog.Web.Security; -using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); @@ -14,7 +15,7 @@ // Razor + Blazor Server builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents(); // Auth0 authentication var auth0Domain = builder.Configuration["Auth0:Domain"]; @@ -22,40 +23,40 @@ if (string.IsNullOrEmpty(auth0Domain) || string.IsNullOrEmpty(auth0ClientId)) { - throw new InvalidOperationException( - "Auth0 configuration is missing or incomplete. Set these user secrets for the Web project:\n" + - " dotnet user-secrets set \"Auth0:Domain\" \".auth0.com\" --project src/Web\n" + - " dotnet user-secrets set \"Auth0:ClientId\" \"\" --project src/Web\n" + - " dotnet user-secrets set \"Auth0:ClientSecret\" \"\" --project src/Web"); + throw new InvalidOperationException( + "Auth0 configuration is missing or incomplete. Set these user secrets for the Web project:\n" + + " dotnet user-secrets set \"Auth0:Domain\" \".auth0.com\" --project src/Web\n" + + " dotnet user-secrets set \"Auth0:ClientId\" \"\" --project src/Web\n" + + " dotnet user-secrets set \"Auth0:ClientSecret\" \"\" --project src/Web"); } var auth0RoleClaimTypes = RoleClaimsHelper.GetRoleClaimTypes(builder.Configuration); builder.Services.AddAuth0WebAppAuthentication(opts => { - opts.Domain = auth0Domain; - opts.ClientId = auth0ClientId; - opts.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; - opts.CallbackPath = "/signin-auth0"; + opts.Domain = auth0Domain; + opts.ClientId = auth0ClientId; + opts.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; + opts.CallbackPath = "/signin-auth0"; }); builder.Services.PostConfigure(Auth0Constants.AuthenticationScheme, options => { - options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; - var existingOnTokenValidated = options.Events.OnTokenValidated; - options.Events.OnTokenValidated = async context => - { - if (existingOnTokenValidated is not null) - await existingOnTokenValidated(context); + var existingOnTokenValidated = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = async context => + { + if (existingOnTokenValidated is not null) + await existingOnTokenValidated(context); - if (context.Principal?.Identity is not ClaimsIdentity identity) - { - return; - } + if (context.Principal?.Identity is not ClaimsIdentity identity) + { + return; + } - RoleClaimsHelper.AddRoleClaims(identity, auth0RoleClaimTypes); - }; + RoleClaimsHelper.AddRoleClaims(identity, auth0RoleClaimTypes); + }; }); builder.Services.AddCascadingAuthenticationState(); @@ -65,8 +66,8 @@ builder.AddMongoDBClient("myblog"); builder.Services.AddDbContextFactory((sp, options) => { - var mongoClient = sp.GetRequiredService(); - options.UseMongoDB(mongoClient, "myblog"); + var mongoClient = sp.GetRequiredService(); + options.UseMongoDB(mongoClient, "myblog"); }); // Redis distributed cache (Aspire-managed connection "redis") @@ -78,11 +79,11 @@ // Repository: concrete + interface builder.Services.AddScoped(); builder.Services.AddScoped(sp => - sp.GetRequiredService()); + sp.GetRequiredService()); // MediatR — scans Web assembly for all handlers builder.Services.AddMediatR(cfg => - cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); // HttpClient for Auth0 Management API builder.Services.AddHttpClient(); @@ -92,8 +93,8 @@ // Configure the HTTP request pipeline if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Error", createScopeForErrors: true); - app.UseHsts(); + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); @@ -108,27 +109,27 @@ // Auth0 login/logout endpoints app.MapGet("/Account/Login", async (HttpContext ctx, string? returnUrl) => { - var safeReturn = !string.IsNullOrEmpty(returnUrl) - && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) - ? returnUrl - : "/"; - var props = new LoginAuthenticationPropertiesBuilder() - .WithRedirectUri(safeReturn) - .Build(); - await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props); + var safeReturn = !string.IsNullOrEmpty(returnUrl) + && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) + ? returnUrl + : "/"; + var props = new LoginAuthenticationPropertiesBuilder() + .WithRedirectUri(safeReturn) + .Build(); + await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props); }).AllowAnonymous(); app.MapGet("/Account/Logout", async ctx => { - var props = new LogoutAuthenticationPropertiesBuilder() - .WithRedirectUri("/") - .Build(); - await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props); - await ctx.SignOutAsync(); + var props = new LogoutAuthenticationPropertiesBuilder() + .WithRedirectUri("/") + .Build(); + await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props); + await ctx.SignOutAsync(); }).RequireAuthorization(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode(); app.MapDefaultEndpoints(); app.Run(); diff --git a/tests/Architecture.Tests/DomainLayerTests.cs b/tests/Architecture.Tests/DomainLayerTests.cs index 250145d9..963d9af9 100644 --- a/tests/Architecture.Tests/DomainLayerTests.cs +++ b/tests/Architecture.Tests/DomainLayerTests.cs @@ -1,43 +1,39 @@ -using FluentAssertions; -using MyBlog.Domain.Entities; -using NetArchTest.Rules; - namespace MyBlog.Architecture.Tests; public class DomainLayerTests { - [Fact] - public void Domain_Should_Not_Reference_Web() - { - var result = Types.InAssembly(typeof(BlogPost).Assembly) - .ShouldNot() - .HaveDependencyOnAny("MyBlog.Web", "Microsoft.AspNetCore") - .GetResult(); + [Fact] + public void Domain_Should_Not_Reference_Web() + { + var result = Types.InAssembly(typeof(BlogPost).Assembly) + .ShouldNot() + .HaveDependencyOnAny("MyBlog.Web", "Microsoft.AspNetCore") + .GetResult(); - result.IsSuccessful.Should().BeTrue(); - } + result.IsSuccessful.Should().BeTrue(); + } - [Fact] - public void Domain_Entities_Should_Be_Sealed() - { - var result = Types.InAssembly(typeof(BlogPost).Assembly) - .That() - .ResideInNamespace("MyBlog.Domain.Entities") - .Should() - .BeSealed() - .GetResult(); + [Fact] + public void Domain_Entities_Should_Be_Sealed() + { + var result = Types.InAssembly(typeof(BlogPost).Assembly) + .That() + .ResideInNamespace("MyBlog.Domain.Entities") + .Should() + .BeSealed() + .GetResult(); - result.IsSuccessful.Should().BeTrue(); - } + result.IsSuccessful.Should().BeTrue(); + } - [Fact] - public void Domain_Should_Not_Have_InMemoryRepository() - { - var types = Types.InAssembly(typeof(BlogPost).Assembly) - .That() - .HaveNameEndingWith("InMemory") - .GetTypes(); + [Fact] + public void Domain_Should_Not_Have_InMemoryRepository() + { + var types = Types.InAssembly(typeof(BlogPost).Assembly) + .That() + .HaveNameEndingWith("InMemory") + .GetTypes(); - types.Should().BeEmpty("InMemoryBlogPostRepository should have been deleted"); - } + types.Should().BeEmpty("InMemoryBlogPostRepository should have been deleted"); + } } diff --git a/tests/Architecture.Tests/GlobalUsings.cs b/tests/Architecture.Tests/GlobalUsings.cs new file mode 100644 index 00000000..22669d28 --- /dev/null +++ b/tests/Architecture.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using FluentAssertions; +global using MyBlog.Domain.Entities; +global using NetArchTest.Rules; diff --git a/tests/Architecture.Tests/VsaLayerTests.cs b/tests/Architecture.Tests/VsaLayerTests.cs index d3669b6f..b527614a 100644 --- a/tests/Architecture.Tests/VsaLayerTests.cs +++ b/tests/Architecture.Tests/VsaLayerTests.cs @@ -1,51 +1,48 @@ -using FluentAssertions; -using MyBlog.Domain.Entities; using MyBlog.Web.Features.BlogPosts.List; -using NetArchTest.Rules; namespace MyBlog.Architecture.Tests; public class VsaLayerTests { - private static readonly System.Reflection.Assembly WebAssembly = typeof(GetBlogPostsQuery).Assembly; - - [Fact] - public void Features_Should_Not_Reference_Each_Other() - { - // BlogPosts slice should not reference UserManagement and vice versa - var blogPostsResult = Types.InAssembly(WebAssembly) - .That() - .ResideInNamespace("MyBlog.Web.Features.BlogPosts") - .ShouldNot() - .HaveDependencyOnAny("MyBlog.Web.Features.UserManagement") - .GetResult(); - - blogPostsResult.IsSuccessful.Should().BeTrue(); - } - - [Fact] - public void Handlers_Should_HaveNameEndingWithHandler_And_BeSealed() - { - // All types named *Handler in Web assembly should be sealed classes - var result = Types.InAssembly(WebAssembly) - .That() - .HaveNameEndingWith("Handler") - .Should() - .BeSealed() - .GetResult(); - - result.IsSuccessful.Should().BeTrue(); - } - - [Fact] - public void Data_Layer_Should_Not_Be_Referenced_Outside_Web() - { - // Domain assembly must NOT depend on MyBlog.Web.Data - var result = Types.InAssembly(typeof(BlogPost).Assembly) - .ShouldNot() - .HaveDependencyOnAny("MyBlog.Web.Data") - .GetResult(); - - result.IsSuccessful.Should().BeTrue(); - } + private static readonly System.Reflection.Assembly WebAssembly = typeof(GetBlogPostsQuery).Assembly; + + [Fact] + public void Features_Should_Not_Reference_Each_Other() + { + // BlogPosts slice should not reference UserManagement and vice versa + var blogPostsResult = Types.InAssembly(WebAssembly) + .That() + .ResideInNamespace("MyBlog.Web.Features.BlogPosts") + .ShouldNot() + .HaveDependencyOnAny("MyBlog.Web.Features.UserManagement") + .GetResult(); + + blogPostsResult.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Handlers_Should_HaveNameEndingWithHandler_And_BeSealed() + { + // All types named *Handler in Web assembly should be sealed classes + var result = Types.InAssembly(WebAssembly) + .That() + .HaveNameEndingWith("Handler") + .Should() + .BeSealed() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Data_Layer_Should_Not_Be_Referenced_Outside_Web() + { + // Domain assembly must NOT depend on MyBlog.Web.Data + var result = Types.InAssembly(typeof(BlogPost).Assembly) + .ShouldNot() + .HaveDependencyOnAny("MyBlog.Web.Data") + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } } diff --git a/tests/Integration.Tests/BlogPosts/MongoDbBlogPostRepositoryTests.cs b/tests/Integration.Tests/BlogPosts/MongoDbBlogPostRepositoryTests.cs index 24a23aaa..2a603ae7 100644 --- a/tests/Integration.Tests/BlogPosts/MongoDbBlogPostRepositoryTests.cs +++ b/tests/Integration.Tests/BlogPosts/MongoDbBlogPostRepositoryTests.cs @@ -1,148 +1,145 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; using MyBlog.Domain.Entities; using MyBlog.Integration.Tests.Infrastructure; -using MyBlog.Web.Data; namespace MyBlog.Integration.Tests.BlogPosts; [Collection("MongoDb")] public sealed class MongoDbBlogPostRepositoryTests(MongoDbFixture fixture) - : IClassFixture + : IClassFixture { - private MongoDbBlogPostRepository CreateRepo() => - new(fixture.CreateFactory($"blog_{Guid.NewGuid():N}")); - - [Fact] - public async Task AddAsync_persists_post_to_MongoDB() - { - var repo = CreateRepo(); - var post = BlogPost.Create("Hello World", "Some content", "Author A"); + private MongoDbBlogPostRepository CreateRepo() => + new(fixture.CreateFactory($"blog_{Guid.NewGuid():N}")); + + [Fact] + public async Task AddAsync_persists_post_to_MongoDB() + { + var repo = CreateRepo(); + var post = BlogPost.Create("Hello World", "Some content", "Author A"); - await repo.AddAsync(post); + await repo.AddAsync(post); - var all = await repo.GetAllAsync(); - all.Should().HaveCount(1); - all[0].Id.Should().Be(post.Id); - all[0].Title.Should().Be("Hello World"); - } - - [Fact] - public async Task GetByIdAsync_returns_null_when_not_found() - { - var repo = CreateRepo(); - - var result = await repo.GetByIdAsync(Guid.NewGuid()); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetByIdAsync_returns_post_when_found() - { - var repo = CreateRepo(); - var post = BlogPost.Create("My Title", "My Content", "My Author"); - await repo.AddAsync(post); - - var result = await repo.GetByIdAsync(post.Id); - - result.Should().NotBeNull(); - result!.Title.Should().Be("My Title"); - result.Author.Should().Be("My Author"); - result.Content.Should().Be("My Content"); - } - - [Fact] - public async Task GetAllAsync_returns_posts_ordered_by_newest_first() - { - var repo = CreateRepo(); - var older = BlogPost.Create("Older Post", "Content", "Author"); - await repo.AddAsync(older); - - await Task.Delay(20); - - var newer = BlogPost.Create("Newer Post", "Content", "Author"); - await repo.AddAsync(newer); - - var all = await repo.GetAllAsync(); - - all.Should().HaveCount(2); - all[0].Title.Should().Be("Newer Post"); - all[1].Title.Should().Be("Older Post"); - } - - [Fact] - public async Task UpdateAsync_modifies_post_in_MongoDB() - { - var repo = CreateRepo(); - var post = BlogPost.Create("Original Title", "Original Content", "Author"); - await repo.AddAsync(post); - - post.Update("Updated Title", "Updated Content"); - await repo.UpdateAsync(post); - - var result = await repo.GetByIdAsync(post.Id); - result!.Title.Should().Be("Updated Title"); - result.Content.Should().Be("Updated Content"); - } - - [Fact] - public async Task DeleteAsync_removes_post_from_MongoDB() - { - var repo = CreateRepo(); - var post = BlogPost.Create("To Delete", "Content", "Author"); - await repo.AddAsync(post); - - await repo.DeleteAsync(post.Id); - - var all = await repo.GetAllAsync(); - all.Should().BeEmpty(); - } - - [Fact] - public async Task DeleteAsync_does_nothing_when_post_not_found() - { - var repo = CreateRepo(); - - var act = async () => await repo.DeleteAsync(Guid.NewGuid()); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task GetAllAsync_returns_empty_when_no_posts_exist() - { - var repo = CreateRepo(); - - var all = await repo.GetAllAsync(); - - all.Should().BeEmpty(); - } + var all = await repo.GetAllAsync(); + all.Should().HaveCount(1); + all[0].Id.Should().Be(post.Id); + all[0].Title.Should().Be("Hello World"); + } + + [Fact] + public async Task GetByIdAsync_returns_null_when_not_found() + { + var repo = CreateRepo(); + + var result = await repo.GetByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_returns_post_when_found() + { + var repo = CreateRepo(); + var post = BlogPost.Create("My Title", "My Content", "My Author"); + await repo.AddAsync(post); + + var result = await repo.GetByIdAsync(post.Id); + + result.Should().NotBeNull(); + result!.Title.Should().Be("My Title"); + result.Author.Should().Be("My Author"); + result.Content.Should().Be("My Content"); + } + + [Fact] + public async Task GetAllAsync_returns_posts_ordered_by_newest_first() + { + var repo = CreateRepo(); + var older = BlogPost.Create("Older Post", "Content", "Author"); + await repo.AddAsync(older); + + await Task.Delay(20); + + var newer = BlogPost.Create("Newer Post", "Content", "Author"); + await repo.AddAsync(newer); + + var all = await repo.GetAllAsync(); + + all.Should().HaveCount(2); + all[0].Title.Should().Be("Newer Post"); + all[1].Title.Should().Be("Older Post"); + } + + [Fact] + public async Task UpdateAsync_modifies_post_in_MongoDB() + { + var repo = CreateRepo(); + var post = BlogPost.Create("Original Title", "Original Content", "Author"); + await repo.AddAsync(post); + + post.Update("Updated Title", "Updated Content"); + await repo.UpdateAsync(post); + + var result = await repo.GetByIdAsync(post.Id); + result!.Title.Should().Be("Updated Title"); + result.Content.Should().Be("Updated Content"); + } + + [Fact] + public async Task DeleteAsync_removes_post_from_MongoDB() + { + var repo = CreateRepo(); + var post = BlogPost.Create("To Delete", "Content", "Author"); + await repo.AddAsync(post); + + await repo.DeleteAsync(post.Id); + + var all = await repo.GetAllAsync(); + all.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteAsync_does_nothing_when_post_not_found() + { + var repo = CreateRepo(); + + var act = async () => await repo.DeleteAsync(Guid.NewGuid()); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetAllAsync_returns_empty_when_no_posts_exist() + { + var repo = CreateRepo(); + + var all = await repo.GetAllAsync(); + + all.Should().BeEmpty(); + } - [Fact] - public async Task UpdateAsync_throws_when_version_conflicts_with_concurrent_update() - { - // Arrange – two repos targeting the same database - var dbName = $"blog_{Guid.NewGuid():N}"; - var repo1 = new MongoDbBlogPostRepository(fixture.CreateFactory(dbName)); - var repo2 = new MongoDbBlogPostRepository(fixture.CreateFactory(dbName)); + [Fact] + public async Task UpdateAsync_throws_when_version_conflicts_with_concurrent_update() + { + // Arrange – two repos targeting the same database + var dbName = $"blog_{Guid.NewGuid():N}"; + var repo1 = new MongoDbBlogPostRepository(fixture.CreateFactory(dbName)); + var repo2 = new MongoDbBlogPostRepository(fixture.CreateFactory(dbName)); - // Insert via repo1 — Version == 0 in DB and in the entity - var post = BlogPost.Create("Original", "Content", "Author"); - await repo1.AddAsync(post); + // Insert via repo1 — Version == 0 in DB and in the entity + var post = BlogPost.Create("Original", "Content", "Author"); + await repo1.AddAsync(post); - // repo2 wins the race: reads, updates (Version → 1 in entity), saves — DB Version becomes 1 - var winner = await repo2.GetByIdAsync(post.Id) ?? throw new InvalidOperationException("post not found"); - winner.Update("Winner Title", "Winner Content"); - await repo2.UpdateAsync(winner); - - // Simulate user from repo1 applying their own edit (Version → 1 in entity, but DB is already 1) - post.Update("Late Title", "Late Content"); - - // Act — repo1 tries to save (OriginalValue = 0) but DB already has Version = 1 - var act = async () => await repo1.UpdateAsync(post); - - // Assert — EF Core detects the Version mismatch and throws - await act.Should().ThrowAsync(); - } + // repo2 wins the race: reads, updates (Version → 1 in entity), saves — DB Version becomes 1 + var winner = await repo2.GetByIdAsync(post.Id) ?? throw new InvalidOperationException("post not found"); + winner.Update("Winner Title", "Winner Content"); + await repo2.UpdateAsync(winner); + + // Simulate user from repo1 applying their own edit (Version → 1 in entity, but DB is already 1) + post.Update("Late Title", "Late Content"); + + // Act — repo1 tries to save (OriginalValue = 0) but DB already has Version = 1 + var act = async () => await repo1.UpdateAsync(post); + + // Assert — EF Core detects the Version mismatch and throws + await act.Should().ThrowAsync(); + } } diff --git a/tests/Integration.Tests/GlobalUsings.cs b/tests/Integration.Tests/GlobalUsings.cs new file mode 100644 index 00000000..82badaf5 --- /dev/null +++ b/tests/Integration.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using FluentAssertions; +global using Microsoft.EntityFrameworkCore; +global using MyBlog.Domain.Entities; +global using MyBlog.Web.Data; diff --git a/tests/Integration.Tests/Infrastructure/MongoDbFixture.cs b/tests/Integration.Tests/Infrastructure/MongoDbFixture.cs index bf5353e5..75a68132 100644 --- a/tests/Integration.Tests/Infrastructure/MongoDbFixture.cs +++ b/tests/Integration.Tests/Infrastructure/MongoDbFixture.cs @@ -1,44 +1,42 @@ -using Microsoft.EntityFrameworkCore; -using MyBlog.Web.Data; using Testcontainers.MongoDb; namespace MyBlog.Integration.Tests.Infrastructure; public sealed class MongoDbFixture : IAsyncLifetime { - private readonly MongoDbContainer _container = + private readonly MongoDbContainer _container = #pragma warning disable CS0618 - new MongoDbBuilder().Build(); + new MongoDbBuilder().Build(); #pragma warning restore CS0618 - public string ConnectionString { get; private set; } = string.Empty; - - public async Task InitializeAsync() - { - await _container.StartAsync(); - ConnectionString = _container.GetConnectionString(); - } - - public async Task DisposeAsync() - { - await _container.DisposeAsync(); - } - - public IDbContextFactory CreateFactory(string dbName) => - new TestContextFactory(ConnectionString, dbName); - - private sealed class TestContextFactory(string connectionString, string dbName) - : IDbContextFactory - { - public BlogDbContext CreateDbContext() - { - var options = new DbContextOptionsBuilder() - .UseMongoDB(connectionString, dbName) - .Options; - return new BlogDbContext(options); - } - - public Task CreateDbContextAsync(CancellationToken ct = default) => - Task.FromResult(CreateDbContext()); - } + public string ConnectionString { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + ConnectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + public IDbContextFactory CreateFactory(string dbName) => + new TestContextFactory(ConnectionString, dbName); + + private sealed class TestContextFactory(string connectionString, string dbName) + : IDbContextFactory + { + public BlogDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseMongoDB(connectionString, dbName) + .Options; + return new BlogDbContext(options); + } + + public Task CreateDbContextAsync(CancellationToken ct = default) => + Task.FromResult(CreateDbContext()); + } } diff --git a/tests/Unit.Tests/BlogPostTests.cs b/tests/Unit.Tests/BlogPostTests.cs index 621f9a05..c94ef4be 100644 --- a/tests/Unit.Tests/BlogPostTests.cs +++ b/tests/Unit.Tests/BlogPostTests.cs @@ -1,58 +1,55 @@ -using FluentAssertions; -using MyBlog.Domain.Entities; - namespace MyBlog.Unit.Tests; public class BlogPostTests { - [Fact] - public void Create_WithValidArgs_ReturnsBlogPost() - { - var post = BlogPost.Create("Test Title", "Test Content", "Test Author"); - - post.Id.Should().NotBeEmpty(); - post.Title.Should().Be("Test Title"); - post.Content.Should().Be("Test Content"); - post.Author.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) - { - var act = () => BlogPost.Create(title, content, author); - act.Should().Throw(); - } - - [Fact] - public void Update_ChangesTitle_AndContent() - { - var post = BlogPost.Create("Old Title", "Old Content", "Author"); - post.Update("New Title", "New Content"); - - post.Title.Should().Be("New Title"); - post.Content.Should().Be("New Content"); - post.UpdatedAt.Should().NotBeNull(); - } - - [Fact] - public void Publish_SetsIsPublished_True() - { - var post = BlogPost.Create("T", "C", "A"); - post.Publish(); - post.IsPublished.Should().BeTrue(); - } - - [Fact] - public void Unpublish_SetsIsPublished_False() - { - var post = BlogPost.Create("T", "C", "A"); - post.Publish(); - post.Unpublish(); - post.IsPublished.Should().BeFalse(); - } + [Fact] + public void Create_WithValidArgs_ReturnsBlogPost() + { + var post = BlogPost.Create("Test Title", "Test Content", "Test Author"); + + post.Id.Should().NotBeEmpty(); + post.Title.Should().Be("Test Title"); + post.Content.Should().Be("Test Content"); + post.Author.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) + { + var act = () => BlogPost.Create(title, content, author); + act.Should().Throw(); + } + + [Fact] + public void Update_ChangesTitle_AndContent() + { + var post = BlogPost.Create("Old Title", "Old Content", "Author"); + post.Update("New Title", "New Content"); + + post.Title.Should().Be("New Title"); + post.Content.Should().Be("New Content"); + post.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void Publish_SetsIsPublished_True() + { + var post = BlogPost.Create("T", "C", "A"); + post.Publish(); + post.IsPublished.Should().BeTrue(); + } + + [Fact] + public void Unpublish_SetsIsPublished_False() + { + var post = BlogPost.Create("T", "C", "A"); + post.Publish(); + post.Unpublish(); + post.IsPublished.Should().BeFalse(); + } } diff --git a/tests/Unit.Tests/Components/Layout/NavMenuTests.cs b/tests/Unit.Tests/Components/Layout/NavMenuTests.cs index 22fa73bc..ef939d3f 100644 --- a/tests/Unit.Tests/Components/Layout/NavMenuTests.cs +++ b/tests/Unit.Tests/Components/Layout/NavMenuTests.cs @@ -6,112 +6,109 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using System.Security.Claims; -using Bunit; -using FluentAssertions; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; -using MyBlog.Web.Components.Layout; + using MyBlog.Unit.Tests.Testing; +using MyBlog.Web.Components.Layout; namespace MyBlog.Unit.Tests.Components.Layout; public class NavMenuTests : BunitContext { - public NavMenuTests() - { - Services.AddAuthorizationCore(); - Services.AddSingleton(); - } - - [Fact] - public void UnauthenticatedUser_SeesLoginAndNoProtectedLinks() - { - // Arrange (none) - // Act - var cut = RenderForUser(CreatePrincipal(authenticated: false)); - - cut.Markup.Should().Contain("Login"); - cut.Markup.Should().NotContain("Logout"); - cut.Markup.Should().NotContain("Manage Users"); - cut.Markup.Should().NotContain("New Post"); - } - - [Fact] - public void AuthenticatedAdmin_UsesDisplayNameAsProfileLabel_AndShowsAdminLinks() - { - // Arrange (none) - // Act - var cut = RenderForUser(CreatePrincipal(name: "Admin User", roles: ["Admin"])); - - cut.Markup.Should().Contain("Admin User"); - cut.Markup.Should().Contain("Manage Users"); - cut.Markup.Should().Contain("New Post"); - cut.Markup.Should().Contain("Logout"); - cut.Markup.Should().NotContain("Logout (Admin User)"); - } - - [Fact] - public void AuthenticatedUser_WithoutName_FallsBackToProfileLabel() - { - // Arrange (none) - // Act - var cut = RenderForUser(CreatePrincipal(roles: ["Author"])); - - cut.Markup.Should().Contain(">Profile<"); - cut.Markup.Should().Contain("New Post"); - cut.Markup.Should().NotContain("Manage Users"); - } - - [Fact] - public void NavMenu_LoadsThemeFromJs_AndAllowsThemeInteraction() - { - // Arrange - JSInterop.Mode = JSRuntimeMode.Loose; - JSInterop.Setup("themeManager.getColor").SetResult("green"); - JSInterop.Setup("themeManager.getBrightness").SetResult("dark"); - - // Act - var cut = RenderForUser(CreatePrincipal(name: "Theme User", roles: ["Admin"])); - - // Assert - cut.WaitForAssertion(() => cut.Markup.Should().Contain("Theme User")); - cut.Find("select").Change("yellow"); - cut.FindAll("button").Last().Click(); - - JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getColor"); - JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getBrightness"); - JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setColor"); - JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setBrightness"); - } - - private IRenderedComponent RenderForUser(ClaimsPrincipal principal) - { - return Render(parameters => parameters - .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); - } - - private static ClaimsPrincipal CreatePrincipal(bool authenticated = true, string? name = null, string[]? roles = null) - { - if (!authenticated) - { - return new ClaimsPrincipal(new ClaimsIdentity()); - } - - var claims = new List(); - - if (!string.IsNullOrWhiteSpace(name)) - { - claims.Add(new Claim(ClaimTypes.Name, name)); - } - - if (roles is not null) - { - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - } - - return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); - } + public NavMenuTests() + { + Services.AddAuthorizationCore(); + Services.AddSingleton(); + } + + [Fact] + public void UnauthenticatedUser_SeesLoginAndNoProtectedLinks() + { + // Arrange (none) + // Act + var cut = RenderForUser(CreatePrincipal(authenticated: false)); + + cut.Markup.Should().Contain("Login"); + cut.Markup.Should().NotContain("Logout"); + cut.Markup.Should().NotContain("Manage Users"); + cut.Markup.Should().NotContain("New Post"); + } + + [Fact] + public void AuthenticatedAdmin_UsesDisplayNameAsProfileLabel_AndShowsAdminLinks() + { + // Arrange (none) + // Act + var cut = RenderForUser(CreatePrincipal(name: "Admin User", roles: ["Admin"])); + + cut.Markup.Should().Contain("Admin User"); + cut.Markup.Should().Contain("Manage Users"); + cut.Markup.Should().Contain("New Post"); + cut.Markup.Should().Contain("Logout"); + cut.Markup.Should().NotContain("Logout (Admin User)"); + } + + [Fact] + public void AuthenticatedUser_WithoutName_FallsBackToProfileLabel() + { + // Arrange (none) + // Act + var cut = RenderForUser(CreatePrincipal(roles: ["Author"])); + + cut.Markup.Should().Contain(">Profile<"); + cut.Markup.Should().Contain("New Post"); + cut.Markup.Should().NotContain("Manage Users"); + } + + [Fact] + public void NavMenu_LoadsThemeFromJs_AndAllowsThemeInteraction() + { + // Arrange + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.Setup("themeManager.getColor").SetResult("green"); + JSInterop.Setup("themeManager.getBrightness").SetResult("dark"); + + // Act + var cut = RenderForUser(CreatePrincipal(name: "Theme User", roles: ["Admin"])); + + // Assert + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Theme User")); + cut.Find("select").Change("yellow"); + cut.FindAll("button").Last().Click(); + + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getColor"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getBrightness"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setColor"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setBrightness"); + } + + private IRenderedComponent RenderForUser(ClaimsPrincipal principal) + { + return Render(parameters => parameters + .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); + } + + private static ClaimsPrincipal CreatePrincipal(bool authenticated = true, string? name = null, string[]? roles = null) + { + if (!authenticated) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + var claims = new List(); + + if (!string.IsNullOrWhiteSpace(name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + if (roles is not null) + { + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } } diff --git a/tests/Unit.Tests/Components/RazorSmokeTests.cs b/tests/Unit.Tests/Components/RazorSmokeTests.cs index 6520d053..bc230c21 100644 --- a/tests/Unit.Tests/Components/RazorSmokeTests.cs +++ b/tests/Unit.Tests/Components/RazorSmokeTests.cs @@ -6,498 +6,496 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using Bunit; -using Domain.Abstractions; -using FluentAssertions; using MediatR; -using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using MyBlog.Web.Components.Layout; -using MyBlog.Web.Components.Shared; + using MyBlog.Unit.Tests.Testing; +using MyBlog.Web.Components.Layout; using MyBlog.Web.Components.Pages; +using MyBlog.Web.Components.Shared; using MyBlog.Web.Data; using MyBlog.Web.Features.BlogPosts.Create; using MyBlog.Web.Features.BlogPosts.Delete; using MyBlog.Web.Features.BlogPosts.Edit; using MyBlog.Web.Features.BlogPosts.List; using MyBlog.Web.Features.UserManagement; + using NSubstitute; -using System.Security.Claims; namespace MyBlog.Unit.Tests.Components; public class RazorSmokeTests : BunitContext { - public RazorSmokeTests() - { - Services.AddAuthorizationCore(); - Services.AddSingleton(); - } - - [Fact] - public void Home_RendersWelcomeMessage() - { - // Arrange (none) - // Act - var cut = Render(); - - // Assert - cut.Markup.Should().Contain("Hello, users!"); - cut.Markup.Should().Contain("Welcome to your new app."); - } - - [Fact] - public void Counter_Increments_WhenButtonClicked() - { - // Arrange (none) - // Act - var cut = Render(); - - // Assert - cut.Markup.Should().Contain("Current count: 0"); - cut.Find("button").Click(); - cut.Markup.Should().Contain("Current count: 1"); - } - - [Fact] - public void Weather_LoadsForecasts() - { - // Arrange (none) - // Act - var cut = Render(); - - // Assert - cut.WaitForAssertion(() => - { - cut.Markup.Should().Contain("Temp. (C)"); - cut.FindAll("tbody tr").Should().HaveCount(5); - }, TimeSpan.FromSeconds(2)); - } - - [Fact] - public void Error_UsesCascadingHttpContextTraceIdentifier() - { - // Arrange - var httpContext = new DefaultHttpContext - { - TraceIdentifier = "trace-123" - }; - - // Act - var cut = Render(parameters => parameters.AddCascadingValue(httpContext)); - - // Assert - cut.Markup.Should().Contain("trace-123"); - } - - [Fact] - public void NotFound_RendersNotFoundMessage() - { - // Arrange (none) - // Act - var cut = Render(); - - // Assert - cut.Markup.Should().Contain("Not Found"); - cut.Markup.Should().Contain("does not exist"); - } - - [Fact] - public void ConfirmDeleteDialog_ShowsDialog_WhenVisible() - { - // Arrange (none) - // Act - var cut = Render(parameters => parameters - .Add(p => p.IsVisible, true) - .Add(p => p.PostTitle, "My Post")); - - // Assert - cut.Markup.Should().Contain("Confirm Delete"); - cut.Markup.Should().Contain("My Post"); - } - - [Fact] - public void RedirectToLogin_NavigatesToLoginWithReturnUrl() - { - // Arrange - var navigation = Services.GetRequiredService(); - - // Act - Render(); - - // Assert - navigation.Uri.Should().Contain("/Account/Login?returnUrl="); - } - - [Fact] - public void MainLayout_RendersMainContentTargetAndFooter() - { - // Arrange (none) - // Act - var cut = RenderWithUser(CreatePrincipal("Layout User", ["Author"]), parameters => parameters - .Add(layout => layout.Body, (RenderFragment)(builder => builder.AddContent(0, "Body content")))); - - // Assert - cut.Markup.Should().Contain("id=\"main-content\""); - cut.Markup.Should().Contain("Body content"); - cut.Markup.Should().Contain("Training Project"); - } - - [Fact] - public void BlogIndex_RendersPostsForAuthorizedUser_AndCanOpenDeleteDialog() - { - // Arrange - var sender = Substitute.For(); - var posts = new[] - { - new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(posts))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); - - // Assert - cut.Markup.Should().Contain("First"); - cut.Markup.Should().Contain("Edit"); - cut.Find("button").Click(); - cut.Markup.Should().Contain("Confirm Delete"); - } - - [Fact] - public void BlogIndex_ShowsEmptyState_WhenNoPostsExist() - { - // Arrange - var sender = Substitute.For(); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(Array.Empty()))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); - - // Assert - cut.Markup.Should().Contain("No posts yet."); - } - - [Fact] - public void BlogIndex_ConfirmDelete_SendsDeleteCommandAndRefreshesList() - { - // Arrange - var sender = Substitute.For(); - var postId = Guid.NewGuid(); - var posts = new[] - { - new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult(Result.Ok>(posts)), - Task.FromResult(Result.Ok>(Array.Empty()))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok())); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); - - cut.Find("button").Click(); - cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); - - // Assert - sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); - cut.WaitForAssertion(() => cut.Markup.Should().Contain("No posts yet.")); - } - - [Fact] - public void BlogIndex_ShowsConcurrencyWarning_WhenDeleteFailsWithConcurrency() - { - // Arrange - var sender = Substitute.For(); - var postId = Guid.NewGuid(); - var posts = new[] - { - new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(posts))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); - - cut.Find("button").Click(); - cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); - - // Assert - cut.WaitForAssertion(() => - { - cut.Markup.Should().Contain("Concurrency Conflict"); - cut.Markup.Should().Contain("Concurrency error"); - }); - } - - [Fact] - public void CreatePost_RendersForm() - { - // Arrange - Services.AddSingleton(Substitute.For()); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); - - // Assert - cut.Markup.Should().Contain("Create Post"); - cut.FindAll("input").Count.Should().BeGreaterThanOrEqualTo(2); - cut.Find("textarea"); - } - - [Fact] - public void CreatePost_SubmitsAndNavigatesToBlog_WhenCommandSucceeds() - { - // Arrange - var sender = Substitute.For(); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok(Guid.NewGuid()))); - Services.AddSingleton(sender); - - var navigation = Services.GetRequiredService(); - - // Act - 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.Content == "Hello world"), Arg.Any()); - navigation.Uri.Should().EndWith("/blog"); - } - - [Fact] - public void EditPost_LoadsExistingPost() - { - // Arrange - var sender = Substitute.For(); - var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok(post))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); - - // Assert - cut.Markup.Should().Contain("Edit Post"); - cut.Markup.Should().Contain("Existing title"); - cut.Markup.Should().Contain("Existing content"); - } - - [Fact] - public void EditPost_ShowsConcurrencyMessage_WhenSaveFailsWithConcurrency() - { - // Arrange - var sender = Substitute.For(); - var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok(post))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); - - cut.Find("form").Submit(); - - // Assert - cut.WaitForAssertion(() => cut.Markup.Should().Contain("Concurrency Conflict")); - } - - [Fact] - public void EditPost_SubmitsAndNavigatesToBlog_WhenSaveSucceeds() - { - // Arrange - var sender = Substitute.For(); - var postId = Guid.NewGuid(); - var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok(post))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok())); - - Services.AddSingleton(sender); - - var navigation = Services.GetRequiredService(); - - // Act - var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); - - cut.Find("form").Submit(); - - // Assert - sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); - navigation.Uri.Should().EndWith("/blog"); - } - - [Fact] - public void ManageRoles_RendersUsersAndAvailableRoles() - { - // Arrange - var sender = Substitute.For(); - var users = new[] - { - new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) - }; - var roles = new[] - { - new RoleDto("role-admin", "Admin"), - new RoleDto("role-author", "Author") - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(users))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(roles))); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); - - // Assert - cut.Markup.Should().Contain("Manage User Roles"); - cut.Markup.Should().Contain("Available roles: Admin, Author"); - cut.Markup.Should().Contain("admin@example.com"); - } - - [Fact] - public void ManageRoles_AssignButton_SendsCommandAndRefreshesUsers() - { - // Arrange - var sender = Substitute.For(); - var users = new[] - { - new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) - }; - var refreshedUsers = new[] - { - new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) - }; - var roles = new[] - { - new RoleDto("role-admin", "Admin"), - new RoleDto("role-author", "Author") - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult(Result.Ok>(users)), - Task.FromResult(Result.Ok>(refreshedUsers))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(roles))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok())); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); - - cut.FindAll("button").First(button => button.TextContent.Contains("+ Author")).Click(); - - // Assert - sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); - cut.WaitForAssertion(() => cut.Markup.Should().Contain("Admin, Author")); - } - - [Fact] - public void ManageRoles_RemoveButton_SendsCommandAndRefreshesUsers() - { - // Arrange - var sender = Substitute.For(); - var users = new[] - { - new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) - }; - var refreshedUsers = new[] - { - new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) - }; - var roles = new[] - { - new RoleDto("role-admin", "Admin"), - new RoleDto("role-author", "Author") - }; - - sender.Send(Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult(Result.Ok>(users)), - Task.FromResult(Result.Ok>(refreshedUsers))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok>(roles))); - sender.Send(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Ok())); - - Services.AddSingleton(sender); - - // Act - var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); - - cut.FindAll("button").First(button => button.TextContent.Contains("- Author")).Click(); - - // Assert - sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); - cut.WaitForAssertion(() => - { - cut.Markup.Should().Contain("+ Author"); - cut.Markup.Should().Contain("Admin"); - }); - } - - private IRenderedComponent RenderWithUser( - ClaimsPrincipal principal, - Action>? configure = null) - where TComponent : IComponent - { - return Render(parameters => - { - parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal))); - configure?.Invoke(parameters); - }); - } - - private static ClaimsPrincipal CreatePrincipal(string name, string[] roles) - { - var claims = new List { new(ClaimTypes.Name, name) }; - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); - } + public RazorSmokeTests() + { + Services.AddAuthorizationCore(); + Services.AddSingleton(); + } + + [Fact] + public void Home_RendersWelcomeMessage() + { + // Arrange (none) + // Act + var cut = Render(); + + // Assert + cut.Markup.Should().Contain("Hello, users!"); + cut.Markup.Should().Contain("Welcome to your new app."); + } + + [Fact] + public void Counter_Increments_WhenButtonClicked() + { + // Arrange (none) + // Act + var cut = Render(); + + // Assert + cut.Markup.Should().Contain("Current count: 0"); + cut.Find("button").Click(); + cut.Markup.Should().Contain("Current count: 1"); + } + + [Fact] + public void Weather_LoadsForecasts() + { + // Arrange (none) + // Act + var cut = Render(); + + // Assert + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Temp. (C)"); + cut.FindAll("tbody tr").Should().HaveCount(5); + }, TimeSpan.FromSeconds(2)); + } + + [Fact] + public void Error_UsesCascadingHttpContextTraceIdentifier() + { + // Arrange + var httpContext = new DefaultHttpContext + { + TraceIdentifier = "trace-123" + }; + + // Act + var cut = Render(parameters => parameters.AddCascadingValue(httpContext)); + + // Assert + cut.Markup.Should().Contain("trace-123"); + } + + [Fact] + public void NotFound_RendersNotFoundMessage() + { + // Arrange (none) + // Act + var cut = Render(); + + // Assert + cut.Markup.Should().Contain("Not Found"); + cut.Markup.Should().Contain("does not exist"); + } + + [Fact] + public void ConfirmDeleteDialog_ShowsDialog_WhenVisible() + { + // Arrange (none) + // Act + var cut = Render(parameters => parameters + .Add(p => p.IsVisible, true) + .Add(p => p.PostTitle, "My Post")); + + // Assert + cut.Markup.Should().Contain("Confirm Delete"); + cut.Markup.Should().Contain("My Post"); + } + + [Fact] + public void RedirectToLogin_NavigatesToLoginWithReturnUrl() + { + // Arrange + var navigation = Services.GetRequiredService(); + + // Act + Render(); + + // Assert + navigation.Uri.Should().Contain("/Account/Login?returnUrl="); + } + + [Fact] + public void MainLayout_RendersMainContentTargetAndFooter() + { + // Arrange (none) + // Act + var cut = RenderWithUser(CreatePrincipal("Layout User", ["Author"]), parameters => parameters + .Add(layout => layout.Body, (RenderFragment)(builder => builder.AddContent(0, "Body content")))); + + // Assert + cut.Markup.Should().Contain("id=\"main-content\""); + cut.Markup.Should().Contain("Body content"); + cut.Markup.Should().Contain("Training Project"); + } + + [Fact] + public void BlogIndex_RendersPostsForAuthorizedUser_AndCanOpenDeleteDialog() + { + // Arrange + var sender = Substitute.For(); + var posts = new[] + { + new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(posts))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + // Assert + cut.Markup.Should().Contain("First"); + cut.Markup.Should().Contain("Edit"); + cut.Find("button").Click(); + cut.Markup.Should().Contain("Confirm Delete"); + } + + [Fact] + public void BlogIndex_ShowsEmptyState_WhenNoPostsExist() + { + // Arrange + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(Array.Empty()))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + // Assert + cut.Markup.Should().Contain("No posts yet."); + } + + [Fact] + public void BlogIndex_ConfirmDelete_SendsDeleteCommandAndRefreshesList() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var posts = new[] + { + new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(posts)), + Task.FromResult(Result.Ok>(Array.Empty()))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Find("button").Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + + // Assert + sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); + cut.WaitForAssertion(() => cut.Markup.Should().Contain("No posts yet.")); + } + + [Fact] + public void BlogIndex_ShowsConcurrencyWarning_WhenDeleteFailsWithConcurrency() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var posts = new[] + { + new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(posts))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Find("button").Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + + // Assert + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Concurrency Conflict"); + cut.Markup.Should().Contain("Concurrency error"); + }); + } + + [Fact] + public void CreatePost_RendersForm() + { + // Arrange + Services.AddSingleton(Substitute.For()); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + // Assert + cut.Markup.Should().Contain("Create Post"); + cut.FindAll("input").Count.Should().BeGreaterThanOrEqualTo(2); + cut.Find("textarea"); + } + + [Fact] + public void CreatePost_SubmitsAndNavigatesToBlog_WhenCommandSucceeds() + { + // Arrange + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(Guid.NewGuid()))); + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + 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.Content == "Hello world"), Arg.Any()); + navigation.Uri.Should().EndWith("/blog"); + } + + [Fact] + public void EditPost_LoadsExistingPost() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + // Assert + cut.Markup.Should().Contain("Edit Post"); + cut.Markup.Should().Contain("Existing title"); + cut.Markup.Should().Contain("Existing content"); + } + + [Fact] + public void EditPost_ShowsConcurrencyMessage_WhenSaveFailsWithConcurrency() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + cut.Find("form").Submit(); + + // Assert + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Concurrency Conflict")); + } + + [Fact] + public void EditPost_SubmitsAndNavigatesToBlog_WhenSaveSucceeds() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + cut.Find("form").Submit(); + + // Assert + sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); + navigation.Uri.Should().EndWith("/blog"); + } + + [Fact] + public void ManageRoles_RendersUsersAndAvailableRoles() + { + // Arrange + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(users))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + // Assert + cut.Markup.Should().Contain("Manage User Roles"); + cut.Markup.Should().Contain("Available roles: Admin, Author"); + cut.Markup.Should().Contain("admin@example.com"); + } + + [Fact] + public void ManageRoles_AssignButton_SendsCommandAndRefreshesUsers() + { + // Arrange + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var refreshedUsers = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(users)), + Task.FromResult(Result.Ok>(refreshedUsers))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + cut.FindAll("button").First(button => button.TextContent.Contains("+ Author")).Click(); + + // Assert + sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Admin, Author")); + } + + [Fact] + public void ManageRoles_RemoveButton_SendsCommandAndRefreshesUsers() + { + // Arrange + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) + }; + var refreshedUsers = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(users)), + Task.FromResult(Result.Ok>(refreshedUsers))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + // Act + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + cut.FindAll("button").First(button => button.TextContent.Contains("- Author")).Click(); + + // Assert + sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("+ Author"); + cut.Markup.Should().Contain("Admin"); + }); + } + + private IRenderedComponent RenderWithUser( + ClaimsPrincipal principal, + Action>? configure = null) + where TComponent : IComponent + { + return Render(parameters => + { + parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal))); + configure?.Invoke(parameters); + }); + } + + private static ClaimsPrincipal CreatePrincipal(string name, string[] roles) + { + var claims = new List { new(ClaimTypes.Name, name) }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } } diff --git a/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs b/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs index bf61aa02..b0c5c22d 100644 --- a/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs +++ b/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs @@ -6,107 +6,105 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using System.Security.Claims; -using Bunit; -using FluentAssertions; using Microsoft.AspNetCore.Components.Authorization; + using MyBlog.Web.Features.UserManagement; namespace MyBlog.Unit.Tests.Features.UserManagement; public class ProfileTests : BunitContext { - [Fact] - public void Profile_RendersIdentityDetailsRolesPictureAndClaims() - { - // Arrange - var principal = CreatePrincipal( - name: "Admin User", - email: "admin@example.com", - userId: "auth0|123", - pictureUrl: "https://example.com/avatar.png", - rolesJson: "[\"Admin\",\"Author\"]", - extraClaims: [new Claim("department", "Engineering")]); - - // Act - var cut = RenderForUser(principal); - - // Assert - cut.Markup.Should().Contain("Admin User"); - cut.Markup.Should().Contain("admin@example.com"); - cut.Markup.Should().Contain("auth0|123"); - cut.Markup.Should().Contain("avatar.png"); - cut.Markup.Should().Contain("Admin"); - cut.Markup.Should().Contain("Author"); - cut.Markup.Should().Contain("department"); - cut.Markup.Should().Contain("Engineering"); - } - - [Fact] - public void Profile_UsesFallbackValues_WhenOptionalClaimsAreMissing() - { - // Arrange - var principal = CreatePrincipal( - name: null, - email: null, - userId: null, - pictureUrl: null, - rolesJson: null, - extraClaims: []); - - // Act - var cut = RenderForUser(principal); - - // Assert - cut.Markup.Should().Contain("Unknown User"); - cut.Markup.Should().Contain("No email claim found"); - cut.Markup.Should().Contain("No roles found in the current claims."); - cut.Markup.Should().Contain(">NE<"); - } - - private IRenderedComponent RenderForUser(ClaimsPrincipal principal) - { - return Render(parameters => parameters - .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); - } - - private static ClaimsPrincipal CreatePrincipal( - string? name, - string? email, - string? userId, - string? pictureUrl, - string? rolesJson, - IEnumerable extraClaims) - { - var claims = new List(); - - if (!string.IsNullOrWhiteSpace(name)) - { - claims.Add(new Claim(ClaimTypes.Name, name)); - } - - if (!string.IsNullOrWhiteSpace(email)) - { - claims.Add(new Claim(ClaimTypes.Email, email)); - } - - if (!string.IsNullOrWhiteSpace(userId)) - { - claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); - } - - if (!string.IsNullOrWhiteSpace(pictureUrl)) - { - claims.Add(new Claim("picture", pictureUrl)); - } - - if (!string.IsNullOrWhiteSpace(rolesJson)) - { - claims.Add(new Claim("https://myblog/roles", rolesJson)); - } - - claims.AddRange(extraClaims); - - return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); - } + [Fact] + public void Profile_RendersIdentityDetailsRolesPictureAndClaims() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: "admin@example.com", + userId: "auth0|123", + pictureUrl: "https://example.com/avatar.png", + rolesJson: "[\"Admin\",\"Author\"]", + extraClaims: [new Claim("department", "Engineering")]); + + // Act + var cut = RenderForUser(principal); + + // Assert + cut.Markup.Should().Contain("Admin User"); + cut.Markup.Should().Contain("admin@example.com"); + cut.Markup.Should().Contain("auth0|123"); + cut.Markup.Should().Contain("avatar.png"); + cut.Markup.Should().Contain("Admin"); + cut.Markup.Should().Contain("Author"); + cut.Markup.Should().Contain("department"); + cut.Markup.Should().Contain("Engineering"); + } + + [Fact] + public void Profile_UsesFallbackValues_WhenOptionalClaimsAreMissing() + { + // Arrange + var principal = CreatePrincipal( + name: null, + email: null, + userId: null, + pictureUrl: null, + rolesJson: null, + extraClaims: []); + + // Act + var cut = RenderForUser(principal); + + // Assert + cut.Markup.Should().Contain("Unknown User"); + cut.Markup.Should().Contain("No email claim found"); + cut.Markup.Should().Contain("No roles found in the current claims."); + cut.Markup.Should().Contain(">NE<"); + } + + private IRenderedComponent RenderForUser(ClaimsPrincipal principal) + { + return Render(parameters => parameters + .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); + } + + private static ClaimsPrincipal CreatePrincipal( + string? name, + string? email, + string? userId, + string? pictureUrl, + string? rolesJson, + IEnumerable extraClaims) + { + var claims = new List(); + + if (!string.IsNullOrWhiteSpace(name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + if (!string.IsNullOrWhiteSpace(email)) + { + claims.Add(new Claim(ClaimTypes.Email, email)); + } + + if (!string.IsNullOrWhiteSpace(userId)) + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + } + + if (!string.IsNullOrWhiteSpace(pictureUrl)) + { + claims.Add(new Claim("picture", pictureUrl)); + } + + if (!string.IsNullOrWhiteSpace(rolesJson)) + { + claims.Add(new Claim("https://myblog/roles", rolesJson)); + } + + claims.AddRange(extraClaims); + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } } diff --git a/tests/Unit.Tests/GlobalUsings.cs b/tests/Unit.Tests/GlobalUsings.cs new file mode 100644 index 00000000..35145fef --- /dev/null +++ b/tests/Unit.Tests/GlobalUsings.cs @@ -0,0 +1,13 @@ +global using Bunit; +global using Domain.Abstractions; +global using FluentAssertions; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Components.Authorization; +global using Microsoft.Extensions.Caching.Distributed; +global using Microsoft.Extensions.Caching.Memory; +global using MyBlog.Domain.Entities; +global using MyBlog.Domain.Interfaces; +global using MyBlog.Web.Data; +global using NSubstitute; +global using NSubstitute.ExceptionExtensions; +global using System.Security.Claims; diff --git a/tests/Unit.Tests/Handlers/CreateBlogPostHandlerTests.cs b/tests/Unit.Tests/Handlers/CreateBlogPostHandlerTests.cs index 6c661344..355bfa5d 100644 --- a/tests/Unit.Tests/Handlers/CreateBlogPostHandlerTests.cs +++ b/tests/Unit.Tests/Handlers/CreateBlogPostHandlerTests.cs @@ -1,56 +1,49 @@ -using Domain.Abstractions; -using FluentAssertions; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Interfaces; using MyBlog.Web.Features.BlogPosts.Create; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace MyBlog.Unit.Tests.Handlers; public class CreateBlogPostHandlerTests { - private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IMemoryCache _localCache = Substitute.For(); - private readonly IDistributedCache _distributedCache = Substitute.For(); - private readonly CreateBlogPostHandler _handler; - - public CreateBlogPostHandlerTests() - { - _handler = new CreateBlogPostHandler(_repo, _localCache, _distributedCache); - } - - [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(), Arg.Any()); - _localCache.Received(1).Remove("blog:all"); - await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); - } - - [Fact] - public async Task Handle_RepoThrows_ReturnsFailResult() - { - // Arrange - var command = new CreateBlogPostCommand("Title", "Content", "Author"); - _repo.AddAsync(Arg.Any(), Arg.Any()) - .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(); + private readonly IMemoryCache _localCache = Substitute.For(); + private readonly IDistributedCache _distributedCache = Substitute.For(); + private readonly CreateBlogPostHandler _handler; + + public CreateBlogPostHandlerTests() + { + _handler = new CreateBlogPostHandler(_repo, _localCache, _distributedCache); + } + + [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(), Arg.Any()); + _localCache.Received(1).Remove("blog:all"); + await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); + } + + [Fact] + public async Task Handle_RepoThrows_ReturnsFailResult() + { + // Arrange + var command = new CreateBlogPostCommand("Title", "Content", "Author"); + _repo.AddAsync(Arg.Any(), Arg.Any()) + .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"); + } } diff --git a/tests/Unit.Tests/Handlers/DeleteBlogPostHandlerTests.cs b/tests/Unit.Tests/Handlers/DeleteBlogPostHandlerTests.cs index 811a7bd0..843d5f68 100644 --- a/tests/Unit.Tests/Handlers/DeleteBlogPostHandlerTests.cs +++ b/tests/Unit.Tests/Handlers/DeleteBlogPostHandlerTests.cs @@ -1,77 +1,70 @@ -using Domain.Abstractions; -using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Interfaces; using MyBlog.Web.Features.BlogPosts.Delete; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace MyBlog.Unit.Tests.Handlers; public class DeleteBlogPostHandlerTests { - private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IMemoryCache _localCache = Substitute.For(); - private readonly IDistributedCache _distributedCache = Substitute.For(); - private readonly DeleteBlogPostHandler _handler; + private readonly IBlogPostRepository _repo = Substitute.For(); + private readonly IMemoryCache _localCache = Substitute.For(); + private readonly IDistributedCache _distributedCache = Substitute.For(); + private readonly DeleteBlogPostHandler _handler; - public DeleteBlogPostHandlerTests() - { - _handler = new DeleteBlogPostHandler(_repo, _localCache, _distributedCache); - } + public DeleteBlogPostHandlerTests() + { + _handler = new DeleteBlogPostHandler(_repo, _localCache, _distributedCache); + } - [Fact] - public async Task Handle_Success_DeletesAndInvalidatesBothCaches() - { - // Arrange - var id = Guid.NewGuid(); - var command = new DeleteBlogPostCommand(id); + [Fact] + public async Task Handle_Success_DeletesAndInvalidatesBothCaches() + { + // Arrange + var id = Guid.NewGuid(); + var command = new DeleteBlogPostCommand(id); - // Act - var result = await _handler.Handle(command, CancellationToken.None); + // Act + var result = await _handler.Handle(command, CancellationToken.None); - // Assert - result.Success.Should().BeTrue(); - await _repo.Received(1).DeleteAsync(id, Arg.Any()); - _localCache.Received(1).Remove("blog:all"); - _localCache.Received(1).Remove($"blog:{id}"); - await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); - await _distributedCache.Received(1).RemoveAsync($"blog:{id}", Arg.Any()); - } + // Assert + result.Success.Should().BeTrue(); + await _repo.Received(1).DeleteAsync(id, Arg.Any()); + _localCache.Received(1).Remove("blog:all"); + _localCache.Received(1).Remove($"blog:{id}"); + await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); + await _distributedCache.Received(1).RemoveAsync($"blog:{id}", Arg.Any()); + } - [Fact] - public async Task Handle_RepoThrows_ReturnsFailResult() - { - // Arrange - var id = Guid.NewGuid(); - var command = new DeleteBlogPostCommand(id); - _repo.DeleteAsync(id, Arg.Any()) - .ThrowsAsync(new InvalidOperationException("delete failed")); + [Fact] + public async Task Handle_RepoThrows_ReturnsFailResult() + { + // Arrange + var id = Guid.NewGuid(); + var command = new DeleteBlogPostCommand(id); + _repo.DeleteAsync(id, Arg.Any()) + .ThrowsAsync(new InvalidOperationException("delete failed")); - // Act - var result = await _handler.Handle(command, CancellationToken.None); + // Act + var result = await _handler.Handle(command, CancellationToken.None); - // Assert - result.Failure.Should().BeTrue(); - result.Error.Should().Contain("delete failed"); - } + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Contain("delete failed"); + } - [Fact] - public async Task Handle_ConcurrentDelete_ReturnsConcurrencyErrorCode() - { - // Arrange - var id = Guid.NewGuid(); - var command = new DeleteBlogPostCommand(id); - _repo.DeleteAsync(id, Arg.Any()) - .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); + [Fact] + public async Task Handle_ConcurrentDelete_ReturnsConcurrencyErrorCode() + { + // Arrange + var id = Guid.NewGuid(); + var command = new DeleteBlogPostCommand(id); + _repo.DeleteAsync(id, Arg.Any()) + .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); - // Act - var result = await _handler.Handle(command, CancellationToken.None); + // Act + var result = await _handler.Handle(command, CancellationToken.None); - // Assert - result.Failure.Should().BeTrue(); - result.ErrorCode.Should().Be(ResultErrorCode.Concurrency); - } + // Assert + result.Failure.Should().BeTrue(); + result.ErrorCode.Should().Be(ResultErrorCode.Concurrency); + } } diff --git a/tests/Unit.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Unit.Tests/Handlers/EditBlogPostHandlerTests.cs index 436da753..92bb9fc0 100644 --- a/tests/Unit.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Unit.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -1,154 +1,146 @@ using System.Text.Json; -using Domain.Abstractions; -using FluentAssertions; + using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Entities; -using MyBlog.Domain.Interfaces; -using MyBlog.Web.Data; using MyBlog.Web.Features.BlogPosts.Edit; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace MyBlog.Unit.Tests.Handlers; public class EditBlogPostHandlerTests { - private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IMemoryCache _localCache = Substitute.For(); - private readonly IDistributedCache _distributedCache = Substitute.For(); - private readonly ICacheEntry _cacheEntry = Substitute.For(); - private readonly EditBlogPostHandler _handler; - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - - public EditBlogPostHandlerTests() - { - // IMemoryCache.Set is an extension that calls CreateEntry — mock it so Set doesn't throw - _localCache.CreateEntry(Arg.Any()).Returns(_cacheEntry); - _handler = new EditBlogPostHandler(_repo, _localCache, _distributedCache); - } - - // ── 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()).Returns(post); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - await _repo.Received(1).UpdateAsync(post, Arg.Any()); - _localCache.Received(1).Remove("blog:all"); - _localCache.Received(1).Remove($"blog:{post.Id}"); - await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); - await _distributedCache.Received(1).RemoveAsync($"blog:{post.Id}", Arg.Any()); - post.Title.Should().Be("New Title"); - post.Content.Should().Be("New Content"); - } - - [Fact] - public async Task HandleEdit_NotFound_ReturnsFailResult() - { - // Arrange - var id = Guid.NewGuid(); - var command = new EditBlogPostCommand(id, "T", "C"); - _repo.GetByIdAsync(id, Arg.Any()).Returns((BlogPost?)null); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Failure.Should().BeTrue(); - result.Error.Should().Contain(id.ToString()); - } - - // ── GetById tests ───────────────────────────────────────────────────────── - - [Fact] - public async Task HandleGetById_L1CacheHit_ReturnsCachedDtoWithoutRepo() - { - // Arrange - var id = Guid.NewGuid(); - var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal) - .Returns(x => { x[1] = (object)dto; return true; }); - - // Act - var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.Id.Should().Be(id); - await _repo.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task HandleGetById_CacheMissRepoReturnsNull_ReturnsOkWithNull() - { - // Arrange - var id = Guid.NewGuid(); - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); - _distributedCache.GetAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - _repo.GetByIdAsync(id, Arg.Any()).Returns((BlogPost?)null); - - // Act - var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().BeNull(); - } - - [Fact] - public async Task HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndCachesBoth() - { - // Arrange - var post = BlogPost.Create("Title", "Content", "Author"); - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); - _distributedCache.GetAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); - - // Act - var result = await _handler.Handle(new GetBlogPostByIdQuery(post.Id), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.Title.Should().Be("Title"); - // IMemoryCache.Set calls CreateEntry — verify L1 was populated - _localCache.Received(1).CreateEntry(Arg.Any()); - await _distributedCache.Received(1).SetAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task HandleEdit_ConcurrentUpdate_ReturnsConcurrencyErrorCode() - { - // Arrange - var post = BlogPost.Create("Title", "Content", "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()) - .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Failure.Should().BeTrue(); - result.ErrorCode.Should().Be(ResultErrorCode.Concurrency); - } + private readonly IBlogPostRepository _repo = Substitute.For(); + private readonly IMemoryCache _localCache = Substitute.For(); + private readonly IDistributedCache _distributedCache = Substitute.For(); + private readonly ICacheEntry _cacheEntry = Substitute.For(); + private readonly EditBlogPostHandler _handler; + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public EditBlogPostHandlerTests() + { + // IMemoryCache.Set is an extension that calls CreateEntry — mock it so Set doesn't throw + _localCache.CreateEntry(Arg.Any()).Returns(_cacheEntry); + _handler = new EditBlogPostHandler(_repo, _localCache, _distributedCache); + } + + // ── 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()).Returns(post); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + await _repo.Received(1).UpdateAsync(post, Arg.Any()); + _localCache.Received(1).Remove("blog:all"); + _localCache.Received(1).Remove($"blog:{post.Id}"); + await _distributedCache.Received(1).RemoveAsync("blog:all", Arg.Any()); + await _distributedCache.Received(1).RemoveAsync($"blog:{post.Id}", Arg.Any()); + post.Title.Should().Be("New Title"); + post.Content.Should().Be("New Content"); + } + + [Fact] + public async Task HandleEdit_NotFound_ReturnsFailResult() + { + // Arrange + var id = Guid.NewGuid(); + var command = new EditBlogPostCommand(id, "T", "C"); + _repo.GetByIdAsync(id, Arg.Any()).Returns((BlogPost?)null); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Contain(id.ToString()); + } + + // ── GetById tests ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleGetById_L1CacheHit_ReturnsCachedDtoWithoutRepo() + { + // Arrange + var id = Guid.NewGuid(); + var dto = new BlogPostDto(id, "T", "C", "A", DateTime.UtcNow, null, false); + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal) + .Returns(x => { x[1] = (object)dto; return true; }); + + // Act + var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(id); + await _repo.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleGetById_CacheMissRepoReturnsNull_ReturnsOkWithNull() + { + // Arrange + var id = Guid.NewGuid(); + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); + _distributedCache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + _repo.GetByIdAsync(id, Arg.Any()).Returns((BlogPost?)null); + + // Act + var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndCachesBoth() + { + // Arrange + var post = BlogPost.Create("Title", "Content", "Author"); + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); + _distributedCache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); + + // Act + var result = await _handler.Handle(new GetBlogPostByIdQuery(post.Id), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Title.Should().Be("Title"); + // IMemoryCache.Set calls CreateEntry — verify L1 was populated + _localCache.Received(1).CreateEntry(Arg.Any()); + await _distributedCache.Received(1).SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleEdit_ConcurrentUpdate_ReturnsConcurrencyErrorCode() + { + // Arrange + var post = BlogPost.Create("Title", "Content", "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()) + .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.ErrorCode.Should().Be(ResultErrorCode.Concurrency); + } } diff --git a/tests/Unit.Tests/Handlers/GetBlogPostsHandlerTests.cs b/tests/Unit.Tests/Handlers/GetBlogPostsHandlerTests.cs index 96366153..f38d8dac 100644 --- a/tests/Unit.Tests/Handlers/GetBlogPostsHandlerTests.cs +++ b/tests/Unit.Tests/Handlers/GetBlogPostsHandlerTests.cs @@ -1,123 +1,115 @@ using System.Text.Json; -using Domain.Abstractions; -using FluentAssertions; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using MyBlog.Domain.Entities; -using MyBlog.Domain.Interfaces; -using MyBlog.Web.Data; + using MyBlog.Web.Features.BlogPosts.List; -using NSubstitute; -using NSubstitute.ExceptionExtensions; namespace MyBlog.Unit.Tests.Handlers; public class GetBlogPostsHandlerTests { - private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IMemoryCache _localCache = Substitute.For(); - private readonly IDistributedCache _distributedCache = Substitute.For(); - private readonly ICacheEntry _cacheEntry = Substitute.For(); - private readonly GetBlogPostsHandler _handler; - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - - public GetBlogPostsHandlerTests() - { - // IMemoryCache.Set is an extension that calls CreateEntry — mock it so Set doesn't throw - _localCache.CreateEntry(Arg.Any()).Returns(_cacheEntry); - _handler = new GetBlogPostsHandler(_repo, _localCache, _distributedCache); - } - - private static List MakeDtos() => - [ - new(Guid.NewGuid(), "T1", "C1", "A1", DateTime.UtcNow, null, false), - new(Guid.NewGuid(), "T2", "C2", "A2", DateTime.UtcNow, null, true), - ]; - - [Fact] - public async Task Handle_L1CacheHit_ReturnsCachedDataWithoutCallingRepo() - { - // Arrange - var cachedList = MakeDtos(); - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal) - .Returns(x => { x[1] = (object)cachedList; return true; }); - - // Act - var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().HaveCount(2); - await _repo.DidNotReceive().GetAllAsync(Arg.Any()); - await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_L2CacheHit_DeserializesAndPopulatesL1() - { - // Arrange - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); - - var dtos = MakeDtos(); - var bytes = JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts); - _distributedCache.GetAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(bytes)); - - // Act - var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().HaveCount(2); - // IMemoryCache.Set calls CreateEntry — verify L1 was populated - _localCache.Received(1).CreateEntry(Arg.Any()); - await _repo.DidNotReceive().GetAllAsync(Arg.Any()); - } - - [Fact] - public async Task Handle_CacheMiss_CallsRepoAndPopulatesBothCaches() - { - // Arrange - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); - _distributedCache.GetAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var post1 = BlogPost.Create("T1", "C1", "A1"); - var post2 = BlogPost.Create("T2", "C2", "A2"); - _repo.GetAllAsync(Arg.Any()) - .Returns(new List { post1, post2 }); - - // Act - var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); - - // Assert - result.Success.Should().BeTrue(); - result.Value.Should().HaveCount(2); - // Verify both caches were populated - _localCache.Received(1).CreateEntry(Arg.Any()); - await _distributedCache.Received(1).SetAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_RepoThrows_ReturnsFailResult() - { - // Arrange - object? outVal = null; - _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); - _distributedCache.GetAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - _repo.GetAllAsync(Arg.Any()) - .ThrowsAsync(new InvalidOperationException("db error")); - - // Act - var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); - - // Assert - result.Failure.Should().BeTrue(); - result.Error.Should().Contain("db error"); - } + private readonly IBlogPostRepository _repo = Substitute.For(); + private readonly IMemoryCache _localCache = Substitute.For(); + private readonly IDistributedCache _distributedCache = Substitute.For(); + private readonly ICacheEntry _cacheEntry = Substitute.For(); + private readonly GetBlogPostsHandler _handler; + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public GetBlogPostsHandlerTests() + { + // IMemoryCache.Set is an extension that calls CreateEntry — mock it so Set doesn't throw + _localCache.CreateEntry(Arg.Any()).Returns(_cacheEntry); + _handler = new GetBlogPostsHandler(_repo, _localCache, _distributedCache); + } + + private static List MakeDtos() => + [ + new(Guid.NewGuid(), "T1", "C1", "A1", DateTime.UtcNow, null, false), + new(Guid.NewGuid(), "T2", "C2", "A2", DateTime.UtcNow, null, true), + ]; + + [Fact] + public async Task Handle_L1CacheHit_ReturnsCachedDataWithoutCallingRepo() + { + // Arrange + var cachedList = MakeDtos(); + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal) + .Returns(x => { x[1] = (object)cachedList; return true; }); + + // Act + var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().HaveCount(2); + await _repo.DidNotReceive().GetAllAsync(Arg.Any()); + await _distributedCache.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_L2CacheHit_DeserializesAndPopulatesL1() + { + // Arrange + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); + + var dtos = MakeDtos(); + var bytes = JsonSerializer.SerializeToUtf8Bytes(dtos, JsonOpts); + _distributedCache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(bytes)); + + // Act + var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().HaveCount(2); + // IMemoryCache.Set calls CreateEntry — verify L1 was populated + _localCache.Received(1).CreateEntry(Arg.Any()); + await _repo.DidNotReceive().GetAllAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_CacheMiss_CallsRepoAndPopulatesBothCaches() + { + // Arrange + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); + _distributedCache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var post1 = BlogPost.Create("T1", "C1", "A1"); + var post2 = BlogPost.Create("T2", "C2", "A2"); + _repo.GetAllAsync(Arg.Any()) + .Returns(new List { post1, post2 }); + + // Act + var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Value.Should().HaveCount(2); + // Verify both caches were populated + _localCache.Received(1).CreateEntry(Arg.Any()); + await _distributedCache.Received(1).SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_RepoThrows_ReturnsFailResult() + { + // Arrange + object? outVal = null; + _localCache.TryGetValue(Arg.Any(), out outVal).Returns(false); + _distributedCache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + _repo.GetAllAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("db error")); + + // Act + var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Contain("db error"); + } } diff --git a/tests/Unit.Tests/ResultTests.cs b/tests/Unit.Tests/ResultTests.cs index 5cc4172d..0cfb83a8 100644 --- a/tests/Unit.Tests/ResultTests.cs +++ b/tests/Unit.Tests/ResultTests.cs @@ -6,88 +6,86 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using Domain.Abstractions; -using FluentAssertions; namespace MyBlog.Unit.Tests; public class ResultTests { - [Fact] - public void Ok_CreatesSuccessfulNonGenericResult() - { - // Arrange (none) - // Act - var result = Result.Ok(); + [Fact] + public void Ok_CreatesSuccessfulNonGenericResult() + { + // Arrange (none) + // Act + var result = Result.Ok(); - result.Success.Should().BeTrue(); - result.Failure.Should().BeFalse(); - result.Error.Should().BeNull(); - result.ErrorCode.Should().Be(ResultErrorCode.None); - } + result.Success.Should().BeTrue(); + result.Failure.Should().BeFalse(); + result.Error.Should().BeNull(); + result.ErrorCode.Should().Be(ResultErrorCode.None); + } - [Fact] - public void Fail_CreatesFailedNonGenericResultWithCodeAndDetails() - { - // Arrange (none) - // Act - var result = Result.Fail("boom", ResultErrorCode.Validation, new { Field = "Title" }); + [Fact] + public void Fail_CreatesFailedNonGenericResultWithCodeAndDetails() + { + // Arrange (none) + // Act + var result = Result.Fail("boom", ResultErrorCode.Validation, new { Field = "Title" }); - result.Success.Should().BeFalse(); - result.Failure.Should().BeTrue(); - result.Error.Should().Be("boom"); - result.ErrorCode.Should().Be(ResultErrorCode.Validation); - result.Details.Should().NotBeNull(); - } + result.Success.Should().BeFalse(); + result.Failure.Should().BeTrue(); + result.Error.Should().Be("boom"); + result.ErrorCode.Should().Be(ResultErrorCode.Validation); + result.Details.Should().NotBeNull(); + } - [Fact] - public void GenericOk_CarriesValue() - { - // Arrange (none) - // Act - var result = Result.Ok("hello"); + [Fact] + public void GenericOk_CarriesValue() + { + // Arrange (none) + // Act + var result = Result.Ok("hello"); - result.Success.Should().BeTrue(); - result.Value.Should().Be("hello"); - } + result.Success.Should().BeTrue(); + result.Value.Should().Be("hello"); + } - [Fact] - public void GenericFail_CreatesFailedResultWithCode() - { - // Arrange (none) - // Act - var result = Result.Fail("missing", ResultErrorCode.NotFound); + [Fact] + public void GenericFail_CreatesFailedResultWithCode() + { + // Arrange (none) + // Act + var result = Result.Fail("missing", ResultErrorCode.NotFound); - result.Success.Should().BeFalse(); - result.Error.Should().Be("missing"); - result.ErrorCode.Should().Be(ResultErrorCode.NotFound); - result.Value.Should().BeNull(); - } + result.Success.Should().BeFalse(); + result.Error.Should().Be("missing"); + result.ErrorCode.Should().Be(ResultErrorCode.NotFound); + result.Value.Should().BeNull(); + } - [Fact] - public void FromValue_ReturnsFailedResultWhenValueIsNull() - { - // Arrange - string? value = null; + [Fact] + public void FromValue_ReturnsFailedResultWhenValueIsNull() + { + // Arrange + string? value = null; - // Act - var result = Result.FromValue(value); + // Act + var result = Result.FromValue(value); - result.Success.Should().BeFalse(); - result.Error.Should().Be("Provided value is null."); - } + result.Success.Should().BeFalse(); + result.Error.Should().Be("Provided value is null."); + } - [Fact] - public void ImplicitConversions_WorkForGenericResult() - { - // Arrange - Result result = "hello"; + [Fact] + public void ImplicitConversions_WorkForGenericResult() + { + // Arrange + Result result = "hello"; - // Act - string? value = result; + // Act + string? value = result; - // Assert - value.Should().Be("hello"); - result.Value.Should().Be("hello"); - } + // Assert + value.Should().Be("hello"); + result.Value.Should().Be("hello"); + } } diff --git a/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs b/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs index f838b9ee..85772f0d 100644 --- a/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs +++ b/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs @@ -6,95 +6,94 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using System.Security.Claims; -using FluentAssertions; using Microsoft.Extensions.Configuration; + using MyBlog.Web.Security; namespace MyBlog.Unit.Tests.Security; public class RoleClaimsHelperTests { - [Fact] - public void GetRoleClaimTypes_UsesConfiguredDistinctValues_WhenPresent() - { - // Arrange - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Auth0:RoleClaimTypes:0"] = "roles", - ["Auth0:RoleClaimTypes:1"] = "https://myblog/roles", - ["Auth0:RoleClaimTypes:2"] = "roles" - }) - .Build(); - - // Act - var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); - - // Assert - result.Should().BeEquivalentTo(["roles", "https://myblog/roles"]); - } - - [Fact] - public void GetRoleClaimTypes_ReturnsDefaults_WhenConfigurationIsMissing() - { - // Arrange - var configuration = new ConfigurationBuilder().Build(); - - // Act - var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); - - result.Should().BeEquivalentTo(RoleClaimsHelper.DefaultRoleClaimTypes); - } - - [Theory] - [InlineData("Admin", new[] { "Admin" })] - [InlineData("Admin,Author", new[] { "Admin", "Author" })] - [InlineData(" [\"Admin\",\"Author\"] ", new[] { "Admin", "Author" })] - public void ExpandRoleValues_ParsesSupportedFormats(string input, string[] expected) - { - // Arrange (none) - // Act - var result = RoleClaimsHelper.ExpandRoleValues(input); - - result.Should().BeEquivalentTo(expected); - } - - [Fact] - public void AddRoleClaims_AddsExpandedRoleClaimsWithoutDuplicates() - { - // Arrange - var identity = new ClaimsIdentity(new[] - { - new Claim("roles", "Admin,Author"), - new Claim(ClaimTypes.Role, "Admin") - }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role); - - // Act - RoleClaimsHelper.AddRoleClaims(identity, ["roles"]); - - // Assert - identity.FindAll(ClaimTypes.Role) - .Select(claim => claim.Value) - .Should() - .BeEquivalentTo(["Admin", "Author"]); - } - - [Fact] - public void GetRoles_CollectsDistinctRolesAcrossMultipleClaimTypes() - { - // Arrange - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(ClaimTypes.Role, "Admin"), - new Claim("https://myblog/roles", "[\"Author\",\"Admin\"]"), - new Claim("roles", "Editor,Author") - }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); - - // Act - var result = RoleClaimsHelper.GetRoles(principal); - - // Assert - result.Should().Equal("Admin", "Author", "Editor"); - } + [Fact] + public void GetRoleClaimTypes_UsesConfiguredDistinctValues_WhenPresent() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Auth0:RoleClaimTypes:0"] = "roles", + ["Auth0:RoleClaimTypes:1"] = "https://myblog/roles", + ["Auth0:RoleClaimTypes:2"] = "roles" + }) + .Build(); + + // Act + var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); + + // Assert + result.Should().BeEquivalentTo(["roles", "https://myblog/roles"]); + } + + [Fact] + public void GetRoleClaimTypes_ReturnsDefaults_WhenConfigurationIsMissing() + { + // Arrange + var configuration = new ConfigurationBuilder().Build(); + + // Act + var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); + + result.Should().BeEquivalentTo(RoleClaimsHelper.DefaultRoleClaimTypes); + } + + [Theory] + [InlineData("Admin", new[] { "Admin" })] + [InlineData("Admin,Author", new[] { "Admin", "Author" })] + [InlineData(" [\"Admin\",\"Author\"] ", new[] { "Admin", "Author" })] + public void ExpandRoleValues_ParsesSupportedFormats(string input, string[] expected) + { + // Arrange (none) + // Act + var result = RoleClaimsHelper.ExpandRoleValues(input); + + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public void AddRoleClaims_AddsExpandedRoleClaimsWithoutDuplicates() + { + // Arrange + var identity = new ClaimsIdentity(new[] + { + new Claim("roles", "Admin,Author"), + new Claim(ClaimTypes.Role, "Admin") + }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role); + + // Act + RoleClaimsHelper.AddRoleClaims(identity, ["roles"]); + + // Assert + identity.FindAll(ClaimTypes.Role) + .Select(claim => claim.Value) + .Should() + .BeEquivalentTo(["Admin", "Author"]); + } + + [Fact] + public void GetRoles_CollectsDistinctRolesAcrossMultipleClaimTypes() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Admin"), + new Claim("https://myblog/roles", "[\"Author\",\"Admin\"]"), + new Claim("roles", "Editor,Author") + }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + + // Act + var result = RoleClaimsHelper.GetRoles(principal); + + // Assert + result.Should().Equal("Admin", "Author", "Editor"); + } } diff --git a/tests/Unit.Tests/Testing/TestAuthorizationService.cs b/tests/Unit.Tests/Testing/TestAuthorizationService.cs index cecfe657..bd336861 100644 --- a/tests/Unit.Tests/Testing/TestAuthorizationService.cs +++ b/tests/Unit.Tests/Testing/TestAuthorizationService.cs @@ -6,44 +6,42 @@ // Solution Name : MyBlog // Project Name : Unit.Tests // ============================================= -using System.Security.Claims; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; namespace MyBlog.Unit.Tests.Testing; internal sealed class TestAuthorizationService : IAuthorizationService { - public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) - { - foreach (var requirement in requirements) - { - switch (requirement) - { - case DenyAnonymousAuthorizationRequirement: - if (user.Identity?.IsAuthenticated != true) - { - return Task.FromResult(AuthorizationResult.Failed()); - } - break; + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + foreach (var requirement in requirements) + { + switch (requirement) + { + case DenyAnonymousAuthorizationRequirement: + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(AuthorizationResult.Failed()); + } + break; - case RolesAuthorizationRequirement rolesRequirement: - if (!rolesRequirement.AllowedRoles.Any(user.IsInRole)) - { - return Task.FromResult(AuthorizationResult.Failed()); - } - break; - } - } + case RolesAuthorizationRequirement rolesRequirement: + if (!rolesRequirement.AllowedRoles.Any(user.IsInRole)) + { + return Task.FromResult(AuthorizationResult.Failed()); + } + break; + } + } - return Task.FromResult(AuthorizationResult.Success()); - } + return Task.FromResult(AuthorizationResult.Success()); + } - public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) - { - // Policy evaluation not implemented; grants access to authenticated users only. - return Task.FromResult(user.Identity?.IsAuthenticated == true - ? AuthorizationResult.Success() - : AuthorizationResult.Failed()); - } + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + { + // Policy evaluation not implemented; grants access to authenticated users only. + return Task.FromResult(user.Identity?.IsAuthenticated == true + ? AuthorizationResult.Success() + : AuthorizationResult.Failed()); + } }