From 34ba7e8271d7acabbaee92f0bbf34126e7153f91 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Fri, 19 Jun 2026 23:54:25 -0400 Subject: [PATCH] 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. --- web/backend/API/API.Core/API.Core.csproj | 3 +- .../API.Core/Controllers/BreweryController.cs | 183 -------------- web/backend/API/API.Core/Dockerfile | 29 ++- web/backend/API/API.Core/Program.cs | 14 +- web/backend/Core.slnx | 6 +- .../Commands/CreateBreweryHandlerTests.cs | 68 +++++ .../Commands/DeleteBreweryHandlerTests.cs | 21 ++ .../Commands/UpdateBreweryHandlerTests.cs | 106 ++++++++ .../Features.Breweries.Tests.csproj} | 9 +- .../Queries/GetAllBreweriesHandlerTests.cs | 46 ++++ .../Queries/GetBreweryByIdHandlerTests.cs | 43 ++++ .../Repository/BreweryRepositoryTests.cs} | 9 +- .../Repository/TestConnectionFactory.cs | 11 + .../CreateBrewery/CreateBreweryCommand.cs | 25 ++ .../CreateBrewery/CreateBreweryHandler.cs | 44 ++++ .../CreateBrewery/CreateBreweryValidator.cs} | 14 +- .../DeleteBrewery/DeleteBreweryCommand.cs | 8 + .../DeleteBrewery/DeleteBreweryHandler.cs | 15 ++ .../UpdateBrewery/UpdateBreweryCommand.cs | 28 ++ .../UpdateBrewery/UpdateBreweryHandler.cs | 46 ++++ .../Controllers/BreweryController.cs | 123 +++++++++ ...resBreweriesServiceCollectionExtensions.cs | 16 ++ .../Features.Breweries/Dtos}/BreweryDto.cs | 64 +---- .../Dtos/BreweryDtoMapper.cs | 36 +++ .../Features.Breweries.csproj | 19 ++ .../GetAllBreweries/GetAllBreweriesHandler.cs | 19 ++ .../GetAllBreweries/GetAllBreweriesQuery.cs | 9 + .../GetBreweryById/GetBreweryByIdHandler.cs | 19 ++ .../GetBreweryById/GetBreweryByIdQuery.cs | 9 + .../Repository}/BreweryRepository.cs | 6 +- .../Repository}/IBreweryRepository.cs | 4 +- .../Database/TestConnectionFactory.cs | 2 +- .../Dockerfile | 5 +- .../Infrastructure.Repository.Tests.csproj | 1 + .../Auth/AuthRepository.cs | 2 +- .../Infrastructure.Repository.csproj | 1 + .../UserAccount/UserAccountRepository.cs | 2 +- .../DefaultSqlConnectionFactory.cs | 2 +- .../ISqlConnectionFactory.cs | 2 +- .../Infrastructure.Sql.csproj | 20 ++ .../Repository.cs | 3 +- .../SqlConnectionStringHelper.cs | 2 +- .../BreweryService.test.cs | 239 ------------------ .../Service.Breweries/BreweryService.cs | 96 ------- .../Service.Breweries/IBreweryService.cs | 151 ----------- .../Service.Breweries.csproj | 12 - 46 files changed, 803 insertions(+), 789 deletions(-) delete mode 100644 web/backend/API/API.Core/Controllers/BreweryController.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs rename web/backend/{Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj => Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj} (63%) create mode 100644 web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs rename web/backend/{Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs => Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs} (95%) create mode 100644 web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs create mode 100644 web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs create mode 100644 web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs rename web/backend/{API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs => Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs} (74%) create mode 100644 web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs create mode 100644 web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs create mode 100644 web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs create mode 100644 web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs create mode 100644 web/backend/Features/Features.Breweries/Controllers/BreweryController.cs create mode 100644 web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs rename web/backend/{API/API.Core/Contracts/Breweries => Features/Features.Breweries/Dtos}/BreweryDto.cs (56%) create mode 100644 web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs create mode 100644 web/backend/Features/Features.Breweries/Features.Breweries.csproj create mode 100644 web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs create mode 100644 web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs create mode 100644 web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs create mode 100644 web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs rename web/backend/{Infrastructure/Infrastructure.Repository/Breweries => Features/Features.Breweries/Repository}/BreweryRepository.cs (98%) rename web/backend/{Infrastructure/Infrastructure.Repository/Breweries => Features/Features.Breweries/Repository}/IBreweryRepository.cs (97%) rename web/backend/Infrastructure/{Infrastructure.Repository/Sql => Infrastructure.Sql}/DefaultSqlConnectionFactory.cs (98%) rename web/backend/Infrastructure/{Infrastructure.Repository/Sql => Infrastructure.Sql}/ISqlConnectionFactory.cs (89%) create mode 100644 web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj rename web/backend/Infrastructure/{Infrastructure.Repository => Infrastructure.Sql}/Repository.cs (94%) rename web/backend/Infrastructure/{Infrastructure.Repository/Sql => Infrastructure.Sql}/SqlConnectionStringHelper.cs (98%) delete mode 100644 web/backend/Service/Service.Breweries.Tests/BreweryService.test.cs delete mode 100644 web/backend/Service/Service.Breweries/BreweryService.cs delete mode 100644 web/backend/Service/Service.Breweries/IBreweryService.cs delete mode 100644 web/backend/Service/Service.Breweries/Service.Breweries.csproj diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 35541dc..fa52cc5 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -30,10 +30,11 @@ + - + diff --git a/web/backend/API/API.Core/Controllers/BreweryController.cs b/web/backend/API/API.Core/Controllers/BreweryController.cs deleted file mode 100644 index d95074f..0000000 --- a/web/backend/API/API.Core/Controllers/BreweryController.cs +++ /dev/null @@ -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; - -/// -/// Provides CRUD endpoints for managing brewery posts. -/// -/// -/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default; read endpoints -/// ( and ) opt out via [AllowAnonymous]. -/// -/// Service used to query and mutate brewery post data. -[ApiController] -[Route("api/[controller]")] -[Authorize(AuthenticationSchemes = "JWT")] -public class BreweryController(IBreweryService breweryService) : ControllerBase -{ - /// - /// Retrieves a single brewery post by its unique identifier. - /// - /// Allows anonymous access. - /// The unique identifier of the brewery post to retrieve. - /// - /// A 200 OK result wrapping a of if found; - /// otherwise a 404 Not Found result wrapping a error message. - /// - [AllowAnonymous] - [HttpGet("{id:guid}")] - public async Task>> 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 - { - Message = "Brewery retrieved successfully.", - Payload = MapToDto(brewery), - }); - } - - /// - /// Retrieves a paginated list of brewery posts. - /// - /// Allows anonymous access. - /// The maximum number of brewery posts to return, or null for no limit. - /// The number of brewery posts to skip before returning results, or null for no offset. - /// A 200 OK result wrapping a of a collection of . - [AllowAnonymous] - [HttpGet] - public async Task>>> GetAll( - [FromQuery] int? limit, - [FromQuery] int? offset) - { - var breweries = await breweryService.GetAllAsync(limit, offset); - return Ok(new ResponseBody> - { - Message = "Breweries retrieved successfully.", - Payload = breweries.Select(MapToDto), - }); - } - - /// - /// Creates a new brewery post. - /// - /// The brewery details to create, including the posting user, name, description, and location. - /// - /// A 201 Created result wrapping a of the newly created - /// if creation succeeds; otherwise a 400 Bad Request result wrapping a error message. - /// - [HttpPost] - public async Task>> 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 - { - Message = "Brewery created successfully.", - Payload = MapToDto(result.Brewery), - }); - } - - /// - /// Updates an existing brewery post. - /// - /// The unique identifier of the brewery post from the route, which must match 's ID. - /// The updated brewery details, including the posting user, name, description, and optional location. - /// - /// A 200 OK result wrapping a of the updated brewery (as a ) - /// if the update succeeds; otherwise a 400 Bad Request result wrapping a error message - /// (returned when the route ID does not match the payload ID, or when the update itself fails). - /// - [HttpPut("{id:guid}")] - public async Task>> 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 - { - Message = "Brewery updated successfully.", - Payload = result.Brewery, - }); - } - - /// - /// Deletes a brewery post. - /// - /// The unique identifier of the brewery post to delete. - /// A 200 OK result wrapping a confirming the deletion. - [HttpDelete("{id:guid}")] - public async Task> Delete(Guid id) - { - await breweryService.DeleteAsync(id); - return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); - } - - /// - /// Maps a domain entity to its representation, - /// including its location, if present. - /// - /// The brewery post entity to map. - /// The mapped . - 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, - }, - }; -} diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index 03dd219..5926b1d 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -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 diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 0106454..811a9f3 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -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(); -}); +}) + .AddApplicationPart(typeof(BreweryController).Assembly); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -30,6 +31,7 @@ builder.Services.AddOpenApi(); // Add FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); +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(); + cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); @@ -60,8 +63,7 @@ builder.Services.AddSingleton< builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddFeaturesBreweries(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/web/backend/Core.slnx b/web/backend/Core.slnx index 860f7f7..7318055 100644 --- a/web/backend/Core.slnx +++ b/web/backend/Core.slnx @@ -21,13 +21,17 @@ + + + + + - diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs new file mode 100644 index 0000000..a632d11 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs @@ -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 _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())) + .Callback(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()), Times.Once); + + result.BreweryName.Should().Be("MyBrew"); + result.Location.Should().NotBeNull(); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs new file mode 100644 index 0000000..1c7106c --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs @@ -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(); + 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); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs new file mode 100644 index 0000000..797c1a3 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs @@ -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 _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())) + .Callback(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())) + .Callback(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())) + .Callback(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); + } +} diff --git a/web/backend/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj similarity index 63% rename from web/backend/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj rename to web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index 5803530..debe2e0 100644 --- a/web/backend/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -4,7 +4,7 @@ enable enable false - Service.Breweries.Tests + Features.Breweries.Tests @@ -13,6 +13,7 @@ + @@ -20,10 +21,6 @@ - - - - + diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs new file mode 100644 index 0000000..9cd96fc --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs @@ -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 _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()); + + 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)); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs new file mode 100644 index 0000000..0c98295 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs @@ -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 _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(); + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs similarity index 95% rename from web/backend/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs rename to web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs index 6352124..aefb999 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs +++ b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs @@ -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)); @@ -100,7 +99,7 @@ public class BreweryRepositoryTest Coordinates = [0x00, 0x01] } }; - + // Should not throw var act = async () => await repo.CreateAsync(brewery); await act.Should().NotThrowAsync(); diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs new file mode 100644 index 0000000..69c6c1a --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs @@ -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; +} diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs new file mode 100644 index 0000000..9a61928 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs @@ -0,0 +1,25 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Commands.CreateBrewery; + +/// +/// Location data required to create a new brewery post, supplied as part of . +/// +public record CreateBreweryLocation( + Guid CityId, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); + +/// +/// Creates a new brewery post. Bound directly from the request body of POST /api/brewery. +/// +public record CreateBreweryCommand( + Guid PostedById, + string BreweryName, + string Description, + CreateBreweryLocation Location +) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs new file mode 100644 index 0000000..5eea5ec --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs @@ -0,0 +1,44 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Commands.CreateBrewery; + +/// +/// Handles by persisting a new brewery post and its location. +/// +/// Repository used to persist the new brewery post. +public class CreateBreweryHandler(IBreweryRepository repository) + : IRequestHandler +{ + /// + /// Creates a new brewery post, generating new identifiers for the post and its location. + /// + /// The details of the brewery post to create. + /// A token to observe for cancellation requests. + /// The newly created brewery post. + public async Task 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(); + } +} diff --git a/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs similarity index 74% rename from web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs rename to web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs index fa8d26f..bb1c2e6 100644 --- a/web/backend/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs @@ -1,19 +1,19 @@ using FluentValidation; -namespace API.Core.Contracts.Breweries; +namespace Features.Breweries.Commands.CreateBrewery; /// -/// Validates instances before they are processed by the brewery creation endpoint. +/// Validates instances before they are processed. /// -public class BreweryCreateDtoValidator : AbstractValidator +public class CreateBreweryValidator : AbstractValidator { /// - /// Configures validation rules requiring , - /// , , and - /// to be present, with length limits on the name, description, + /// Configures validation rules requiring , + /// , , and + /// to be present, with length limits on the name, description, /// address line 1, and postal code fields. /// - public BreweryCreateDtoValidator() + public CreateBreweryValidator() { RuleFor(x => x.PostedById) .NotEmpty() diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs new file mode 100644 index 0000000..360de7c --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Features.Breweries.Commands.DeleteBrewery; + +/// +/// Deletes a brewery post by its unique identifier. +/// +public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs new file mode 100644 index 0000000..44675f5 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs @@ -0,0 +1,15 @@ +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Commands.DeleteBrewery; + +/// +/// Handles by deleting the matching brewery post. +/// +/// Repository used to delete the brewery post. +public class DeleteBreweryHandler(IBreweryRepository repository) + : IRequestHandler +{ + public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) => + repository.DeleteAsync(request.BreweryPostId); +} diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs new file mode 100644 index 0000000..b4126b2 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -0,0 +1,28 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Commands.UpdateBrewery; + +/// +/// Location data for an existing brewery post, supplied as part of . +/// +public record UpdateBreweryLocation( + Guid BreweryPostLocationId, + Guid CityId, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); + +/// +/// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}. +/// A null clears the brewery's location. +/// +public record UpdateBreweryCommand( + Guid BreweryPostId, + Guid PostedById, + string BreweryName, + string Description, + UpdateBreweryLocation? Location +) : IRequest; diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs new file mode 100644 index 0000000..8cac769 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs @@ -0,0 +1,46 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Commands.UpdateBrewery; + +/// +/// Handles by persisting changes to an existing brewery post. +/// +/// Repository used to persist the updated brewery post. +public class UpdateBreweryHandler(IBreweryRepository repository) + : IRequestHandler +{ + /// + /// Updates an existing brewery post. If has no Location, + /// the brewery's location is cleared. + /// + /// The updated details of the brewery post. + /// A token to observe for cancellation requests. + /// The updated brewery post. + public async Task 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(); + } +} diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs new file mode 100644 index 0000000..db5ecdc --- /dev/null +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -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; + +/// +/// Provides CRUD endpoints for managing brewery posts. +/// +/// +/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default; read endpoints +/// ( and ) opt out via [AllowAnonymous]. +/// +/// Used to dispatch brewery commands and queries to their handlers. +[ApiController] +[Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] +public class BreweryController(IMediator mediator) : ControllerBase +{ + /// + /// Retrieves a single brewery post by its unique identifier. + /// + /// Allows anonymous access. + /// The unique identifier of the brewery post to retrieve. + /// + /// A 200 OK result wrapping a of if found; + /// otherwise a 404 Not Found result wrapping a error message. + /// + [AllowAnonymous] + [HttpGet("{id:guid}")] + public async Task>> 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 + { + Message = "Brewery retrieved successfully.", + Payload = brewery, + }); + } + + /// + /// Retrieves a paginated list of brewery posts. + /// + /// Allows anonymous access. + /// The maximum number of brewery posts to return, or null for no limit. + /// The number of brewery posts to skip before returning results, or null for no offset. + /// A 200 OK result wrapping a of a collection of . + [AllowAnonymous] + [HttpGet] + public async Task>>> GetAll( + [FromQuery] int? limit, + [FromQuery] int? offset) + { + var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset)); + return Ok(new ResponseBody> + { + Message = "Breweries retrieved successfully.", + Payload = breweries, + }); + } + + /// + /// Creates a new brewery post. + /// + /// The brewery details to create, including the posting user, name, description, and location. + /// A 201 Created result wrapping a of the newly created . + [HttpPost] + public async Task>> Create([FromBody] CreateBreweryCommand command) + { + var brewery = await mediator.Send(command); + return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody + { + Message = "Brewery created successfully.", + Payload = brewery, + }); + } + + /// + /// Updates an existing brewery post. + /// + /// The unique identifier of the brewery post from the route, which must match 's ID. + /// The updated brewery details, including the posting user, name, description, and optional location. + /// + /// A 200 OK result wrapping a of the updated if the + /// update succeeds; otherwise a 400 Bad Request result wrapping a error message + /// when the route ID does not match the payload ID. + /// + [HttpPut("{id:guid}")] + public async Task>> 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 + { + Message = "Brewery updated successfully.", + Payload = brewery, + }); + } + + /// + /// Deletes a brewery post. + /// + /// The unique identifier of the brewery post to delete. + /// A 200 OK result wrapping a confirming the deletion. + [HttpDelete("{id:guid}")] + public async Task> Delete(Guid id) + { + await mediator.Send(new DeleteBreweryCommand(id)); + return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); + } +} diff --git a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs new file mode 100644 index 0000000..6fc01db --- /dev/null +++ b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Features.Breweries.Repository; +using Microsoft.Extensions.DependencyInjection; + +namespace Features.Breweries.DependencyInjection; + +/// +/// Registers the services owned by the Breweries feature slice. +/// +public static class FeaturesBreweriesServiceCollectionExtensions +{ + public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs similarity index 56% rename from web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs rename to web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index eda26e3..c05c48d 100644 --- a/web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -1,36 +1,4 @@ -namespace API.Core.Contracts.Breweries; - -/// -/// Request payload describing the location of a brewery to be created, supplied as part of -/// . -/// -public class BreweryLocationCreateDto -{ - /// - /// The unique identifier of the city in which the brewery is located. - /// - public Guid CityId { get; set; } - - /// - /// The primary street address line of the brewery. - /// - public string AddressLine1 { get; set; } = string.Empty; - - /// - /// An optional secondary address line (e.g. suite or unit number). - /// - public string? AddressLine2 { get; set; } - - /// - /// The postal/ZIP code of the brewery's address. - /// - public string PostalCode { get; set; } = string.Empty; - - /// - /// The optional geographic coordinates of the brewery, in a raw binary representation. - /// - public byte[]? Coordinates { get; set; } -} +namespace Features.Breweries.Dtos; /// /// Represents the location details of an existing brewery, as returned in . @@ -74,34 +42,8 @@ public class BreweryLocationDto } /// -/// Request body used by to create a new brewery post. -/// -public class BreweryCreateDto -{ - /// - /// The unique identifier of the user account that is creating the brewery post. - /// - public Guid PostedById { get; set; } - - /// - /// The name of the brewery; required and limited to 256 characters. - /// - public string BreweryName { get; set; } = string.Empty; - - /// - /// A description of the brewery; required and limited to 512 characters. - /// - public string Description { get; set; } = string.Empty; - - /// - /// The location details of the brewery being created. - /// - public BreweryLocationCreateDto Location { get; set; } = null!; -} - -/// -/// 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. /// public class BreweryDto { diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs new file mode 100644 index 0000000..90db7a3 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -0,0 +1,36 @@ +using Domain.Entities; + +namespace Features.Breweries.Dtos; + +/// +/// Maps domain entities to their wire representation. +/// +public static class BreweryDtoMapper +{ + /// + /// Maps a domain entity to its representation, + /// including its location, if present. + /// + /// The brewery post entity to map. + /// The mapped . + 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, + }, + }; +} diff --git a/web/backend/Features/Features.Breweries/Features.Breweries.csproj b/web/backend/Features/Features.Breweries/Features.Breweries.csproj new file mode 100644 index 0000000..0c5faed --- /dev/null +++ b/web/backend/Features/Features.Breweries/Features.Breweries.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + Features.Breweries + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs new file mode 100644 index 0000000..e758625 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs @@ -0,0 +1,19 @@ +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Queries.GetAllBreweries; + +/// +/// Handles by retrieving a paginated list of brewery posts. +/// +/// Repository used to query brewery post data. +public class GetAllBreweriesHandler(IBreweryRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken) + { + var breweries = await repository.GetAllAsync(request.Limit, request.Offset); + return breweries.Select(b => b.ToDto()); + } +} diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs new file mode 100644 index 0000000..d8caea2 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs @@ -0,0 +1,9 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Queries.GetAllBreweries; + +/// +/// Retrieves a paginated list of brewery posts. +/// +public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs new file mode 100644 index 0000000..efbd3c5 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs @@ -0,0 +1,19 @@ +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Queries.GetBreweryById; + +/// +/// Handles by looking up the matching brewery post. +/// +/// Repository used to query brewery post data. +public class GetBreweryByIdHandler(IBreweryRepository repository) + : IRequestHandler +{ + public async Task Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken) + { + var brewery = await repository.GetByIdAsync(request.BreweryPostId); + return brewery?.ToDto(); + } +} diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs new file mode 100644 index 0000000..c753991 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs @@ -0,0 +1,9 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Queries.GetBreweryById; + +/// +/// Retrieves a single brewery post by its unique identifier. +/// +public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest; diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs similarity index 98% rename from web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs rename to web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index a974e86..e8753e3 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -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; /// /// ADO.NET-based implementation of backed by SQL Server stored @@ -12,8 +12,6 @@ namespace Infrastructure.Repository.Breweries; public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IBreweryRepository { - private readonly ISqlConnectionFactory _connectionFactory = connectionFactory; - /// /// Retrieves a brewery post by ID using the USP_GetBreweryById stored procedure. /// diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs similarity index 97% rename from web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs rename to web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index 9598bfe..8563190 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -1,6 +1,6 @@ using Domain.Entities; -namespace Infrastructure.Repository.Breweries; +namespace Features.Breweries.Repository; /// /// Repository for CRUD operations on brewery post records. @@ -40,4 +40,4 @@ public interface IBreweryRepository /// The brewery post to create. Must have a non-null Location. /// Thrown when has no Location. Task CreateAsync(BreweryPost brewery); -} \ No newline at end of file +} diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs index c25f0e1..d43dcda 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs @@ -1,5 +1,5 @@ using System.Data.Common; -using Infrastructure.Repository.Sql; +using Infrastructure.Sql; namespace Infrastructure.Repository.Tests.Database; diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile index a995f50..435afb5 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile +++ b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile @@ -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 . . diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj index 55a49a2..ad54e15 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj +++ b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj @@ -22,5 +22,6 @@ + diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index a993162..92ce775 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -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; diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj index 3b418d7..df21703 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj +++ b/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj @@ -19,5 +19,6 @@ + diff --git a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs index 0bb76cc..943042d 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs +++ b/web/backend/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs @@ -1,6 +1,6 @@ using System.Data; using System.Data.Common; -using Infrastructure.Repository.Sql; +using Infrastructure.Sql; namespace Infrastructure.Repository.UserAccount; diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs similarity index 98% rename from web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs rename to web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs index f802e50..503ab8a 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs @@ -2,7 +2,7 @@ using System.Data.Common; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; -namespace Infrastructure.Repository.Sql; +namespace Infrastructure.Sql; /// /// Default implementation that creates SQL Server connections, diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs similarity index 89% rename from web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs rename to web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs index 1799305..fc2d112 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs @@ -1,6 +1,6 @@ using System.Data.Common; -namespace Infrastructure.Repository.Sql; +namespace Infrastructure.Sql; /// /// Factory for creating database connections used by repositories. diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj new file mode 100644 index 0000000..78ce87b --- /dev/null +++ b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + Infrastructure.Sql + + + + + + + + diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs similarity index 94% rename from web/backend/Infrastructure/Infrastructure.Repository/Repository.cs rename to web/backend/Infrastructure/Infrastructure.Sql/Repository.cs index e4eba31..872dcd1 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Repository.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs @@ -1,7 +1,6 @@ using System.Data.Common; -using Infrastructure.Repository.Sql; -namespace Infrastructure.Repository; +namespace Infrastructure.Sql; /// /// Base class for ADO.NET-based repositories, providing shared connection creation and diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs similarity index 98% rename from web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs rename to web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs index c8b304d..a38e401 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs @@ -1,6 +1,6 @@ using Microsoft.Data.SqlClient; -namespace Infrastructure.Repository.Sql; +namespace Infrastructure.Sql; /// /// Helper for building SQL Server connection strings from environment variables. diff --git a/web/backend/Service/Service.Breweries.Tests/BreweryService.test.cs b/web/backend/Service/Service.Breweries.Tests/BreweryService.test.cs deleted file mode 100644 index 4b371db..0000000 --- a/web/backend/Service/Service.Breweries.Tests/BreweryService.test.cs +++ /dev/null @@ -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 _repoMock; - private readonly BreweryService _service; - - public BreweryServiceTests() - { - _repoMock = new Mock(); - _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()); - - 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())) - .Callback(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()), 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())) - .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())) - .Callback(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())) - .Callback(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())) - .Callback(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); - } -} diff --git a/web/backend/Service/Service.Breweries/BreweryService.cs b/web/backend/Service/Service.Breweries/BreweryService.cs deleted file mode 100644 index dd1b750..0000000 --- a/web/backend/Service/Service.Breweries/BreweryService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Domain.Entities; -using Infrastructure.Repository.Breweries; - -namespace Service.Breweries; - -/// -/// Handles retrieval, creation, update, and deletion of brewery posts. -/// -/// Repository used to persist and query brewery post data. -public class BreweryService(IBreweryRepository repository) : IBreweryService -{ - /// - /// Retrieves a brewery post by its unique identifier. - /// - /// The unique identifier of the brewery post. - /// The matching , or null if no such post exists. - public Task GetByIdAsync(Guid id) => - repository.GetByIdAsync(id); - - /// - /// Retrieves all brewery posts, optionally paginated. - /// - /// The maximum number of results to return, or null for no limit. - /// The number of results to skip, or null to start from the beginning. - /// A collection of entities. - public Task> GetAllAsync(int? limit = null, int? offset = null) => - repository.GetAllAsync(limit, offset); - - /// - /// Creates a new brewery post, generating new identifiers for the post and its location. - /// - /// The details of the brewery post to create. - /// A successful wrapping the newly created brewery post. - public async Task 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); - } - - /// - /// Updates an existing brewery post. If has no Location, - /// the brewery's location is cleared. - /// - /// The updated details of the brewery post. - /// A successful wrapping the updated brewery post. - public async Task 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); - } - - /// - /// Deletes a brewery post by its unique identifier. - /// - /// The unique identifier of the brewery post to delete. - /// A task that completes once the deletion has finished. - public Task DeleteAsync(Guid id) => - repository.DeleteAsync(id); -} diff --git a/web/backend/Service/Service.Breweries/IBreweryService.cs b/web/backend/Service/Service.Breweries/IBreweryService.cs deleted file mode 100644 index ef97a9e..0000000 --- a/web/backend/Service/Service.Breweries/IBreweryService.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Domain.Entities; - -namespace Service.Breweries; - -/// -/// Represents the data required to create a new brewery post. -/// -/// The unique identifier of the user creating the post. -/// The name of the brewery. -/// A description of the brewery. -/// The location details for the new brewery post. -public record BreweryCreateRequest( - Guid PostedById, - string BreweryName, - string Description, - BreweryLocationCreateRequest Location -); - -/// -/// Represents the location data required to create a new brewery post. -/// -/// The unique identifier of the city the brewery is located in. -/// The primary address line. -/// An optional secondary address line. -/// The postal code of the brewery's address. -/// Optional geographic coordinates for the brewery's location. -public record BreweryLocationCreateRequest( - Guid CityId, - string AddressLine1, - string? AddressLine2, - string PostalCode, - byte[]? Coordinates -); - -/// -/// Represents the data required to update an existing brewery post. -/// -/// The unique identifier of the brewery post to update. -/// The unique identifier of the user who owns/posted the brewery. -/// The updated name of the brewery. -/// The updated description of the brewery. -/// The updated location details, or null to clear the location. -public record BreweryUpdateRequest( - Guid BreweryPostId, - Guid PostedById, - string BreweryName, - string Description, - BreweryLocationUpdateRequest? Location -); - -/// -/// Represents the location data required to update an existing brewery post. -/// -/// The unique identifier of the existing brewery location record. -/// The unique identifier of the city the brewery is located in. -/// The primary address line. -/// An optional secondary address line. -/// The postal code of the brewery's address. -/// Optional geographic coordinates for the brewery's location. -public record BreweryLocationUpdateRequest( - Guid BreweryPostLocationId, - Guid CityId, - string AddressLine1, - string? AddressLine2, - string PostalCode, - byte[]? Coordinates -); - -/// -/// Represents the result of a create or update operation against a brewery post. -/// -public record BreweryServiceReturn -{ - /// - /// Gets a value indicating whether the operation succeeded. - /// - public bool Success { get; init; } - - /// - /// Gets the resulting brewery post when the operation succeeded; otherwise a default/unset value. - /// - public BreweryPost Brewery { get; init; } - - /// - /// Gets a message describing the failure reason, empty when is true. - /// - public string Message { get; init; } = string.Empty; - - /// - /// Initializes a new successful result wrapping the given brewery post. - /// - /// The brewery post resulting from the operation. - public BreweryServiceReturn(BreweryPost brewery) - { - Success = true; - Brewery = brewery; - } - - /// - /// Initializes a new failed result with the given error message. - /// - /// A message describing why the operation failed. - public BreweryServiceReturn(string message) - { - Success = false; - Brewery = default!; - Message = message; - } -} - -/// -/// Defines operations for retrieving, creating, updating, and deleting brewery posts. -/// -public interface IBreweryService -{ - /// - /// Retrieves a brewery post by its unique identifier. - /// - /// The unique identifier of the brewery post. - /// The matching , or null if no such post exists. - Task GetByIdAsync(Guid id); - - /// - /// Retrieves all brewery posts, optionally paginated. - /// - /// The maximum number of results to return, or null for no limit. - /// The number of results to skip, or null to start from the beginning. - /// A collection of entities. - Task> GetAllAsync(int? limit = null, int? offset = null); - - /// - /// Creates a new brewery post. - /// - /// The details of the brewery post to create. - /// A describing the outcome of the creation. - Task CreateAsync(BreweryCreateRequest request); - - /// - /// Updates an existing brewery post. - /// - /// The updated details of the brewery post. - /// A describing the outcome of the update. - Task UpdateAsync(BreweryUpdateRequest request); - - /// - /// Deletes a brewery post by its unique identifier. - /// - /// The unique identifier of the brewery post to delete. - /// A task that completes once the deletion has finished. - Task DeleteAsync(Guid id); -} diff --git a/web/backend/Service/Service.Breweries/Service.Breweries.csproj b/web/backend/Service/Service.Breweries/Service.Breweries.csproj deleted file mode 100644 index 578b547..0000000 --- a/web/backend/Service/Service.Breweries/Service.Breweries.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - enable - enable - - - - - - -