From 14498b0372832851b104cb4c9762c2b910a106c7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:42:05 +0000 Subject: [PATCH 1/2] Add .NET Core 6.0 Web API project with read-only endpoints for Blogs, Tags, and Posts Phase 2 of the .NET Framework to .NET Core migration: - Create SampleWebApp.Core class library with: - Entity classes (Blog, Post, Tag) - DTOs (BlogDto, TagDto, SimplePostDto, DetailPostDto) - MediatR handlers for read-only queries - AutoMapper mapping profile - Create SampleWebApp.Api Web API project with: - DbContext configured for SQL Server (same database as existing app) - BlogsController with GET /api/blogs and GET /api/blogs/{id} - TagsController with GET /api/tags and GET /api/tags/{id} - PostsController with GET /api/posts?blogId={blogId} and GET /api/posts/{id} - Swagger/OpenAPI documentation - MediatR and AutoMapper integration Co-Authored-By: Abhay Aggarwal --- .../Controllers/BlogsController.cs | 37 ++++++++++++++ .../Controllers/PostsController.cs | 37 ++++++++++++++ .../Controllers/TagsController.cs | 37 ++++++++++++++ .../Data/SampleWebAppDbContext.cs | 50 +++++++++++++++++++ SampleWebApp.Api/Program.cs | 43 ++++++++++++++++ .../Properties/launchSettings.json | 31 ++++++++++++ SampleWebApp.Api/SampleWebApp.Api.csproj | 24 +++++++++ SampleWebApp.Api/appsettings.Development.json | 8 +++ SampleWebApp.Api/appsettings.json | 12 +++++ SampleWebApp.Core/DTOs/BlogDto.cs | 10 ++++ SampleWebApp.Core/DTOs/DetailPostDto.cs | 14 ++++++ SampleWebApp.Core/DTOs/SimplePostDto.cs | 13 +++++ SampleWebApp.Core/DTOs/TagDto.cs | 10 ++++ SampleWebApp.Core/Entities/Blog.cs | 21 ++++++++ SampleWebApp.Core/Entities/Post.cs | 23 +++++++++ SampleWebApp.Core/Entities/Tag.cs | 19 +++++++ .../Blogs/Queries/GetBlogByIdHandler.cs | 28 +++++++++++ .../Blogs/Queries/GetBlogByIdQuery.cs | 10 ++++ .../Handlers/Blogs/Queries/GetBlogsHandler.cs | 29 +++++++++++ .../Handlers/Blogs/Queries/GetBlogsQuery.cs | 9 ++++ .../Posts/Queries/GetPostByIdHandler.cs | 29 +++++++++++ .../Posts/Queries/GetPostByIdQuery.cs | 10 ++++ .../Handlers/Posts/Queries/GetPostsHandler.cs | 38 ++++++++++++++ .../Handlers/Posts/Queries/GetPostsQuery.cs | 10 ++++ .../Tags/Queries/GetTagByIdHandler.cs | 28 +++++++++++ .../Handlers/Tags/Queries/GetTagByIdQuery.cs | 10 ++++ .../Handlers/Tags/Queries/GetTagsHandler.cs | 29 +++++++++++ .../Handlers/Tags/Queries/GetTagsQuery.cs | 9 ++++ SampleWebApp.Core/Mapping/MappingProfile.cs | 26 ++++++++++ SampleWebApp.Core/SampleWebApp.Core.csproj | 15 ++++++ 30 files changed, 669 insertions(+) create mode 100644 SampleWebApp.Api/Controllers/BlogsController.cs create mode 100644 SampleWebApp.Api/Controllers/PostsController.cs create mode 100644 SampleWebApp.Api/Controllers/TagsController.cs create mode 100644 SampleWebApp.Api/Data/SampleWebAppDbContext.cs create mode 100644 SampleWebApp.Api/Program.cs create mode 100644 SampleWebApp.Api/Properties/launchSettings.json create mode 100644 SampleWebApp.Api/SampleWebApp.Api.csproj create mode 100644 SampleWebApp.Api/appsettings.Development.json create mode 100644 SampleWebApp.Api/appsettings.json create mode 100644 SampleWebApp.Core/DTOs/BlogDto.cs create mode 100644 SampleWebApp.Core/DTOs/DetailPostDto.cs create mode 100644 SampleWebApp.Core/DTOs/SimplePostDto.cs create mode 100644 SampleWebApp.Core/DTOs/TagDto.cs create mode 100644 SampleWebApp.Core/Entities/Blog.cs create mode 100644 SampleWebApp.Core/Entities/Post.cs create mode 100644 SampleWebApp.Core/Entities/Tag.cs create mode 100644 SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdQuery.cs create mode 100644 SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsQuery.cs create mode 100644 SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdQuery.cs create mode 100644 SampleWebApp.Core/Handlers/Posts/Queries/GetPostsHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Posts/Queries/GetPostsQuery.cs create mode 100644 SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdQuery.cs create mode 100644 SampleWebApp.Core/Handlers/Tags/Queries/GetTagsHandler.cs create mode 100644 SampleWebApp.Core/Handlers/Tags/Queries/GetTagsQuery.cs create mode 100644 SampleWebApp.Core/Mapping/MappingProfile.cs create mode 100644 SampleWebApp.Core/SampleWebApp.Core.csproj diff --git a/SampleWebApp.Api/Controllers/BlogsController.cs b/SampleWebApp.Api/Controllers/BlogsController.cs new file mode 100644 index 0000000..89a938a --- /dev/null +++ b/SampleWebApp.Api/Controllers/BlogsController.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SampleWebApp.Core.DTOs; +using SampleWebApp.Core.Handlers.Blogs.Queries; + +namespace SampleWebApp.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class BlogsController : ControllerBase + { + private readonly IMediator _mediator; + + public BlogsController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet] + public async Task>> GetBlogs() + { + var blogs = await _mediator.Send(new GetBlogsQuery()); + return Ok(blogs); + } + + [HttpGet("{id}")] + public async Task> GetBlog(int id) + { + var blog = await _mediator.Send(new GetBlogByIdQuery { BlogId = id }); + if (blog == null) + { + return NotFound(); + } + return Ok(blog); + } + } +} diff --git a/SampleWebApp.Api/Controllers/PostsController.cs b/SampleWebApp.Api/Controllers/PostsController.cs new file mode 100644 index 0000000..a5d59cb --- /dev/null +++ b/SampleWebApp.Api/Controllers/PostsController.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SampleWebApp.Core.DTOs; +using SampleWebApp.Core.Handlers.Posts.Queries; + +namespace SampleWebApp.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class PostsController : ControllerBase + { + private readonly IMediator _mediator; + + public PostsController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet] + public async Task>> GetPosts([FromQuery] int? blogId) + { + var posts = await _mediator.Send(new GetPostsQuery { BlogId = blogId }); + return Ok(posts); + } + + [HttpGet("{id}")] + public async Task> GetPost(int id) + { + var post = await _mediator.Send(new GetPostByIdQuery { PostId = id }); + if (post == null) + { + return NotFound(); + } + return Ok(post); + } + } +} diff --git a/SampleWebApp.Api/Controllers/TagsController.cs b/SampleWebApp.Api/Controllers/TagsController.cs new file mode 100644 index 0000000..56a0c8a --- /dev/null +++ b/SampleWebApp.Api/Controllers/TagsController.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SampleWebApp.Core.DTOs; +using SampleWebApp.Core.Handlers.Tags.Queries; + +namespace SampleWebApp.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class TagsController : ControllerBase + { + private readonly IMediator _mediator; + + public TagsController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpGet] + public async Task>> GetTags() + { + var tags = await _mediator.Send(new GetTagsQuery()); + return Ok(tags); + } + + [HttpGet("{id}")] + public async Task> GetTag(int id) + { + var tag = await _mediator.Send(new GetTagByIdQuery { TagId = id }); + if (tag == null) + { + return NotFound(); + } + return Ok(tag); + } + } +} diff --git a/SampleWebApp.Api/Data/SampleWebAppDbContext.cs b/SampleWebApp.Api/Data/SampleWebAppDbContext.cs new file mode 100644 index 0000000..a5294df --- /dev/null +++ b/SampleWebApp.Api/Data/SampleWebAppDbContext.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.Entities; + +namespace SampleWebApp.Api.Data +{ + public class SampleWebAppDbContext : DbContext + { + public SampleWebAppDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Blogs { get; set; } = null!; + public DbSet Posts { get; set; } = null!; + public DbSet Tags { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.BlogId); + entity.Property(e => e.Name).IsRequired().HasMaxLength(64); + entity.Property(e => e.EmailAddress).IsRequired().HasMaxLength(256); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PostId); + entity.Property(e => e.Title).IsRequired().HasMaxLength(128); + entity.Property(e => e.Content).IsRequired(); + + entity.HasOne(p => p.Blogger) + .WithMany(b => b.Posts) + .HasForeignKey(p => p.BlogId); + + entity.HasMany(p => p.Tags) + .WithMany(t => t.Posts); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.TagId); + entity.Property(e => e.Slug).IsRequired().HasMaxLength(64); + entity.Property(e => e.Name).IsRequired().HasMaxLength(128); + }); + } + } +} diff --git a/SampleWebApp.Api/Program.cs b/SampleWebApp.Api/Program.cs new file mode 100644 index 0000000..0898f87 --- /dev/null +++ b/SampleWebApp.Api/Program.cs @@ -0,0 +1,43 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Api.Data; +using SampleWebApp.Core.Mapping; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +// Configure DbContext +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register DbContext as DbContext for handlers +builder.Services.AddScoped(provider => provider.GetRequiredService()); + +// Configure MediatR +builder.Services.AddMediatR(typeof(SampleWebApp.Core.Handlers.Blogs.Queries.GetBlogsQuery).Assembly); + +// Configure AutoMapper +builder.Services.AddAutoMapper(typeof(MappingProfile).Assembly); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/SampleWebApp.Api/Properties/launchSettings.json b/SampleWebApp.Api/Properties/launchSettings.json new file mode 100644 index 0000000..7abed19 --- /dev/null +++ b/SampleWebApp.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:19497", + "sslPort": 44396 + } + }, + "profiles": { + "SampleWebApp.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7089;http://localhost:5111", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/SampleWebApp.Api/SampleWebApp.Api.csproj b/SampleWebApp.Api/SampleWebApp.Api.csproj new file mode 100644 index 0000000..7f8a5f4 --- /dev/null +++ b/SampleWebApp.Api/SampleWebApp.Api.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/SampleWebApp.Api/appsettings.Development.json b/SampleWebApp.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/SampleWebApp.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/SampleWebApp.Api/appsettings.json b/SampleWebApp.Api/appsettings.json new file mode 100644 index 0000000..be9efdb --- /dev/null +++ b/SampleWebApp.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=SampleWebAppDb;Trusted_Connection=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/SampleWebApp.Core/DTOs/BlogDto.cs b/SampleWebApp.Core/DTOs/BlogDto.cs new file mode 100644 index 0000000..bb87dcc --- /dev/null +++ b/SampleWebApp.Core/DTOs/BlogDto.cs @@ -0,0 +1,10 @@ +namespace SampleWebApp.Core.DTOs +{ + public class BlogDto + { + public int BlogId { get; set; } + public string Name { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + public int PostsCount { get; set; } + } +} diff --git a/SampleWebApp.Core/DTOs/DetailPostDto.cs b/SampleWebApp.Core/DTOs/DetailPostDto.cs new file mode 100644 index 0000000..85e33e4 --- /dev/null +++ b/SampleWebApp.Core/DTOs/DetailPostDto.cs @@ -0,0 +1,14 @@ +namespace SampleWebApp.Core.DTOs +{ + public class DetailPostDto + { + public int PostId { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string BloggerName { get; set; } = string.Empty; + public int BlogId { get; set; } + public DateTime LastUpdated { get; set; } + public DateTime LastUpdatedUtc => DateTime.SpecifyKind(LastUpdated, DateTimeKind.Utc); + public ICollection Tags { get; set; } = new List(); + } +} diff --git a/SampleWebApp.Core/DTOs/SimplePostDto.cs b/SampleWebApp.Core/DTOs/SimplePostDto.cs new file mode 100644 index 0000000..3f53169 --- /dev/null +++ b/SampleWebApp.Core/DTOs/SimplePostDto.cs @@ -0,0 +1,13 @@ +namespace SampleWebApp.Core.DTOs +{ + public class SimplePostDto + { + public int PostId { get; set; } + public int BlogId { get; set; } + public string BloggerName { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public DateTime LastUpdated { get; set; } + public DateTime LastUpdatedUtc => DateTime.SpecifyKind(LastUpdated, DateTimeKind.Utc); + public ICollection TagNames { get; set; } = new List(); + } +} diff --git a/SampleWebApp.Core/DTOs/TagDto.cs b/SampleWebApp.Core/DTOs/TagDto.cs new file mode 100644 index 0000000..4828402 --- /dev/null +++ b/SampleWebApp.Core/DTOs/TagDto.cs @@ -0,0 +1,10 @@ +namespace SampleWebApp.Core.DTOs +{ + public class TagDto + { + public int TagId { get; set; } + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public int PostCount { get; set; } + } +} diff --git a/SampleWebApp.Core/Entities/Blog.cs b/SampleWebApp.Core/Entities/Blog.cs new file mode 100644 index 0000000..7869afe --- /dev/null +++ b/SampleWebApp.Core/Entities/Blog.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace SampleWebApp.Core.Entities +{ + public class Blog + { + public int BlogId { get; set; } + + [MinLength(2)] + [MaxLength(64)] + [Required] + public string Name { get; set; } = string.Empty; + + [MaxLength(256)] + [Required] + [EmailAddress] + public string EmailAddress { get; set; } = string.Empty; + + public ICollection Posts { get; set; } = new List(); + } +} diff --git a/SampleWebApp.Core/Entities/Post.cs b/SampleWebApp.Core/Entities/Post.cs new file mode 100644 index 0000000..2abae12 --- /dev/null +++ b/SampleWebApp.Core/Entities/Post.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace SampleWebApp.Core.Entities +{ + public class Post + { + public int PostId { get; set; } + + [MinLength(2), MaxLength(128)] + [Required] + public string Title { get; set; } = string.Empty; + + [Required] + public string Content { get; set; } = string.Empty; + + public DateTime LastUpdated { get; set; } + + public int BlogId { get; set; } + public virtual Blog Blogger { get; set; } = null!; + + public ICollection Tags { get; set; } = new List(); + } +} diff --git a/SampleWebApp.Core/Entities/Tag.cs b/SampleWebApp.Core/Entities/Tag.cs new file mode 100644 index 0000000..a8d1bc6 --- /dev/null +++ b/SampleWebApp.Core/Entities/Tag.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace SampleWebApp.Core.Entities +{ + public class Tag + { + public int TagId { get; set; } + + [MaxLength(64)] + [Required] + public string Slug { get; set; } = string.Empty; + + [MaxLength(128)] + [Required] + public string Name { get; set; } = string.Empty; + + public ICollection Posts { get; set; } = new List(); + } +} diff --git a/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdHandler.cs b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdHandler.cs new file mode 100644 index 0000000..d1c1803 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdHandler.cs @@ -0,0 +1,28 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Blogs.Queries +{ + public class GetBlogByIdHandler : IRequestHandler + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetBlogByIdHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetBlogByIdQuery request, CancellationToken cancellationToken) + { + var blog = await _context.Set() + .Include(b => b.Posts) + .FirstOrDefaultAsync(b => b.BlogId == request.BlogId, cancellationToken); + + return blog == null ? null : _mapper.Map(blog); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdQuery.cs b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdQuery.cs new file mode 100644 index 0000000..4743800 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogByIdQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Blogs.Queries +{ + public class GetBlogByIdQuery : IRequest + { + public int BlogId { get; set; } + } +} diff --git a/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsHandler.cs b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsHandler.cs new file mode 100644 index 0000000..55f97e8 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsHandler.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Blogs.Queries +{ + public class GetBlogsHandler : IRequestHandler> + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetBlogsHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task> Handle(GetBlogsQuery request, CancellationToken cancellationToken) + { + var blogs = await _context.Set() + .Include(b => b.Posts) + .OrderByDescending(b => b.Name) + .ToListAsync(cancellationToken); + + return _mapper.Map>(blogs); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsQuery.cs b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsQuery.cs new file mode 100644 index 0000000..8a30470 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Blogs/Queries/GetBlogsQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Blogs.Queries +{ + public class GetBlogsQuery : IRequest> + { + } +} diff --git a/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdHandler.cs b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdHandler.cs new file mode 100644 index 0000000..92d9702 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdHandler.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Posts.Queries +{ + public class GetPostByIdHandler : IRequestHandler + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetPostByIdHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetPostByIdQuery request, CancellationToken cancellationToken) + { + var post = await _context.Set() + .Include(p => p.Blogger) + .Include(p => p.Tags) + .FirstOrDefaultAsync(p => p.PostId == request.PostId, cancellationToken); + + return post == null ? null : _mapper.Map(post); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdQuery.cs b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdQuery.cs new file mode 100644 index 0000000..457ebc4 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostByIdQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Posts.Queries +{ + public class GetPostByIdQuery : IRequest + { + public int PostId { get; set; } + } +} diff --git a/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsHandler.cs b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsHandler.cs new file mode 100644 index 0000000..7ebed5f --- /dev/null +++ b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsHandler.cs @@ -0,0 +1,38 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Posts.Queries +{ + public class GetPostsHandler : IRequestHandler> + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetPostsHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task> Handle(GetPostsQuery request, CancellationToken cancellationToken) + { + var query = _context.Set() + .Include(p => p.Blogger) + .Include(p => p.Tags) + .AsQueryable(); + + if (request.BlogId.HasValue) + { + query = query.Where(p => p.BlogId == request.BlogId.Value); + } + + var posts = await query + .OrderByDescending(p => p.LastUpdated) + .ToListAsync(cancellationToken); + + return _mapper.Map>(posts); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsQuery.cs b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsQuery.cs new file mode 100644 index 0000000..7be4f77 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Posts/Queries/GetPostsQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Posts.Queries +{ + public class GetPostsQuery : IRequest> + { + public int? BlogId { get; set; } + } +} diff --git a/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdHandler.cs b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdHandler.cs new file mode 100644 index 0000000..8d1ef5f --- /dev/null +++ b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdHandler.cs @@ -0,0 +1,28 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Tags.Queries +{ + public class GetTagByIdHandler : IRequestHandler + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetTagByIdHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetTagByIdQuery request, CancellationToken cancellationToken) + { + var tag = await _context.Set() + .Include(t => t.Posts) + .FirstOrDefaultAsync(t => t.TagId == request.TagId, cancellationToken); + + return tag == null ? null : _mapper.Map(tag); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdQuery.cs b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdQuery.cs new file mode 100644 index 0000000..bdd7e28 --- /dev/null +++ b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagByIdQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Tags.Queries +{ + public class GetTagByIdQuery : IRequest + { + public int TagId { get; set; } + } +} diff --git a/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsHandler.cs b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsHandler.cs new file mode 100644 index 0000000..5b46a4f --- /dev/null +++ b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsHandler.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Tags.Queries +{ + public class GetTagsHandler : IRequestHandler> + { + private readonly DbContext _context; + private readonly IMapper _mapper; + + public GetTagsHandler(DbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task> Handle(GetTagsQuery request, CancellationToken cancellationToken) + { + var tags = await _context.Set() + .Include(t => t.Posts) + .OrderBy(t => t.Name) + .ToListAsync(cancellationToken); + + return _mapper.Map>(tags); + } + } +} diff --git a/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsQuery.cs b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsQuery.cs new file mode 100644 index 0000000..1abb3bd --- /dev/null +++ b/SampleWebApp.Core/Handlers/Tags/Queries/GetTagsQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using SampleWebApp.Core.DTOs; + +namespace SampleWebApp.Core.Handlers.Tags.Queries +{ + public class GetTagsQuery : IRequest> + { + } +} diff --git a/SampleWebApp.Core/Mapping/MappingProfile.cs b/SampleWebApp.Core/Mapping/MappingProfile.cs new file mode 100644 index 0000000..46d614f --- /dev/null +++ b/SampleWebApp.Core/Mapping/MappingProfile.cs @@ -0,0 +1,26 @@ +using AutoMapper; +using SampleWebApp.Core.DTOs; +using SampleWebApp.Core.Entities; + +namespace SampleWebApp.Core.Mapping +{ + public class MappingProfile : Profile + { + public MappingProfile() + { + CreateMap() + .ForMember(dest => dest.PostsCount, opt => opt.MapFrom(src => src.Posts.Count)); + + CreateMap() + .ForMember(dest => dest.PostCount, opt => opt.MapFrom(src => src.Posts.Count)); + + CreateMap() + .ForMember(dest => dest.BloggerName, opt => opt.MapFrom(src => src.Blogger.Name)) + .ForMember(dest => dest.TagNames, opt => opt.MapFrom(src => src.Tags.Select(t => t.Name).ToList())); + + CreateMap() + .ForMember(dest => dest.BloggerName, opt => opt.MapFrom(src => src.Blogger.Name)) + .ForMember(dest => dest.Tags, opt => opt.MapFrom(src => src.Tags)); + } + } +} diff --git a/SampleWebApp.Core/SampleWebApp.Core.csproj b/SampleWebApp.Core/SampleWebApp.Core.csproj new file mode 100644 index 0000000..5e2d6c2 --- /dev/null +++ b/SampleWebApp.Core/SampleWebApp.Core.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + enable + enable + + + + + + + + + From 8afa62d7ba31da57c1d7bd8447b7607162b22e3b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:58:17 +0000 Subject: [PATCH 2/2] Add comprehensive unit and integration tests for .NET Core Web API - Add SampleWebApp.Api.Tests project with xUnit, FluentAssertions, Moq - Add TestDbContextFixture for in-memory database testing - Add AutoMapperFixture for mapping configuration - Add unit tests for Blog handlers (GetBlogsHandler, GetBlogByIdHandler) - Add unit tests for Tag handlers (GetTagsHandler, GetTagByIdHandler) - Add unit tests for Post handlers (GetPostsHandler, GetPostByIdHandler) - Add integration tests for BlogsController, TagsController, PostsController - Add CustomWebApplicationFactory for integration testing with in-memory database - Update Program.cs with partial class for test accessibility All 42 tests pass successfully. Co-Authored-By: Abhay Aggarwal --- .../Fixtures/AutoMapperFixture.cs | 20 +++ .../Fixtures/TestDbContextFixture.cs | 109 +++++++++++++++ .../Handlers/Blogs/GetBlogByIdHandlerTests.cs | 73 +++++++++++ .../Handlers/Blogs/GetBlogsHandlerTests.cs | 75 +++++++++++ .../Handlers/Posts/GetPostByIdHandlerTests.cs | 116 ++++++++++++++++ .../Handlers/Posts/GetPostsHandlerTests.cs | 124 ++++++++++++++++++ .../Handlers/Tags/GetTagByIdHandlerTests.cs | 73 +++++++++++ .../Handlers/Tags/GetTagsHandlerTests.cs | 76 +++++++++++ .../Integration/BlogsControllerTests.cs | 48 +++++++ .../CustomWebApplicationFactory.cs | 114 ++++++++++++++++ .../Integration/PostsControllerTests.cs | 70 ++++++++++ .../Integration/TagsControllerTests.cs | 48 +++++++ .../SampleWebApp.Api.Tests.csproj | 33 +++++ SampleWebApp.Api.Tests/Usings.cs | 1 + SampleWebApp.Api/Program.cs | 2 + 15 files changed, 982 insertions(+) create mode 100644 SampleWebApp.Api.Tests/Fixtures/AutoMapperFixture.cs create mode 100644 SampleWebApp.Api.Tests/Fixtures/TestDbContextFixture.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogByIdHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogsHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Posts/GetPostByIdHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Posts/GetPostsHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Tags/GetTagByIdHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Handlers/Tags/GetTagsHandlerTests.cs create mode 100644 SampleWebApp.Api.Tests/Integration/BlogsControllerTests.cs create mode 100644 SampleWebApp.Api.Tests/Integration/CustomWebApplicationFactory.cs create mode 100644 SampleWebApp.Api.Tests/Integration/PostsControllerTests.cs create mode 100644 SampleWebApp.Api.Tests/Integration/TagsControllerTests.cs create mode 100644 SampleWebApp.Api.Tests/SampleWebApp.Api.Tests.csproj create mode 100644 SampleWebApp.Api.Tests/Usings.cs diff --git a/SampleWebApp.Api.Tests/Fixtures/AutoMapperFixture.cs b/SampleWebApp.Api.Tests/Fixtures/AutoMapperFixture.cs new file mode 100644 index 0000000..6b85a11 --- /dev/null +++ b/SampleWebApp.Api.Tests/Fixtures/AutoMapperFixture.cs @@ -0,0 +1,20 @@ +using AutoMapper; +using SampleWebApp.Core.Mapping; + +namespace SampleWebApp.Api.Tests.Fixtures +{ + public class AutoMapperFixture + { + public IMapper Mapper { get; } + + public AutoMapperFixture() + { + var config = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + }); + + Mapper = config.CreateMapper(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Fixtures/TestDbContextFixture.cs b/SampleWebApp.Api.Tests/Fixtures/TestDbContextFixture.cs new file mode 100644 index 0000000..b8c5aa5 --- /dev/null +++ b/SampleWebApp.Api.Tests/Fixtures/TestDbContextFixture.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore; +using SampleWebApp.Api.Data; +using SampleWebApp.Core.Entities; + +namespace SampleWebApp.Api.Tests.Fixtures +{ + public class TestDbContextFixture : IDisposable + { + public SampleWebAppDbContext Context { get; private set; } + + public TestDbContextFixture() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + Context = new SampleWebAppDbContext(options); + SeedData(); + } + + private void SeedData() + { + var blog1 = new Blog + { + BlogId = 1, + Name = "Tech Blog", + EmailAddress = "tech@example.com" + }; + + var blog2 = new Blog + { + BlogId = 2, + Name = "Science Blog", + EmailAddress = "science@example.com" + }; + + var tag1 = new Tag + { + TagId = 1, + Name = "Programming", + Slug = "programming" + }; + + var tag2 = new Tag + { + TagId = 2, + Name = "Web Development", + Slug = "web-development" + }; + + var tag3 = new Tag + { + TagId = 3, + Name = "Science", + Slug = "science" + }; + + var post1 = new Post + { + PostId = 1, + Title = "Introduction to C#", + Content = "This is a post about C# programming.", + BlogId = 1, + Blogger = blog1, + LastUpdated = DateTime.UtcNow.AddDays(-1), + Tags = new List { tag1, tag2 } + }; + + var post2 = new Post + { + PostId = 2, + Title = "ASP.NET Core Basics", + Content = "Learn the basics of ASP.NET Core.", + BlogId = 1, + Blogger = blog1, + LastUpdated = DateTime.UtcNow, + Tags = new List { tag1, tag2 } + }; + + var post3 = new Post + { + PostId = 3, + Title = "Physics Fundamentals", + Content = "Understanding the basics of physics.", + BlogId = 2, + Blogger = blog2, + LastUpdated = DateTime.UtcNow.AddDays(-2), + Tags = new List { tag3 } + }; + + blog1.Posts = new List { post1, post2 }; + blog2.Posts = new List { post3 }; + + tag1.Posts = new List { post1, post2 }; + tag2.Posts = new List { post1, post2 }; + tag3.Posts = new List { post3 }; + + Context.Blogs.AddRange(blog1, blog2); + Context.Tags.AddRange(tag1, tag2, tag3); + Context.Posts.AddRange(post1, post2, post3); + Context.SaveChanges(); + } + + public void Dispose() + { + Context.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogByIdHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogByIdHandlerTests.cs new file mode 100644 index 0000000..baa6d02 --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogByIdHandlerTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Blogs.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Blogs +{ + public class GetBlogByIdHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetBlogByIdHandler _handler; + + public GetBlogByIdHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetBlogByIdHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_WithValidId_ShouldReturnBlog() + { + var query = new GetBlogByIdQuery { BlogId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.BlogId.Should().Be(1); + result.Name.Should().Be("Tech Blog"); + } + + [Fact] + public async Task Handle_WithInvalidId_ShouldReturnNull() + { + var query = new GetBlogByIdQuery { BlogId = 999 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_ShouldIncludePostsCount() + { + var query = new GetBlogByIdQuery { BlogId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.PostsCount.Should().Be(2); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetBlogByIdQuery { BlogId = 2 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.BlogId.Should().Be(2); + result.Name.Should().Be("Science Blog"); + result.EmailAddress.Should().Be("science@example.com"); + result.PostsCount.Should().Be(1); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogsHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogsHandlerTests.cs new file mode 100644 index 0000000..aa6dd01 --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Blogs/GetBlogsHandlerTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Blogs.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Blogs +{ + public class GetBlogsHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetBlogsHandler _handler; + + public GetBlogsHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetBlogsHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_ShouldReturnAllBlogs() + { + var query = new GetBlogsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().HaveCount(2); + } + + [Fact] + public async Task Handle_ShouldReturnBlogsOrderedByNameDescending() + { + var query = new GetBlogsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var blogList = result.ToList(); + + blogList[0].Name.Should().Be("Tech Blog"); + blogList[1].Name.Should().Be("Science Blog"); + } + + [Fact] + public async Task Handle_ShouldIncludePostsCount() + { + var query = new GetBlogsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var techBlog = result.First(b => b.Name == "Tech Blog"); + var scienceBlog = result.First(b => b.Name == "Science Blog"); + + techBlog.PostsCount.Should().Be(2); + scienceBlog.PostsCount.Should().Be(1); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetBlogsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var techBlog = result.First(b => b.Name == "Tech Blog"); + + techBlog.BlogId.Should().Be(1); + techBlog.Name.Should().Be("Tech Blog"); + techBlog.EmailAddress.Should().Be("tech@example.com"); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Posts/GetPostByIdHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Posts/GetPostByIdHandlerTests.cs new file mode 100644 index 0000000..af102cd --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Posts/GetPostByIdHandlerTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Posts.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Posts +{ + public class GetPostByIdHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetPostByIdHandler _handler; + + public GetPostByIdHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetPostByIdHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_WithValidId_ShouldReturnPost() + { + var query = new GetPostByIdQuery { PostId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.PostId.Should().Be(1); + result.Title.Should().Be("Introduction to C#"); + } + + [Fact] + public async Task Handle_WithInvalidId_ShouldReturnNull() + { + var query = new GetPostByIdQuery { PostId = 999 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_ShouldIncludeBloggerName() + { + var query = new GetPostByIdQuery { PostId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.BloggerName.Should().Be("Tech Blog"); + } + + [Fact] + public async Task Handle_ShouldIncludeTags() + { + var query = new GetPostByIdQuery { PostId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.Tags.Should().HaveCount(2); + result.Tags.Select(t => t.Name).Should().Contain("Programming"); + result.Tags.Select(t => t.Name).Should().Contain("Web Development"); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetPostByIdQuery { PostId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.PostId.Should().Be(1); + result.Title.Should().Be("Introduction to C#"); + result.Content.Should().Be("This is a post about C# programming."); + result.BloggerName.Should().Be("Tech Blog"); + result.BlogId.Should().Be(1); + result.LastUpdated.Should().NotBe(default); + } + + [Fact] + public async Task Handle_ShouldReturnDetailedTagInfo() + { + var query = new GetPostByIdQuery { PostId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + var programmingTag = result!.Tags.First(t => t.Name == "Programming"); + programmingTag.TagId.Should().Be(1); + programmingTag.Slug.Should().Be("programming"); + } + + [Fact] + public async Task Handle_WithDifferentPost_ShouldReturnCorrectData() + { + var query = new GetPostByIdQuery { PostId = 3 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.PostId.Should().Be(3); + result.Title.Should().Be("Physics Fundamentals"); + result.BloggerName.Should().Be("Science Blog"); + result.Tags.Should().HaveCount(1); + result.Tags.First().Name.Should().Be("Science"); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Posts/GetPostsHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Posts/GetPostsHandlerTests.cs new file mode 100644 index 0000000..5006510 --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Posts/GetPostsHandlerTests.cs @@ -0,0 +1,124 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Posts.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Posts +{ + public class GetPostsHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetPostsHandler _handler; + + public GetPostsHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetPostsHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_WithoutBlogIdFilter_ShouldReturnAllPosts() + { + var query = new GetPostsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().HaveCount(3); + } + + [Fact] + public async Task Handle_WithBlogIdFilter_ShouldReturnFilteredPosts() + { + var query = new GetPostsQuery { BlogId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.All(p => p.BlogId == 1).Should().BeTrue(); + } + + [Fact] + public async Task Handle_WithBlogIdFilter_ShouldReturnOnlyMatchingBlogPosts() + { + var query = new GetPostsQuery { BlogId = 2 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result.First().Title.Should().Be("Physics Fundamentals"); + } + + [Fact] + public async Task Handle_ShouldReturnPostsOrderedByLastUpdatedDescending() + { + var query = new GetPostsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var postList = result.ToList(); + + postList[0].Title.Should().Be("ASP.NET Core Basics"); + postList[1].Title.Should().Be("Introduction to C#"); + postList[2].Title.Should().Be("Physics Fundamentals"); + } + + [Fact] + public async Task Handle_ShouldIncludeBloggerName() + { + var query = new GetPostsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var techPost = result.First(p => p.Title == "Introduction to C#"); + + techPost.BloggerName.Should().Be("Tech Blog"); + } + + [Fact] + public async Task Handle_ShouldIncludeTagNames() + { + var query = new GetPostsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var techPost = result.First(p => p.Title == "Introduction to C#"); + + techPost.TagNames.Should().HaveCount(2); + techPost.TagNames.Should().Contain("Programming"); + techPost.TagNames.Should().Contain("Web Development"); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetPostsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var post = result.First(p => p.Title == "Introduction to C#"); + + post.PostId.Should().Be(1); + post.BlogId.Should().Be(1); + post.Title.Should().Be("Introduction to C#"); + post.BloggerName.Should().Be("Tech Blog"); + post.LastUpdated.Should().NotBe(default); + } + + [Fact] + public async Task Handle_WithNonExistentBlogId_ShouldReturnEmptyList() + { + var query = new GetPostsQuery { BlogId = 999 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Tags/GetTagByIdHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Tags/GetTagByIdHandlerTests.cs new file mode 100644 index 0000000..f2b66c4 --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Tags/GetTagByIdHandlerTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Tags.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Tags +{ + public class GetTagByIdHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetTagByIdHandler _handler; + + public GetTagByIdHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetTagByIdHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_WithValidId_ShouldReturnTag() + { + var query = new GetTagByIdQuery { TagId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.TagId.Should().Be(1); + result.Name.Should().Be("Programming"); + } + + [Fact] + public async Task Handle_WithInvalidId_ShouldReturnNull() + { + var query = new GetTagByIdQuery { TagId = 999 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_ShouldIncludePostCount() + { + var query = new GetTagByIdQuery { TagId = 1 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.PostCount.Should().Be(2); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetTagByIdQuery { TagId = 2 }; + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result!.TagId.Should().Be(2); + result.Name.Should().Be("Web Development"); + result.Slug.Should().Be("web-development"); + result.PostCount.Should().Be(2); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Handlers/Tags/GetTagsHandlerTests.cs b/SampleWebApp.Api.Tests/Handlers/Tags/GetTagsHandlerTests.cs new file mode 100644 index 0000000..8294512 --- /dev/null +++ b/SampleWebApp.Api.Tests/Handlers/Tags/GetTagsHandlerTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using SampleWebApp.Api.Tests.Fixtures; +using SampleWebApp.Core.Handlers.Tags.Queries; +using Xunit; + +namespace SampleWebApp.Api.Tests.Handlers.Tags +{ + public class GetTagsHandlerTests : IDisposable + { + private readonly TestDbContextFixture _dbFixture; + private readonly AutoMapperFixture _mapperFixture; + private readonly GetTagsHandler _handler; + + public GetTagsHandlerTests() + { + _dbFixture = new TestDbContextFixture(); + _mapperFixture = new AutoMapperFixture(); + _handler = new GetTagsHandler(_dbFixture.Context, _mapperFixture.Mapper); + } + + [Fact] + public async Task Handle_ShouldReturnAllTags() + { + var query = new GetTagsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.Should().NotBeNull(); + result.Should().HaveCount(3); + } + + [Fact] + public async Task Handle_ShouldReturnTagsOrderedByName() + { + var query = new GetTagsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var tagList = result.ToList(); + + tagList[0].Name.Should().Be("Programming"); + tagList[1].Name.Should().Be("Science"); + tagList[2].Name.Should().Be("Web Development"); + } + + [Fact] + public async Task Handle_ShouldIncludePostCount() + { + var query = new GetTagsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var programmingTag = result.First(t => t.Name == "Programming"); + var scienceTag = result.First(t => t.Name == "Science"); + + programmingTag.PostCount.Should().Be(2); + scienceTag.PostCount.Should().Be(1); + } + + [Fact] + public async Task Handle_ShouldMapAllProperties() + { + var query = new GetTagsQuery(); + + var result = await _handler.Handle(query, CancellationToken.None); + var programmingTag = result.First(t => t.Name == "Programming"); + + programmingTag.TagId.Should().Be(1); + programmingTag.Name.Should().Be("Programming"); + programmingTag.Slug.Should().Be("programming"); + } + + public void Dispose() + { + _dbFixture.Dispose(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Integration/BlogsControllerTests.cs b/SampleWebApp.Api.Tests/Integration/BlogsControllerTests.cs new file mode 100644 index 0000000..3043f59 --- /dev/null +++ b/SampleWebApp.Api.Tests/Integration/BlogsControllerTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using SampleWebApp.Core.DTOs; +using Xunit; + +namespace SampleWebApp.Api.Tests.Integration +{ + public class BlogsControllerTests : IClassFixture + { + private readonly HttpClient _client; + + public BlogsControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetBlogs_ShouldReturnOkWithBlogs() + { + var response = await _client.GetAsync("/api/blogs"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var blogs = await response.Content.ReadFromJsonAsync>(); + blogs.Should().NotBeNull(); + blogs.Should().HaveCountGreaterThan(0); + } + + [Fact] + public async Task GetBlogById_WithValidId_ShouldReturnOkWithBlog() + { + var response = await _client.GetAsync("/api/blogs/1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var blog = await response.Content.ReadFromJsonAsync(); + blog.Should().NotBeNull(); + blog!.BlogId.Should().Be(1); + } + + [Fact] + public async Task GetBlogById_WithInvalidId_ShouldReturnNotFound() + { + var response = await _client.GetAsync("/api/blogs/999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } +} diff --git a/SampleWebApp.Api.Tests/Integration/CustomWebApplicationFactory.cs b/SampleWebApp.Api.Tests/Integration/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..56ed262 --- /dev/null +++ b/SampleWebApp.Api.Tests/Integration/CustomWebApplicationFactory.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SampleWebApp.Api.Data; +using SampleWebApp.Core.Entities; + +namespace SampleWebApp.Api.Tests.Integration +{ + public class CustomWebApplicationFactory : WebApplicationFactory + { + private readonly string _databaseName = $"IntegrationTestDb_{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_databaseName); + }); + + services.AddScoped(provider => + provider.GetRequiredService()); + + var sp = services.BuildServiceProvider(); + + using (var scope = sp.CreateScope()) + { + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + + db.Database.EnsureCreated(); + SeedTestData(db); + } + }); + } + + private static void SeedTestData(SampleWebAppDbContext context) + { + if (context.Blogs.Any()) + { + return; + } + + var blog1 = new Blog + { + BlogId = 1, + Name = "Tech Blog", + EmailAddress = "tech@example.com" + }; + + var blog2 = new Blog + { + BlogId = 2, + Name = "Science Blog", + EmailAddress = "science@example.com" + }; + + var tag1 = new Tag + { + TagId = 1, + Name = "Programming", + Slug = "programming" + }; + + var tag2 = new Tag + { + TagId = 2, + Name = "Web Development", + Slug = "web-development" + }; + + var post1 = new Post + { + PostId = 1, + Title = "Introduction to C#", + Content = "This is a post about C# programming.", + BlogId = 1, + Blogger = blog1, + LastUpdated = DateTime.UtcNow.AddDays(-1), + Tags = new List { tag1, tag2 } + }; + + var post2 = new Post + { + PostId = 2, + Title = "ASP.NET Core Basics", + Content = "Learn the basics of ASP.NET Core.", + BlogId = 1, + Blogger = blog1, + LastUpdated = DateTime.UtcNow, + Tags = new List { tag1 } + }; + + blog1.Posts = new List { post1, post2 }; + tag1.Posts = new List { post1, post2 }; + tag2.Posts = new List { post1 }; + + context.Blogs.AddRange(blog1, blog2); + context.Tags.AddRange(tag1, tag2); + context.Posts.AddRange(post1, post2); + context.SaveChanges(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Integration/PostsControllerTests.cs b/SampleWebApp.Api.Tests/Integration/PostsControllerTests.cs new file mode 100644 index 0000000..7a0d0f4 --- /dev/null +++ b/SampleWebApp.Api.Tests/Integration/PostsControllerTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using SampleWebApp.Core.DTOs; +using Xunit; + +namespace SampleWebApp.Api.Tests.Integration +{ + public class PostsControllerTests : IClassFixture + { + private readonly HttpClient _client; + + public PostsControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetPosts_ShouldReturnOkWithPosts() + { + var response = await _client.GetAsync("/api/posts"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var posts = await response.Content.ReadFromJsonAsync>(); + posts.Should().NotBeNull(); + posts.Should().HaveCountGreaterThan(0); + } + + [Fact] + public async Task GetPosts_WithBlogIdFilter_ShouldReturnFilteredPosts() + { + var response = await _client.GetAsync("/api/posts?blogId=1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var posts = await response.Content.ReadFromJsonAsync>(); + posts.Should().NotBeNull(); + posts!.All(p => p.BlogId == 1).Should().BeTrue(); + } + + [Fact] + public async Task GetPostById_WithValidId_ShouldReturnOkWithPost() + { + var response = await _client.GetAsync("/api/posts/1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var post = await response.Content.ReadFromJsonAsync(); + post.Should().NotBeNull(); + post!.PostId.Should().Be(1); + } + + [Fact] + public async Task GetPostById_WithInvalidId_ShouldReturnNotFound() + { + var response = await _client.GetAsync("/api/posts/999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetPostById_ShouldIncludeTags() + { + var response = await _client.GetAsync("/api/posts/1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var post = await response.Content.ReadFromJsonAsync(); + post.Should().NotBeNull(); + post!.Tags.Should().NotBeEmpty(); + } + } +} diff --git a/SampleWebApp.Api.Tests/Integration/TagsControllerTests.cs b/SampleWebApp.Api.Tests/Integration/TagsControllerTests.cs new file mode 100644 index 0000000..923029d --- /dev/null +++ b/SampleWebApp.Api.Tests/Integration/TagsControllerTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using SampleWebApp.Core.DTOs; +using Xunit; + +namespace SampleWebApp.Api.Tests.Integration +{ + public class TagsControllerTests : IClassFixture + { + private readonly HttpClient _client; + + public TagsControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetTags_ShouldReturnOkWithTags() + { + var response = await _client.GetAsync("/api/tags"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var tags = await response.Content.ReadFromJsonAsync>(); + tags.Should().NotBeNull(); + tags.Should().HaveCountGreaterThan(0); + } + + [Fact] + public async Task GetTagById_WithValidId_ShouldReturnOkWithTag() + { + var response = await _client.GetAsync("/api/tags/1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var tag = await response.Content.ReadFromJsonAsync(); + tag.Should().NotBeNull(); + tag!.TagId.Should().Be(1); + } + + [Fact] + public async Task GetTagById_WithInvalidId_ShouldReturnNotFound() + { + var response = await _client.GetAsync("/api/tags/999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } +} diff --git a/SampleWebApp.Api.Tests/SampleWebApp.Api.Tests.csproj b/SampleWebApp.Api.Tests/SampleWebApp.Api.Tests.csproj new file mode 100644 index 0000000..9483ad0 --- /dev/null +++ b/SampleWebApp.Api.Tests/SampleWebApp.Api.Tests.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/SampleWebApp.Api.Tests/Usings.cs b/SampleWebApp.Api.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/SampleWebApp.Api.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/SampleWebApp.Api/Program.cs b/SampleWebApp.Api/Program.cs index 0898f87..eed7282 100644 --- a/SampleWebApp.Api/Program.cs +++ b/SampleWebApp.Api/Program.cs @@ -41,3 +41,5 @@ app.MapControllers(); app.Run(); + +public partial class Program { }