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 60a7b790d4
commit 34ba7e8271
46 changed files with 803 additions and 789 deletions

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,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Service.Breweries.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</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" />
</ItemGroup>
</Project>

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>