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:
Aaron Po
2026-06-19 23:54:25 -04:00
parent 62d682472e
commit a8c1b17095
46 changed files with 803 additions and 789 deletions

View File

@@ -30,10 +30,11 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.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="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.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="..\..\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.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -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,
},
};
}

View File

@@ -8,16 +8,35 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src 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 ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] 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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] 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/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/"] 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 . . COPY . .
WORKDIR "/src/API/API.Core" WORKDIR "/src/API/API.Core"
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -1,5 +1,7 @@
using API.Core; using API.Core;
using API.Core.Authentication; using API.Core.Authentication;
using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Email; using Infrastructure.Email;
@@ -7,11 +9,9 @@ using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount; using Infrastructure.Repository.UserAccount;
using Infrastructure.Repository.Breweries; using Infrastructure.Sql;
using Service.Auth; using Service.Auth;
using Service.Breweries;
using Service.Emails; using Service.Emails;
using Service.UserManagement.User; using Service.UserManagement.User;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
@@ -22,7 +22,8 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {
options.Filters.Add<GlobalExceptionFilter>(); options.Filters.Add<GlobalExceptionFilter>();
}); })
.AddApplicationPart(typeof(BreweryController).Assembly);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
@@ -30,6 +31,7 @@ builder.Services.AddOpenApi();
// Add FluentValidation // Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced; // 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 => builder.Services.AddMediatR(cfg =>
{ {
cfg.RegisterServicesFromAssemblyContaining<Program>(); cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });
@@ -60,8 +63,7 @@ builder.Services.AddSingleton<
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>(); builder.Services.AddFeaturesBreweries();
builder.Services.AddScoped<IBreweryService, BreweryService>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>(); builder.Services.AddScoped<ILoginService, LoginService>();

View File

@@ -21,13 +21,17 @@
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" /> <Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
<Project <Project
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" /> 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>
<Folder Name="/Service/"> <Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" /> <Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" /> <Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" /> <Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service/Service.Auth/Service.Auth.csproj" /> <Project Path="Service/Service.Auth/Service.Auth.csproj" />
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Service.Breweries.Tests</RootNamespace> <RootNamespace>Features.Breweries.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -13,6 +13,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -20,10 +21,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" /> <ProjectReference Include="..\Features.Breweries\Features.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" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -1,12 +1,11 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Repository.Breweries; using Features.Breweries.Repository;
using Infrastructure.Repository.Tests.Database;
using Domain.Entities; 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) => private static BreweryRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn)); new(new TestConnectionFactory(conn));

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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();
}
}

View File

@@ -1,19 +1,19 @@
using FluentValidation; using FluentValidation;
namespace API.Core.Contracts.Breweries; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <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> /// </summary>
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
/// <summary> /// <summary>
/// Configures validation rules requiring <see cref="BreweryCreateDto.PostedById"/>, /// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>,
/// <see cref="BreweryCreateDto.BreweryName"/>, <see cref="BreweryCreateDto.Description"/>, and /// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and
/// <see cref="BreweryCreateDto.Location"/> to be present, with length limits on the name, description, /// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description,
/// address line 1, and postal code fields. /// address line 1, and postal code fields.
/// </summary> /// </summary>
public BreweryCreateDtoValidator() public CreateBreweryValidator()
{ {
RuleFor(x => x.PostedById) RuleFor(x => x.PostedById)
.NotEmpty() .NotEmpty()

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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>;

View File

@@ -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();
}
}

View File

@@ -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." });
}
}

View File

@@ -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;
}
}

View File

@@ -1,36 +1,4 @@
namespace API.Core.Contracts.Breweries; namespace Features.Breweries.Dtos;
/// <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; }
}
/// <summary> /// <summary>
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>. /// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>.
@@ -74,34 +42,8 @@ public class BreweryLocationDto
} }
/// <summary> /// <summary>
/// Request body used by <see cref="Controllers.BreweryController.Create"/> to create a new brewery post. /// Represents a brewery post as returned by the brewery endpoints, including its metadata and
/// </summary> /// optional location.
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.
/// </summary> /// </summary>
public class BreweryDto public class BreweryDto
{ {

View File

@@ -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,
},
};
}

View File

@@ -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>

View File

@@ -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());
}
}

View File

@@ -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>>;

View File

@@ -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();
}
}

View File

@@ -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?>;

View File

@@ -1,8 +1,8 @@
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Repository.Sql; using Infrastructure.Sql;
namespace Infrastructure.Repository.Breweries; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// ADO.NET-based implementation of <see cref="IBreweryRepository"/> backed by SQL Server stored /// 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) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), IBreweryRepository : Repository<BreweryPost>(connectionFactory), IBreweryRepository
{ {
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
/// <summary> /// <summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure. /// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
/// </summary> /// </summary>

View File

@@ -1,6 +1,6 @@
using Domain.Entities; using Domain.Entities;
namespace Infrastructure.Repository.Breweries; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Repository for CRUD operations on brewery post records. /// Repository for CRUD operations on brewery post records.

View File

@@ -1,5 +1,5 @@
using System.Data.Common; using System.Data.Common;
using Infrastructure.Repository.Sql; using Infrastructure.Sql;
namespace Infrastructure.Repository.Tests.Database; namespace Infrastructure.Repository.Tests.Database;

View File

@@ -1,9 +1,10 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] 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/"] COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj" RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
COPY . . COPY . .

View File

@@ -22,5 +22,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\Infrastructure.Sql\Infrastructure.Sql.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,7 +1,7 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Repository.Sql; using Infrastructure.Sql;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Auth; namespace Infrastructure.Repository.Auth;

View File

@@ -19,5 +19,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\Infrastructure.Sql\Infrastructure.Sql.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,6 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using Infrastructure.Repository.Sql; using Infrastructure.Sql;
namespace Infrastructure.Repository.UserAccount; namespace Infrastructure.Repository.UserAccount;

View File

@@ -2,7 +2,7 @@ using System.Data.Common;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Infrastructure.Repository.Sql; namespace Infrastructure.Sql;
/// <summary> /// <summary>
/// Default <see cref="ISqlConnectionFactory"/> implementation that creates SQL Server connections, /// Default <see cref="ISqlConnectionFactory"/> implementation that creates SQL Server connections,

View File

@@ -1,6 +1,6 @@
using System.Data.Common; using System.Data.Common;
namespace Infrastructure.Repository.Sql; namespace Infrastructure.Sql;
/// <summary> /// <summary>
/// Factory for creating database connections used by repositories. /// Factory for creating database connections used by repositories.

View File

@@ -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>

View File

@@ -1,7 +1,6 @@
using System.Data.Common; using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository; namespace Infrastructure.Sql;
/// <summary> /// <summary>
/// Base class for ADO.NET-based repositories, providing shared connection creation and /// Base class for ADO.NET-based repositories, providing shared connection creation and

View File

@@ -1,6 +1,6 @@
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Sql; namespace Infrastructure.Sql;
/// <summary> /// <summary>
/// Helper for building SQL Server connection strings from environment variables. /// Helper for building SQL Server connection strings from environment variables.

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>