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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions MyFirstBlog/Controllers/PostsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,34 @@ public IEnumerable<PostDto> GetPosts() {
return _postService.GetPosts();
}

// Get /posts/:slug
// Get /posts/:slug
[HttpGet("{slug}")]
public ActionResult<PostDto> GetPost(string slug) {
public ActionResult<PostDto> GetPost(string slug)
{
var post = _postService.GetPost(slug);

if (post is null) {
if (post == null)
{
return NotFound();
}

return post;
}

[HttpPost]
public ActionResult<object> 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);

}
6 changes: 5 additions & 1 deletion MyFirstBlog/Helpers/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Post> Posts { get; set; }
Expand Down
1 change: 1 addition & 0 deletions MyFirstBlog/MyFirstBlog.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.20" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
Expand Down
59 changes: 41 additions & 18 deletions MyFirstBlog/Program.cs
Original file line number Diff line number Diff line change
@@ -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<DataContext>();
// Use InMemory DB in Development; Postgres (via OnConfiguring) otherwise
if (env.IsDevelopment())
{
services.AddDbContext<DataContext>(opt =>
opt.UseInMemoryDatabase("MyFirstBlogDev"));
}
else
{
services.AddDbContext<DataContext>(); // 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<IPostService, PostService>();

if (env.IsDevelopment())
{
services.AddSingleton<IPostService, InMemoryPostService>();
}
else
{
services.AddScoped<IPostService, PostService>();
}
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<Program> work if you add integration tests later
public partial class Program {}
32 changes: 32 additions & 0 deletions MyFirstBlog/Services/InMemoryPostService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using MyFirstBlog.Dtos;

namespace MyFirstBlog.Services
{
public class InMemoryPostService : IPostService
{
private readonly List<PostDto> _posts = new();

public IEnumerable<PostDto> 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));
}
}
38 changes: 38 additions & 0 deletions MyFirstBlog/Services/PostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public interface IPostService
{
IEnumerable<PostDto> GetPosts();
PostDto GetPost(String slug);
PostDto CreatePost(string title, string body);
}

public class PostService : IPostService
Expand All @@ -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;
}

}
77 changes: 77 additions & 0 deletions MyFirstBlogTests/Controllers/PostsControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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<IPostService> _postServiceMock = null!;
private PostsController _controller = null!;

[SetUp]
public void SetUp()
{
_postServiceMock = new Mock<IPostService>();
_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<BadRequestObjectResult>();
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<CreatedResult>();
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);
}
}
}
26 changes: 13 additions & 13 deletions MyFirstBlogTests/MyFirstBlogTests.csproj
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4"/>
<PackageReference Include="NUnit" Version="3.13.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0"/>
<PackageReference Include="coverlet.collector" Version="3.0.2"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.18.4" />
<ProjectReference Include="..\MyFirstBlog\MyFirstBlog.csproj" />
</ItemGroup>
</Project>