From 4f38c789991a49bc48485f36d511bbd0d877248d Mon Sep 17 00:00:00 2001 From: tamnguyen976 Date: Fri, 8 Aug 2025 01:28:58 -0600 Subject: [PATCH 1/2] TDD: POST /posts returns 201 on success, 400 on blank title; add service method --- MyFirstBlog/Controllers/PostsController.cs | 19 +++++ MyFirstBlog/Services/PostService.cs | 38 +++++++++ .../Controllers/PostsControllerTests.cs | 77 +++++++++++++++++++ MyFirstBlogTests/MyFirstBlogTests.csproj | 26 +++---- 4 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 MyFirstBlogTests/Controllers/PostsControllerTests.cs diff --git a/MyFirstBlog/Controllers/PostsController.cs b/MyFirstBlog/Controllers/PostsController.cs index 8fa6bf2c..543c9422 100644 --- a/MyFirstBlog/Controllers/PostsController.cs +++ b/MyFirstBlog/Controllers/PostsController.cs @@ -31,4 +31,23 @@ public ActionResult GetPost(string slug) { return post; } + +[HttpPost] +public ActionResult Create([FromBody] CreatePostRequest req) +{ + if (req is null || string.IsNullOrWhiteSpace(req.title)) + { + return BadRequest(new ErrorResponse(new[] { "Title cannot be blank" })); + } + + var created = _postService.CreatePost(req.title!, req.description ?? string.Empty); + var body = new PostResponse(new PostBody(created.Title, created.Body)); + return Created(string.Empty, body); +} + + public record CreatePostRequest(string? title, string? description); + public record PostBody(string title, string description); + public record PostResponse(PostBody post); + public record ErrorResponse(string[] errors); + } diff --git a/MyFirstBlog/Services/PostService.cs b/MyFirstBlog/Services/PostService.cs index 6bac099f..897eee53 100644 --- a/MyFirstBlog/Services/PostService.cs +++ b/MyFirstBlog/Services/PostService.cs @@ -9,6 +9,7 @@ public interface IPostService { IEnumerable GetPosts(); PostDto GetPost(String slug); + PostDto CreatePost(string title, string body); } public class PostService : IPostService @@ -34,4 +35,41 @@ private Post getPost(string slug) { return _context.Posts.Where(a=>a.Slug==slug.ToString()).SingleOrDefault(); } + + public PostDto CreatePost(string title, string body) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title cannot be blank", nameof(title)); + + var slug = Slugify(title); + + var entity = new Post + { + Id = Guid.NewGuid(), + Title = title, + Slug = slug, + Body = body ?? string.Empty, + CreatedDate = DateTime.UtcNow + }; + + _context.Posts.Add(entity); + _context.SaveChanges(); + + return new PostDto + { + Id = entity.Id, + Title = entity.Title, + Slug = entity.Slug, + Body = entity.Body, + CreatedDate = entity.CreatedDate + }; + } + + private static string Slugify(string input) + { + var s = input.Trim().ToLowerInvariant(); + s = string.Join('-', s.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); + return s; + } + } diff --git a/MyFirstBlogTests/Controllers/PostsControllerTests.cs b/MyFirstBlogTests/Controllers/PostsControllerTests.cs new file mode 100644 index 00000000..cd82e4a8 --- /dev/null +++ b/MyFirstBlogTests/Controllers/PostsControllerTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Microsoft.AspNetCore.Mvc; +using MyFirstBlog.Controllers; +using MyFirstBlog.Dtos; +using MyFirstBlog.Services; + +namespace MyFirstBlogTests.Controllers +{ + [TestFixture] + public class PostsControllerTests + { + private Mock _postServiceMock = null!; + private PostsController _controller = null!; + + [SetUp] + public void SetUp() + { + _postServiceMock = new Mock(); + _controller = new PostsController(_postServiceMock.Object); + } + + [Test] + public void Create_Returns400_WhenTitleBlank() + { + // Arrange + var req = new PostsController.CreatePostRequest("", "x"); + + // Act + var result = _controller.Create(req); + + // Assert + result.Result.Should().BeOfType(); + var badReq = (BadRequestObjectResult)result.Result!; + badReq.StatusCode.Should().Be(400); + + var errors = badReq.Value as PostsController.ErrorResponse; + errors.Should().NotBeNull(); + errors!.errors.Should().Contain("Title cannot be blank"); + } + + [Test] + public void Create_Returns201_AndPostBody_WhenSuccess() + { + // Arrange + var req = new PostsController.CreatePostRequest("some title", "some content"); + + // Mock the service to return a DTO (Body maps to description) + _postServiceMock + .Setup(s => s.CreatePost("some title", "some content")) + .Returns(new PostDto + { + Id = System.Guid.NewGuid(), + Title = "some title", + Slug = "some-title", + Body = "some content", + CreatedDate = System.DateTime.UtcNow + }); + + // Act + var result = _controller.Create(req); + + // Assert + result.Result.Should().BeOfType(); + var created = (CreatedResult)result.Result!; + created.StatusCode.Should().Be(201); + + var body = created.Value as PostsController.PostResponse; + body.Should().NotBeNull(); + body!.post.title.Should().Be("some title"); + body.post.description.Should().Be("some content"); + + _postServiceMock.Verify(s => s.CreatePost("some title", "some content"), Times.Once); + } + } +} diff --git a/MyFirstBlogTests/MyFirstBlogTests.csproj b/MyFirstBlogTests/MyFirstBlogTests.csproj index 79ed3371..0a75768d 100644 --- a/MyFirstBlogTests/MyFirstBlogTests.csproj +++ b/MyFirstBlogTests/MyFirstBlogTests.csproj @@ -1,16 +1,16 @@ + + net7.0 + false + - - net5.0 - - false - - - - - - - - - + + + + + + + + + From 7972d7084c75f5beb660d3ed4f789b3b9781d9a2 Mon Sep 17 00:00:00 2001 From: tamnguyen976 Date: Fri, 8 Aug 2025 02:05:53 -0600 Subject: [PATCH 2/2] Backend: POST /posts with validation + dev InMemory + tests --- MyFirstBlog/Controllers/PostsController.cs | 12 ++--- MyFirstBlog/Helpers/DataContext.cs | 6 ++- MyFirstBlog/MyFirstBlog.csproj | 1 + MyFirstBlog/Program.cs | 59 ++++++++++++++------- MyFirstBlog/Services/InMemoryPostService.cs | 32 +++++++++++ 5 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 MyFirstBlog/Services/InMemoryPostService.cs diff --git a/MyFirstBlog/Controllers/PostsController.cs b/MyFirstBlog/Controllers/PostsController.cs index 543c9422..8a24b794 100644 --- a/MyFirstBlog/Controllers/PostsController.cs +++ b/MyFirstBlog/Controllers/PostsController.cs @@ -20,15 +20,15 @@ public IEnumerable GetPosts() { return _postService.GetPosts(); } - // Get /posts/:slug + // Get /posts/:slug [HttpGet("{slug}")] - public ActionResult GetPost(string slug) { + public ActionResult GetPost(string slug) + { var post = _postService.GetPost(slug); - - if (post is null) { + if (post == null) + { return NotFound(); } - return post; } @@ -39,7 +39,7 @@ public ActionResult Create([FromBody] CreatePostRequest req) { return BadRequest(new ErrorResponse(new[] { "Title cannot be blank" })); } - + var created = _postService.CreatePost(req.title!, req.description ?? string.Empty); var body = new PostResponse(new PostBody(created.Title, created.Body)); return Created(string.Empty, body); diff --git a/MyFirstBlog/Helpers/DataContext.cs b/MyFirstBlog/Helpers/DataContext.cs index 60ad2bfc..12027302 100644 --- a/MyFirstBlog/Helpers/DataContext.cs +++ b/MyFirstBlog/Helpers/DataContext.cs @@ -15,7 +15,11 @@ public DataContext(IConfiguration configuration) protected override void OnConfiguring(DbContextOptionsBuilder options) { - options.UseNpgsql(ConnectionHelper.GetConnectionString(Configuration)); +// Only configure Npgsql if options were not configured by DI + if (!options.IsConfigured) + { + options.UseNpgsql(ConnectionHelper.GetConnectionString(Configuration)); + } } public DbSet Posts { get; set; } diff --git a/MyFirstBlog/MyFirstBlog.csproj b/MyFirstBlog/MyFirstBlog.csproj index 04ccc9c1..10f06476 100644 --- a/MyFirstBlog/MyFirstBlog.csproj +++ b/MyFirstBlog/MyFirstBlog.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/MyFirstBlog/Program.cs b/MyFirstBlog/Program.cs index 4042b610..1de5d3a6 100644 --- a/MyFirstBlog/Program.cs +++ b/MyFirstBlog/Program.cs @@ -1,52 +1,75 @@ +using Microsoft.EntityFrameworkCore; using MyFirstBlog.Helpers; using MyFirstBlog.Services; -var MyAllowLocalhostOrigins = "_myAllowLocalhostOrigins"; +var MyAllowLocalhostOrigins = "_myAllowLocalhostOrigins"; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; var env = builder.Environment; -// Add services to the container. -services.AddDbContext(); +// Use InMemory DB in Development; Postgres (via OnConfiguring) otherwise +if (env.IsDevelopment()) +{ + services.AddDbContext(opt => + opt.UseInMemoryDatabase("MyFirstBlogDev")); +} +else +{ + services.AddDbContext(); // falls back to Npgsql via DataContext.OnConfiguring +} -services.AddCors(policyBuilder => { - policyBuilder.AddPolicy( MyAllowLocalhostOrigins, - policy => { - policy.WithOrigins("http://localhost:3000").AllowAnyHeader().AllowAnyMethod(); - }); +services.AddCors(policyBuilder => +{ + policyBuilder.AddPolicy(MyAllowLocalhostOrigins, policy => + { + policy.WithOrigins("http://localhost:3000") + .AllowAnyHeader() + .AllowAnyMethod(); + }); }); services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); -services.AddScoped(); - +if (env.IsDevelopment()) +{ + services.AddSingleton(); +} +else +{ + services.AddScoped(); +} var app = builder.Build(); -var scope = app.Services.CreateScope(); -await DatabaseHelper.ManageMigrationsAsync(scope.ServiceProvider); +// ---- Pipeline ---- + +// ✅ Only run EF migrations in Production (avoid local Postgres errors) +if (env.IsProduction()) +{ + var scope = app.Services.CreateScope(); + await DatabaseHelper.ManageMigrationsAsync(scope.ServiceProvider); +} + -// Configure the HTTP request pipeline. if (env.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); - app.UseCors(MyAllowLocalhostOrigins); } - -if (env.IsProduction()) +else { app.UseHttpsRedirection(); } app.UseAuthorization(); - app.MapControllers(); app.Run(); + +// 👇 makes WebApplicationFactory work if you add integration tests later +public partial class Program {} diff --git a/MyFirstBlog/Services/InMemoryPostService.cs b/MyFirstBlog/Services/InMemoryPostService.cs new file mode 100644 index 00000000..5fa75a2b --- /dev/null +++ b/MyFirstBlog/Services/InMemoryPostService.cs @@ -0,0 +1,32 @@ +using MyFirstBlog.Dtos; + +namespace MyFirstBlog.Services +{ + public class InMemoryPostService : IPostService + { + private readonly List _posts = new(); + + public IEnumerable GetPosts() => _posts; + + public PostDto? GetPost(string slug) => _posts.FirstOrDefault(p => p.Slug == slug); + + public PostDto CreatePost(string title, string body) + { + var slug = Slugify(title); + var dto = new PostDto + { + Id = Guid.NewGuid(), + Title = title, + Slug = slug, + Body = body ?? string.Empty, + CreatedDate = DateTime.UtcNow + }; + _posts.Add(dto); + return dto; + } + + private static string Slugify(string input) => + string.Join('-', (input ?? "").Trim().ToLowerInvariant() + .Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } +}