mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Migrate Breweries to a vertical slice (Features.Breweries)
Moves brewery CRUD from the Service.Breweries/Infrastructure.Repository layered split into a single Features.Breweries project: MediatR commands/queries replace BreweryService, and the repository moves in alongside its own controller. Extracts Infrastructure.Sql out of Infrastructure.Repository (just the generic ADO.NET connection/base-repo plumbing) since Breweries no longer needs the rest of that project, while Auth and UserAccount repos still do until their own migration. Also fixes the API.Core and Infrastructure.Repository.Tests Dockerfiles, which were restoring against COPY destinations that didn't match the projects' actual relative paths (silently working only because of the COPY . . that followed); both now restore against Core.slnx with correctly nested paths, verified with a real docker build.
This commit is contained in:
@@ -30,10 +30,11 @@
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
using API.Core.Contracts.Breweries;
|
||||
using Shared.Contracts;
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.Breweries;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides CRUD endpoints for managing brewery posts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
||||
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>.
|
||||
/// </remarks>
|
||||
/// <param name="breweryService">Service used to query and mutate brewery post data.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class BreweryController(IBreweryService breweryService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
||||
/// <returns>
|
||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found;
|
||||
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||
{
|
||||
var brewery = await breweryService.GetByIdAsync(id);
|
||||
if (brewery is null)
|
||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = MapToDto(brewery),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of brewery posts.
|
||||
/// </summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
|
||||
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset)
|
||||
{
|
||||
var breweries = await breweryService.GetAllAsync(limit, offset);
|
||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries.Select(MapToDto),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="dto">The brewery details to create, including the posting user, name, description, and location.</param>
|
||||
/// <returns>
|
||||
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>
|
||||
/// if creation succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
|
||||
{
|
||||
var request = new BreweryCreateRequest(
|
||||
dto.PostedById,
|
||||
dto.BreweryName,
|
||||
dto.Description,
|
||||
new BreweryLocationCreateRequest(
|
||||
dto.Location.CityId,
|
||||
dto.Location.AddressLine1,
|
||||
dto.Location.AddressLine2,
|
||||
dto.Location.PostalCode,
|
||||
dto.Location.Coordinates
|
||||
)
|
||||
);
|
||||
|
||||
var result = await breweryService.CreateAsync(request);
|
||||
if (!result.Success)
|
||||
return BadRequest(new ResponseBody { Message = result.Message });
|
||||
|
||||
return Created($"/api/brewery/{result.Brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = MapToDto(result.Brewery),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="dto"/>'s ID.</param>
|
||||
/// <param name="dto">The updated brewery details, including the posting user, name, description, and optional location.</param>
|
||||
/// <returns>
|
||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated brewery (as a <see cref="BreweryPost"/>)
|
||||
/// if the update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message
|
||||
/// (returned when the route ID does not match the payload ID, or when the update itself fails).
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
|
||||
{
|
||||
if (dto.BreweryPostId != id)
|
||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||
|
||||
var request = new BreweryUpdateRequest(
|
||||
dto.BreweryPostId,
|
||||
dto.PostedById,
|
||||
dto.BreweryName,
|
||||
dto.Description,
|
||||
dto.Location is null ? null : new BreweryLocationUpdateRequest(
|
||||
dto.Location.BreweryPostLocationId,
|
||||
dto.Location.CityId,
|
||||
dto.Location.AddressLine1,
|
||||
dto.Location.AddressLine2,
|
||||
dto.Location.PostalCode,
|
||||
dto.Location.Coordinates
|
||||
)
|
||||
);
|
||||
|
||||
var result = await breweryService.UpdateAsync(request);
|
||||
if (!result.Success)
|
||||
return BadRequest(new ResponseBody { Message = result.Message });
|
||||
|
||||
return Ok(new ResponseBody<BreweryPost>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = result.Brewery,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||
{
|
||||
await breweryService.DeleteAsync(id);
|
||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation,
|
||||
/// including its location, if present.
|
||||
/// </summary>
|
||||
/// <param name="b">The brewery post entity to map.</param>
|
||||
/// <returns>The mapped <see cref="BreweryDto"/>.</returns>
|
||||
private static BreweryDto MapToDto(BreweryPost b) => new()
|
||||
{
|
||||
BreweryPostId = b.BreweryPostId,
|
||||
PostedById = b.PostedById,
|
||||
BreweryName = b.BreweryName,
|
||||
Description = b.Description,
|
||||
CreatedAt = b.CreatedAt,
|
||||
UpdatedAt = b.UpdatedAt,
|
||||
Timer = b.Timer,
|
||||
Location = b.Location is null ? null : new BreweryLocationDto
|
||||
{
|
||||
BreweryPostLocationId = b.Location.BreweryPostLocationId,
|
||||
BreweryPostId = b.Location.BreweryPostId,
|
||||
CityId = b.Location.CityId,
|
||||
AddressLine1 = b.Location.AddressLine1,
|
||||
AddressLine2 = b.Location.AddressLine2,
|
||||
PostalCode = b.Location.PostalCode,
|
||||
Coordinates = b.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,16 +8,35 @@ EXPOSE 8081
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
# Copy the solution file and every project file first (preserving their real relative paths, since
|
||||
# ProjectReference paths are resolved relative to the referencing .csproj's location), so the
|
||||
# `dotnet restore` layer stays cached as long as no project file or package reference changes,
|
||||
# independent of source-only edits. Restoring against Core.slnx avoids hand-maintaining a per-project
|
||||
# dependency list here that silently drifts out of sync as ProjectReferences change.
|
||||
COPY ["Core.slnx", "./"]
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
|
||||
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
||||
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
|
||||
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"]
|
||||
COPY ["Service/Service.Emails/Service.Emails.csproj", "Service/Service.Emails/"]
|
||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||
RUN dotnet restore "API/API.Core/API.Core.csproj"
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Core.slnx"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Core"
|
||||
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using API.Core;
|
||||
using API.Core.Authentication;
|
||||
using Features.Breweries.Controllers;
|
||||
using Features.Breweries.DependencyInjection;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Infrastructure.Email;
|
||||
@@ -7,11 +9,9 @@ using Infrastructure.Email.Templates.Rendering;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
using Infrastructure.Sql;
|
||||
using Service.Auth;
|
||||
using Service.Breweries;
|
||||
using Service.Emails;
|
||||
using Service.UserManagement.User;
|
||||
using Shared.Application.Behaviors;
|
||||
@@ -22,7 +22,8 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
});
|
||||
})
|
||||
.AddApplicationPart(typeof(BreweryController).Assembly);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
@@ -30,6 +31,7 @@ builder.Services.AddOpenApi();
|
||||
|
||||
// Add FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
|
||||
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
|
||||
@@ -37,6 +39,7 @@ builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
});
|
||||
|
||||
@@ -60,8 +63,7 @@ builder.Services.AddSingleton<
|
||||
|
||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||
builder.Services.AddScoped<IBreweryService, BreweryService>();
|
||||
builder.Services.AddFeaturesBreweries();
|
||||
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Features/">
|
||||
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
|
||||
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Service/">
|
||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
|
||||
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
||||
<Project Path="Service/Service.Auth/Service.Auth.csproj" />
|
||||
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Shared/">
|
||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Commands.CreateBrewery;
|
||||
using Features.Breweries.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Commands;
|
||||
|
||||
public class CreateBreweryHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly CreateBreweryHandler _handler;
|
||||
|
||||
public CreateBreweryHandlerTests()
|
||||
{
|
||||
_handler = new CreateBreweryHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
private static CreateBreweryLocation ValidLocation() =>
|
||||
new(
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "123 Main St",
|
||||
AddressLine2: null,
|
||||
PostalCode: "12345",
|
||||
Coordinates: null
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||
{
|
||||
var command = new CreateBreweryCommand(
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "MyBrew",
|
||||
Description: "Desc",
|
||||
Location: ValidLocation()
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
||||
persisted.PostedById.Should().Be(command.PostedById);
|
||||
persisted.BreweryName.Should().Be("MyBrew");
|
||||
persisted.Description.Should().Be("Desc");
|
||||
persisted.CreatedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
persisted.UpdatedAt.Should().BeNull();
|
||||
|
||||
persisted.Location.Should().NotBeNull();
|
||||
persisted.Location!.BreweryPostLocationId.Should().NotBe(Guid.Empty);
|
||||
persisted.Location.BreweryPostLocationId.Should().NotBe(persisted.BreweryPostId);
|
||||
persisted.Location.CityId.Should().Be(command.Location.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(command.Location.AddressLine1);
|
||||
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
|
||||
|
||||
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
|
||||
|
||||
result.BreweryName.Should().Be("MyBrew");
|
||||
result.Location.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Features.Breweries.Commands.DeleteBrewery;
|
||||
using Features.Breweries.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Commands;
|
||||
|
||||
public class DeleteBreweryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToRepository()
|
||||
{
|
||||
var repoMock = new Mock<IBreweryRepository>();
|
||||
var handler = new DeleteBreweryHandler(repoMock.Object);
|
||||
var id = Guid.NewGuid();
|
||||
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
||||
|
||||
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
||||
|
||||
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Commands.UpdateBrewery;
|
||||
using Features.Breweries.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Commands;
|
||||
|
||||
public class UpdateBreweryHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly UpdateBreweryHandler _handler;
|
||||
|
||||
public UpdateBreweryHandlerTests()
|
||||
{
|
||||
_handler = new UpdateBreweryHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||
{
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Renamed",
|
||||
Description: "New description",
|
||||
Location: null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||
persisted.PostedById.Should().Be(command.PostedById);
|
||||
persisted.BreweryName.Should().Be("Renamed");
|
||||
persisted.Description.Should().Be("New description");
|
||||
persisted.UpdatedAt.Should().NotBeNull();
|
||||
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
||||
{
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
persisted!.Location.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
||||
{
|
||||
var locationCommand = new UpdateBreweryLocation(
|
||||
BreweryPostLocationId: Guid.NewGuid(),
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "456 Oak Ave",
|
||||
AddressLine2: "Suite 2",
|
||||
PostalCode: "54321",
|
||||
Coordinates: null
|
||||
);
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: locationCommand
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
persisted!.Location.Should().NotBeNull();
|
||||
persisted.Location!.BreweryPostLocationId.Should().Be(locationCommand.BreweryPostLocationId);
|
||||
persisted.Location.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||
persisted.Location.CityId.Should().Be(locationCommand.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1);
|
||||
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
||||
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Service.Breweries.Tests</RootNamespace>
|
||||
<RootNamespace>Features.Breweries.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -20,10 +21,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" />
|
||||
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\API\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Queries.GetAllBreweries;
|
||||
using Features.Breweries.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetAllBreweriesHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly GetAllBreweriesHandler _handler;
|
||||
|
||||
public GetAllBreweriesHandlerTests()
|
||||
{
|
||||
_handler = new GetAllBreweriesHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsAllBreweries_FromRepository()
|
||||
{
|
||||
var breweries = new[]
|
||||
{
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||
.ReturnsAsync(breweries);
|
||||
|
||||
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
||||
|
||||
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Queries.GetBreweryById;
|
||||
using Features.Breweries.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetBreweryByIdHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly GetBreweryByIdHandler _handler;
|
||||
|
||||
public GetBreweryByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetBreweryByIdHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsBrewery_WhenFound()
|
||||
{
|
||||
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||
.ReturnsAsync(brewery);
|
||||
|
||||
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||
.ReturnsAsync((BreweryPost?)null);
|
||||
|
||||
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
using Infrastructure.Repository.Tests.Database;
|
||||
using Features.Breweries.Repository;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.Breweries;
|
||||
namespace Features.Breweries.Tests.Repository;
|
||||
|
||||
public class BreweryRepositoryTest
|
||||
public class BreweryRepositoryTests
|
||||
{
|
||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Data.Common;
|
||||
using Infrastructure.Sql;
|
||||
|
||||
namespace Features.Breweries.Tests.Repository;
|
||||
|
||||
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
private readonly DbConnection _conn = conn;
|
||||
|
||||
public DbConnection CreateConnection() => _conn;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>.
|
||||
/// </summary>
|
||||
public record CreateBreweryLocation(
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
|
||||
/// </summary>
|
||||
public record CreateBreweryCommand(
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
CreateBreweryLocation Location
|
||||
) : IRequest<BreweryDto>;
|
||||
@@ -0,0 +1,44 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to persist the new brewery post.</param>
|
||||
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
||||
/// </summary>
|
||||
/// <param name="request">The details of the brewery post to create.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <returns>The newly created brewery post.</returns>
|
||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.CreateAsync(entity);
|
||||
return entity.ToDto();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Breweries;
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="BreweryCreateDto"/> instances before they are processed by the brewery creation endpoint.
|
||||
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed.
|
||||
/// </summary>
|
||||
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures validation rules requiring <see cref="BreweryCreateDto.PostedById"/>,
|
||||
/// <see cref="BreweryCreateDto.BreweryName"/>, <see cref="BreweryCreateDto.Description"/>, and
|
||||
/// <see cref="BreweryCreateDto.Location"/> to be present, with length limits on the name, description,
|
||||
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>,
|
||||
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and
|
||||
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description,
|
||||
/// address line 1, and postal code fields.
|
||||
/// </summary>
|
||||
public BreweryCreateDtoValidator()
|
||||
public CreateBreweryValidator()
|
||||
{
|
||||
RuleFor(x => x.PostedById)
|
||||
.NotEmpty()
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||
@@ -0,0 +1,15 @@
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="DeleteBreweryCommand"/> by deleting the matching brewery post.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to delete the brewery post.</param>
|
||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<DeleteBreweryCommand>
|
||||
{
|
||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) =>
|
||||
repository.DeleteAsync(request.BreweryPostId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand"/>.
|
||||
/// </summary>
|
||||
public record UpdateBreweryLocation(
|
||||
Guid BreweryPostLocationId,
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
|
||||
/// A <c>null</c> <see cref="Location"/> clears the brewery's location.
|
||||
/// </summary>
|
||||
public record UpdateBreweryCommand(
|
||||
Guid BreweryPostId,
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
UpdateBreweryLocation? Location
|
||||
) : IRequest<BreweryDto>;
|
||||
@@ -0,0 +1,46 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateBreweryCommand"/> by persisting changes to an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to persist the updated brewery post.</param>
|
||||
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>,
|
||||
/// the brewery's location is cleared.
|
||||
/// </summary>
|
||||
/// <param name="request">The updated details of the brewery post.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <returns>The updated brewery post.</returns>
|
||||
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Location = request.Location is null ? null : new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.UpdateAsync(entity);
|
||||
return entity.ToDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Features.Breweries.Commands.CreateBrewery;
|
||||
using Features.Breweries.Commands.DeleteBrewery;
|
||||
using Features.Breweries.Commands.UpdateBrewery;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetAllBreweries;
|
||||
using Features.Breweries.Queries.GetBreweryById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Breweries.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides CRUD endpoints for managing brewery posts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
||||
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>.
|
||||
/// </remarks>
|
||||
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class BreweryController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
||||
/// <returns>
|
||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found;
|
||||
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||
/// </returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||
{
|
||||
var brewery = await mediator.Send(new GetBreweryByIdQuery(id));
|
||||
if (brewery is null)
|
||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of brewery posts.
|
||||
/// </summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
|
||||
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset)
|
||||
{
|
||||
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
|
||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
|
||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>.</returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
|
||||
{
|
||||
var brewery = await mediator.Send(command);
|
||||
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="command"/>'s ID.</param>
|
||||
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param>
|
||||
/// <returns>
|
||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated <see cref="BreweryDto"/> if the
|
||||
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message
|
||||
/// when the route ID does not match the payload ID.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command)
|
||||
{
|
||||
if (command.BreweryPostId != id)
|
||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||
|
||||
var brewery = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||
{
|
||||
await mediator.Send(new DeleteBreweryCommand(id));
|
||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Features.Breweries.Repository;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Breweries.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services owned by the Breweries feature slice.
|
||||
/// </summary>
|
||||
public static class FeaturesBreweriesServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,4 @@
|
||||
namespace API.Core.Contracts.Breweries;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload describing the location of a brewery to be created, supplied as part of
|
||||
/// <see cref="BreweryCreateDto"/>.
|
||||
/// </summary>
|
||||
public class BreweryLocationCreateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the city in which the brewery is located.
|
||||
/// </summary>
|
||||
public Guid CityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The primary street address line of the brewery.
|
||||
/// </summary>
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// An optional secondary address line (e.g. suite or unit number).
|
||||
/// </summary>
|
||||
public string? AddressLine2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The postal/ZIP code of the brewery's address.
|
||||
/// </summary>
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
||||
/// </summary>
|
||||
public byte[]? Coordinates { get; set; }
|
||||
}
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>.
|
||||
@@ -74,34 +42,8 @@ public class BreweryLocationDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request body used by <see cref="Controllers.BreweryController.Create"/> to create a new brewery post.
|
||||
/// </summary>
|
||||
public class BreweryCreateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the user account that is creating the brewery post.
|
||||
/// </summary>
|
||||
public Guid PostedById { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the brewery; required and limited to 256 characters.
|
||||
/// </summary>
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// A description of the brewery; required and limited to 512 characters.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The location details of the brewery being created.
|
||||
/// </summary>
|
||||
public BreweryLocationCreateDto Location { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a brewery post as returned by, or submitted to, the brewery endpoints, including its
|
||||
/// metadata and optional location.
|
||||
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
|
||||
/// optional location.
|
||||
/// </summary>
|
||||
public class BreweryDto
|
||||
{
|
||||
@@ -0,0 +1,36 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="BreweryPost"/> domain entities to their <see cref="BreweryDto"/> wire representation.
|
||||
/// </summary>
|
||||
public static class BreweryDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation,
|
||||
/// including its location, if present.
|
||||
/// </summary>
|
||||
/// <param name="brewery">The brewery post entity to map.</param>
|
||||
/// <returns>The mapped <see cref="BreweryDto"/>.</returns>
|
||||
public static BreweryDto ToDto(this BreweryPost brewery) => new()
|
||||
{
|
||||
BreweryPostId = brewery.BreweryPostId,
|
||||
PostedById = brewery.PostedById,
|
||||
BreweryName = brewery.BreweryName,
|
||||
Description = brewery.Description,
|
||||
CreatedAt = brewery.CreatedAt,
|
||||
UpdatedAt = brewery.UpdatedAt,
|
||||
Timer = brewery.Timer,
|
||||
Location = brewery.Location is null ? null : new BreweryLocationDto
|
||||
{
|
||||
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
|
||||
BreweryPostId = brewery.Location.BreweryPostId,
|
||||
CityId = brewery.Location.CityId,
|
||||
AddressLine1 = brewery.Location.AddressLine1,
|
||||
AddressLine2 = brewery.Location.AddressLine2,
|
||||
PostalCode = brewery.Location.PostalCode,
|
||||
Coordinates = brewery.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.Breweries</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetAllBreweriesQuery"/> by retrieving a paginated list of brewery posts.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to query brewery post data.</param>
|
||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||
{
|
||||
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var breweries = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
return breweries.Select(b => b.ToDto());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of brewery posts.
|
||||
/// </summary>
|
||||
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryById;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetBreweryByIdQuery"/> by looking up the matching brewery post.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to query brewery post data.</param>
|
||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||
{
|
||||
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var brewery = await repository.GetByIdAsync(request.BreweryPostId);
|
||||
return brewery?.ToDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryById;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Sql;
|
||||
|
||||
namespace Infrastructure.Repository.Breweries;
|
||||
namespace Features.Breweries.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// ADO.NET-based implementation of <see cref="IBreweryRepository"/> backed by SQL Server stored
|
||||
@@ -12,8 +12,6 @@ namespace Infrastructure.Repository.Breweries;
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
||||
{
|
||||
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
|
||||
/// </summary>
|
||||
@@ -1,6 +1,6 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Infrastructure.Repository.Breweries;
|
||||
namespace Features.Breweries.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for CRUD operations on brewery post records.
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Sql;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.Database;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
||||
COPY . .
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Infrastructure.Repository.Auth;
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Sql;
|
||||
|
||||
namespace Infrastructure.Repository.UserAccount;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Data.Common;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Infrastructure.Repository.Sql;
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ISqlConnectionFactory"/> implementation that creates SQL Server connections,
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace Infrastructure.Repository.Sql;
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating database connections used by repositories.
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Infrastructure.Sql</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference
|
||||
Include="Microsoft.SqlServer.Types"
|
||||
Version="160.1000.6"
|
||||
/>
|
||||
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||
Version="9.0.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Data.Common;
|
||||
using Infrastructure.Repository.Sql;
|
||||
|
||||
namespace Infrastructure.Repository;
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for ADO.NET-based repositories, providing shared connection creation and
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Infrastructure.Repository.Sql;
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for building SQL Server connection strings from environment variables.
|
||||
@@ -1,239 +0,0 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Breweries.Tests;
|
||||
|
||||
public class BreweryServiceTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock;
|
||||
private readonly BreweryService _service;
|
||||
|
||||
public BreweryServiceTests()
|
||||
{
|
||||
_repoMock = new Mock<IBreweryRepository>();
|
||||
_service = new BreweryService(_repoMock.Object);
|
||||
}
|
||||
|
||||
private static BreweryLocationCreateRequest ValidLocationCreateRequest() =>
|
||||
new(
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "123 Main St",
|
||||
AddressLine2: null,
|
||||
PostalCode: "12345",
|
||||
Coordinates: null
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsBrewery_WhenFound()
|
||||
{
|
||||
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid() };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||
.ReturnsAsync(brewery);
|
||||
|
||||
var result = await _service.GetByIdAsync(brewery.BreweryPostId);
|
||||
|
||||
result.Should().Be(brewery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||
.ReturnsAsync((BreweryPost?)null);
|
||||
|
||||
var result = await _service.GetByIdAsync(id);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
var result = await _service.GetAllAsync(10, 5);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsAllBreweries_FromRepository()
|
||||
{
|
||||
var breweries = new[]
|
||||
{
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid() },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid() },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||
.ReturnsAsync(breweries);
|
||||
|
||||
var result = await _service.GetAllAsync();
|
||||
|
||||
result.Should().BeEquivalentTo(breweries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||
{
|
||||
var request = new BreweryCreateRequest(
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "MyBrew",
|
||||
Description: "Desc",
|
||||
Location: ValidLocationCreateRequest()
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await _service.CreateAsync(request);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
||||
persisted.PostedById.Should().Be(request.PostedById);
|
||||
persisted.BreweryName.Should().Be("MyBrew");
|
||||
persisted.Description.Should().Be("Desc");
|
||||
persisted.CreatedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
persisted.UpdatedAt.Should().BeNull();
|
||||
|
||||
persisted.Location.Should().NotBeNull();
|
||||
persisted.Location!.BreweryPostLocationId.Should().NotBe(Guid.Empty);
|
||||
persisted.Location.BreweryPostLocationId.Should().NotBe(persisted.BreweryPostId);
|
||||
persisted.Location.CityId.Should().Be(request.Location.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(request.Location.AddressLine1);
|
||||
persisted.Location.PostalCode.Should().Be(request.Location.PostalCode);
|
||||
|
||||
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsServiceReturn_WrappingTheCreatedEntity()
|
||||
{
|
||||
var request = new BreweryCreateRequest(
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "MyBrew",
|
||||
Description: "Desc",
|
||||
Location: ValidLocationCreateRequest()
|
||||
);
|
||||
|
||||
_repoMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Brewery.BreweryName.Should().Be("MyBrew");
|
||||
result.Brewery.Location.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||
{
|
||||
var request = new BreweryUpdateRequest(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Renamed",
|
||||
Description: "New description",
|
||||
Location: null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await _service.UpdateAsync(request);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().Be(request.BreweryPostId);
|
||||
persisted.PostedById.Should().Be(request.PostedById);
|
||||
persisted.BreweryName.Should().Be("Renamed");
|
||||
persisted.Description.Should().Be("New description");
|
||||
persisted.UpdatedAt.Should().NotBeNull();
|
||||
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ClearsLocation_WhenRequestLocationIsNull()
|
||||
{
|
||||
var request = new BreweryUpdateRequest(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _service.UpdateAsync(request);
|
||||
|
||||
persisted!.Location.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_SetsLocation_WhenRequestLocationProvided()
|
||||
{
|
||||
var locationRequest = new BreweryLocationUpdateRequest(
|
||||
BreweryPostLocationId: Guid.NewGuid(),
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "456 Oak Ave",
|
||||
AddressLine2: "Suite 2",
|
||||
PostalCode: "54321",
|
||||
Coordinates: null
|
||||
);
|
||||
var request = new BreweryUpdateRequest(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: locationRequest
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await _service.UpdateAsync(request);
|
||||
|
||||
persisted!.Location.Should().NotBeNull();
|
||||
persisted.Location!.BreweryPostLocationId.Should().Be(locationRequest.BreweryPostLocationId);
|
||||
persisted.Location.BreweryPostId.Should().Be(request.BreweryPostId);
|
||||
persisted.Location.CityId.Should().Be(locationRequest.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(locationRequest.AddressLine1);
|
||||
persisted.Location.AddressLine2.Should().Be(locationRequest.AddressLine2);
|
||||
persisted.Location.PostalCode.Should().Be(locationRequest.PostalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_DelegatesToRepository()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
||||
|
||||
await _service.DeleteAsync(id);
|
||||
|
||||
_repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
|
||||
namespace Service.Breweries;
|
||||
|
||||
/// <summary>
|
||||
/// Handles retrieval, creation, update, and deletion of brewery posts.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to persist and query brewery post data.</param>
|
||||
public class BreweryService(IBreweryRepository repository) : IBreweryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if no such post exists.</returns>
|
||||
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
|
||||
repository.GetByIdAsync(id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all brewery posts, optionally paginated.
|
||||
/// </summary>
|
||||
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||
/// <returns>A collection of <see cref="BreweryPost"/> entities.</returns>
|
||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
|
||||
repository.GetAllAsync(limit, offset);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
||||
/// </summary>
|
||||
/// <param name="request">The details of the brewery post to create.</param>
|
||||
/// <returns>A successful <see cref="BreweryServiceReturn"/> wrapping the newly created brewery post.</returns>
|
||||
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.CreateAsync(entity);
|
||||
return new BreweryServiceReturn(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>,
|
||||
/// the brewery's location is cleared.
|
||||
/// </summary>
|
||||
/// <param name="request">The updated details of the brewery post.</param>
|
||||
/// <returns>A successful <see cref="BreweryServiceReturn"/> wrapping the updated brewery post.</returns>
|
||||
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Location = request.Location is null ? null : new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.UpdateAsync(entity);
|
||||
return new BreweryServiceReturn(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||
/// <returns>A task that completes once the deletion has finished.</returns>
|
||||
public Task DeleteAsync(Guid id) =>
|
||||
repository.DeleteAsync(id);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.Breweries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the data required to create a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="PostedById">The unique identifier of the user creating the post.</param>
|
||||
/// <param name="BreweryName">The name of the brewery.</param>
|
||||
/// <param name="Description">A description of the brewery.</param>
|
||||
/// <param name="Location">The location details for the new brewery post.</param>
|
||||
public record BreweryCreateRequest(
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
BreweryLocationCreateRequest Location
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the location data required to create a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="CityId">The unique identifier of the city the brewery is located in.</param>
|
||||
/// <param name="AddressLine1">The primary address line.</param>
|
||||
/// <param name="AddressLine2">An optional secondary address line.</param>
|
||||
/// <param name="PostalCode">The postal code of the brewery's address.</param>
|
||||
/// <param name="Coordinates">Optional geographic coordinates for the brewery's location.</param>
|
||||
public record BreweryLocationCreateRequest(
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the data required to update an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="BreweryPostId">The unique identifier of the brewery post to update.</param>
|
||||
/// <param name="PostedById">The unique identifier of the user who owns/posted the brewery.</param>
|
||||
/// <param name="BreweryName">The updated name of the brewery.</param>
|
||||
/// <param name="Description">The updated description of the brewery.</param>
|
||||
/// <param name="Location">The updated location details, or <c>null</c> to clear the location.</param>
|
||||
public record BreweryUpdateRequest(
|
||||
Guid BreweryPostId,
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
BreweryLocationUpdateRequest? Location
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the location data required to update an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="BreweryPostLocationId">The unique identifier of the existing brewery location record.</param>
|
||||
/// <param name="CityId">The unique identifier of the city the brewery is located in.</param>
|
||||
/// <param name="AddressLine1">The primary address line.</param>
|
||||
/// <param name="AddressLine2">An optional secondary address line.</param>
|
||||
/// <param name="PostalCode">The postal code of the brewery's address.</param>
|
||||
/// <param name="Coordinates">Optional geographic coordinates for the brewery's location.</param>
|
||||
public record BreweryLocationUpdateRequest(
|
||||
Guid BreweryPostLocationId,
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a create or update operation against a brewery post.
|
||||
/// </summary>
|
||||
public record BreweryServiceReturn
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resulting brewery post when the operation succeeded; otherwise a default/unset value.
|
||||
/// </summary>
|
||||
public BreweryPost Brewery { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a message describing the failure reason, empty when <see cref="Success"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new successful result wrapping the given brewery post.
|
||||
/// </summary>
|
||||
/// <param name="brewery">The brewery post resulting from the operation.</param>
|
||||
public BreweryServiceReturn(BreweryPost brewery)
|
||||
{
|
||||
Success = true;
|
||||
Brewery = brewery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new failed result with the given error message.
|
||||
/// </summary>
|
||||
/// <param name="message">A message describing why the operation failed.</param>
|
||||
public BreweryServiceReturn(string message)
|
||||
{
|
||||
Success = false;
|
||||
Brewery = default!;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines operations for retrieving, creating, updating, and deleting brewery posts.
|
||||
/// </summary>
|
||||
public interface IBreweryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post.</param>
|
||||
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if no such post exists.</returns>
|
||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all brewery posts, optionally paginated.
|
||||
/// </summary>
|
||||
/// <param name="limit">The maximum number of results to return, or <c>null</c> for no limit.</param>
|
||||
/// <param name="offset">The number of results to skip, or <c>null</c> to start from the beginning.</param>
|
||||
/// <returns>A collection of <see cref="BreweryPost"/> entities.</returns>
|
||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="request">The details of the brewery post to create.</param>
|
||||
/// <returns>A <see cref="BreweryServiceReturn"/> describing the outcome of the creation.</returns>
|
||||
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="request">The updated details of the brewery post.</param>
|
||||
/// <returns>A <see cref="BreweryServiceReturn"/> describing the outcome of the update.</returns>
|
||||
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||
/// <returns>A task that completes once the deletion has finished.</returns>
|
||||
Task DeleteAsync(Guid id);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user