diff --git a/src/Core/API/API.Core/API.Core.csproj b/src/Core/API/API.Core/API.Core.csproj index 61c6ba5..aab193e 100644 --- a/src/Core/API/API.Core/API.Core.csproj +++ b/src/Core/API/API.Core/API.Core.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Core/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs b/src/Core/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs new file mode 100644 index 0000000..b181a74 --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Breweries/BreweryCreateRequestValidator.cs @@ -0,0 +1,50 @@ +using FluentValidation; + +namespace API.Core.Contracts.Breweries; + +public class BreweryCreateDtoValidator : AbstractValidator +{ + public BreweryCreateDtoValidator() + { + RuleFor(x => x.PostedById) + .NotEmpty() + .WithMessage("PostedById is required."); + + RuleFor(x => x.BreweryName) + .NotEmpty() + .WithMessage("Brewery name is required.") + .MaximumLength(256) + .WithMessage("Brewery name cannot exceed 256 characters."); + + RuleFor(x => x.Description) + .NotEmpty() + .WithMessage("Description is required.") + .MaximumLength(512) + .WithMessage("Description cannot exceed 512 characters."); + + RuleFor(x => x.Location) + .NotNull() + .WithMessage("Location is required."); + + RuleFor(x => x.Location.CityId) + .NotEmpty() + .When(x => x.Location is not null) + .WithMessage("CityId is required."); + + RuleFor(x => x.Location.AddressLine1) + .NotEmpty() + .When(x => x.Location is not null) + .WithMessage("Address line 1 is required.") + .MaximumLength(256) + .When(x => x.Location is not null) + .WithMessage("Address line 1 cannot exceed 256 characters."); + + RuleFor(x => x.Location.PostalCode) + .NotEmpty() + .When(x => x.Location is not null) + .WithMessage("Postal code is required.") + .MaximumLength(20) + .When(x => x.Location is not null) + .WithMessage("Postal code cannot exceed 20 characters."); + } +} diff --git a/src/Core/API/API.Core/Contracts/Breweries/BreweryDto.cs b/src/Core/API/API.Core/Contracts/Breweries/BreweryDto.cs new file mode 100644 index 0000000..c6ff6bf --- /dev/null +++ b/src/Core/API/API.Core/Contracts/Breweries/BreweryDto.cs @@ -0,0 +1,41 @@ +namespace API.Core.Contracts.Breweries; + +public class BreweryLocationCreateDto +{ + public Guid CityId { get; set; } + public string AddressLine1 { get; set; } = string.Empty; + public string? AddressLine2 { get; set; } + public string PostalCode { get; set; } = string.Empty; + public byte[]? Coordinates { get; set; } +} + +public class BreweryLocationDto +{ + public Guid BreweryPostLocationId { get; set; } + public Guid BreweryPostId { get; set; } + public Guid CityId { get; set; } + public string AddressLine1 { get; set; } = string.Empty; + public string? AddressLine2 { get; set; } + public string PostalCode { get; set; } = string.Empty; + public byte[]? Coordinates { get; set; } +} + +public class BreweryCreateDto +{ + public Guid PostedById { get; set; } + public string BreweryName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public BreweryLocationCreateDto Location { get; set; } = null!; +} + +public class BreweryDto +{ + public Guid BreweryPostId { get; set; } + public Guid PostedById { get; set; } + public string BreweryName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public byte[]? Timer { get; set; } + public BreweryLocationDto? Location { get; set; } +} diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index 7b2dcc4..a6cc9af 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -86,6 +86,13 @@ namespace API.Core.Controllers ); } + [HttpPost("confirm/resend")] + public async Task ResendConfirmation([FromQuery] Guid userId) + { + await confirmationService.ResendConfirmationEmailAsync(userId); + return Ok(new ResponseBody { Message = "confirmation email has been resent" }); + } + [AllowAnonymous] [HttpPost("refresh")] public async Task Refresh( diff --git a/src/Core/API/API.Core/Controllers/BreweryController.cs b/src/Core/API/API.Core/Controllers/BreweryController.cs new file mode 100644 index 0000000..d0fb8d4 --- /dev/null +++ b/src/Core/API/API.Core/Controllers/BreweryController.cs @@ -0,0 +1,129 @@ +using API.Core.Contracts.Breweries; +using API.Core.Contracts.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Service.Breweries; + +namespace API.Core.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] +public class BreweryController(IBreweryService breweryService) : ControllerBase +{ + [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), + }); + } + + [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), + }); + } + + [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), + }); + } + + [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 = MapToDto(result.Brewery), + }); + } + + [HttpDelete("{id:guid}")] + public async Task> Delete(Guid id) + { + await breweryService.DeleteAsync(id); + return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); + } + + private static BreweryDto MapToDto(Domain.Entities.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/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs index e186dba..f6c7aee 100644 --- a/src/Core/API/API.Core/Program.cs +++ b/src/Core/API/API.Core/Program.cs @@ -1,20 +1,15 @@ using API.Core; using API.Core.Authentication; -using API.Core.Contracts.Common; -using Domain.Exceptions; using FluentValidation; using FluentValidation.AspNetCore; using Infrastructure.Email; -using Infrastructure.Email.Templates; using Infrastructure.Email.Templates.Rendering; using Infrastructure.Jwt; using Infrastructure.PasswordHashing; using Infrastructure.Repository.Auth; using Infrastructure.Repository.Sql; using Infrastructure.Repository.UserAccount; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; +using Infrastructure.Repository.Breweries; using Service.Auth; using Service.Emails; using Service.UserManagement.User; @@ -55,6 +50,7 @@ builder.Services.AddSingleton< builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx index b1ff5d5..6add115 100644 --- a/src/Core/Core.slnx +++ b/src/Core/Core.slnx @@ -26,6 +26,7 @@ - + + diff --git a/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql b/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql index 38cdf51..3683dc7 100644 --- a/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql +++ b/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql @@ -12,7 +12,7 @@ AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; - + IF @BreweryName IS NULL THROW 50001, 'Brewery name cannot be null.', 1; @@ -30,6 +30,7 @@ BEGIN THROW 50404, 'City not found.', 1; DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID(); + DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID(); BEGIN TRANSACTION; @@ -37,9 +38,13 @@ BEGIN (BreweryPostID, BreweryName, Description, PostedByID) VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID); - INSERT INTO dbo.BreweryPostLocation - (@NewBreweryID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) - VALUES (@NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates); + INSERT INTO dbo.BreweryPostLocation + (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) + VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates); COMMIT TRANSACTION; + + SELECT @NewBreweryID AS BreweryPostID, + @NewBrewerLocationID AS BreweryPostLocationID; + END diff --git a/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql b/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql new file mode 100644 index 0000000..25425ef --- /dev/null +++ b/src/Core/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql @@ -0,0 +1,9 @@ +CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER +AS +BEGIN + SELECT * + FROM BreweryPost bp + INNER JOIN BreweryPostLocation bpl + ON bp.BreweryPostID = bpl.BreweryPostID + WHERE bp.BreweryPostID = @BreweryPostID; +END \ No newline at end of file diff --git a/src/Core/Domain/Domain.Entities/Entities/BreweryPost.cs b/src/Core/Domain/Domain.Entities/Entities/BreweryPost.cs new file mode 100644 index 0000000..07a7b78 --- /dev/null +++ b/src/Core/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -0,0 +1,13 @@ +namespace Domain.Entities; + +public class BreweryPost +{ + public Guid BreweryPostId { get; set; } + public Guid PostedById { get; set; } + public string BreweryName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public byte[]? Timer { get; set; } + public BreweryPostLocation? Location { get; set; } +} diff --git a/src/Core/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/src/Core/Domain/Domain.Entities/Entities/BreweryPostLocation.cs new file mode 100644 index 0000000..460e30a --- /dev/null +++ b/src/Core/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -0,0 +1,13 @@ +namespace Domain.Entities; + +public class BreweryPostLocation +{ + public Guid BreweryPostLocationId { get; set; } + public Guid BreweryPostId { get; set; } + public string AddressLine1 { get; set; } = string.Empty; + public string? AddressLine2 { get; set; } + public string PostalCode { get; set; } = string.Empty; + public Guid CityId { get; set; } + public byte[]? Coordinates { get; set; } + public byte[]? Timer { get; set; } +} diff --git a/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj b/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj index b02eae8..717486f 100644 --- a/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj +++ b/src/Core/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj @@ -7,6 +7,6 @@ - + diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs index e30ceee..e067b4f 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs @@ -17,10 +17,34 @@ public class AuthRepositoryTest var conn = new MockDbConnection(); conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser") + .ReturnsScalar(expectedUserId); + + // Mock the subsequent read for the newly created user by id + conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") .ReturnsTable( MockTable - .WithColumns(("UserAccountId", typeof(Guid))) - .AddRow(expectedUserId) + .WithColumns( + ("UserAccountId", typeof(Guid)), + ("Username", typeof(string)), + ("FirstName", typeof(string)), + ("LastName", typeof(string)), + ("Email", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("DateOfBirth", typeof(DateTime)), + ("Timer", typeof(byte[])) + ) + .AddRow( + expectedUserId, + "testuser", + "Test", + "User", + "test@example.com", + DateTime.UtcNow, + null, + new DateTime(1990, 1, 1), + null + ) ); var repo = CreateRepo(conn); diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs new file mode 100644 index 0000000..6352124 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Breweries/BreweryRepository.test.cs @@ -0,0 +1,108 @@ +using Apps72.Dev.Data.DbMocker; +using FluentAssertions; +using Infrastructure.Repository.Breweries; +using Infrastructure.Repository.Tests.Database; +using Domain.Entities; + +namespace Infrastructure.Repository.Tests.Breweries; + +public class BreweryRepositoryTest +{ + private static BreweryRepository CreateRepo(MockDbConnection conn) => + new(new TestConnectionFactory(conn)); + + [Fact] + public async Task GetByIdAsync_ReturnsBrewery_WhenExists() + { + var breweryId = Guid.NewGuid(); + var conn = new MockDbConnection(); + + // Repository calls the stored procedure + const string getByIdSql = "USP_GetBreweryById"; + + var locationId = Guid.NewGuid(); + + conn.Mocks.When(cmd => cmd.CommandText == getByIdSql) + .ReturnsTable( + MockTable + .WithColumns( + ("BreweryPostId", typeof(Guid)), + ("PostedById", typeof(Guid)), + ("BreweryName", typeof(string)), + ("Description", typeof(string)), + ("CreatedAt", typeof(DateTime)), + ("UpdatedAt", typeof(DateTime?)), + ("Timer", typeof(byte[])), + ("BreweryPostLocationId", typeof(Guid)), + ("CityId", typeof(Guid)), + ("AddressLine1", typeof(string)), + ("AddressLine2", typeof(string)), + ("PostalCode", typeof(string)), + ("Coordinates", typeof(byte[])) + ) + .AddRow( + breweryId, + Guid.NewGuid(), + "Test Brewery", + "A test brewery description", + DateTime.UtcNow, + null, + null, + locationId, + Guid.NewGuid(), + "123 Main St", + null, + "12345", + null + ) + ); + + var repo = CreateRepo(conn); + var result = await repo.GetByIdAsync(breweryId); + result.Should().NotBeNull(); + result!.BreweryPostId.Should().Be(breweryId); + result.Location.Should().NotBeNull(); + result.Location!.BreweryPostLocationId.Should().Be(locationId); + } + + [Fact] + public async Task GetByIdAsync_ReturnsNull_WhenNotExists() + { + var conn = new MockDbConnection(); + conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById") + .ReturnsTable(MockTable.Empty()); + var repo = CreateRepo(conn); + var result = await repo.GetByIdAsync(Guid.NewGuid()); + result.Should().BeNull(); + + } + + [Fact] + public async Task CreateAsync_ExecutesSuccessfully() + { + var conn = new MockDbConnection(); + conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery") + .ReturnsScalar(1); + var repo = CreateRepo(conn); + var brewery = new BreweryPost + { + BreweryPostId = Guid.NewGuid(), + PostedById = Guid.NewGuid(), + BreweryName = "Test Brewery", + Description = "A test brewery description", + CreatedAt = DateTime.UtcNow, + Location = new BreweryPostLocation + { + BreweryPostLocationId = Guid.NewGuid(), + CityId = Guid.NewGuid(), + AddressLine1 = "123 Main St", + PostalCode = "12345", + Coordinates = [0x00, 0x01] + } + }; + + // Should not throw + var act = async () => await repo.CreateAsync(brewery); + await act.Should().NotThrowAsync(); + } +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index fbff2b3..bb701b3 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -33,18 +33,39 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) AddParameter(command, "@Hash", passwordHash); var result = await command.ExecuteScalarAsync(); - var userAccountId = result != null ? (Guid)result : Guid.Empty; - return new Domain.Entities.UserAccount + Guid userAccountId = Guid.Empty; + if (result != null && result != DBNull.Value) { - UserAccountId = userAccountId, - Username = username, - FirstName = firstName, - LastName = lastName, - Email = email, - DateOfBirth = dateOfBirth, - CreatedAt = DateTime.UtcNow, - }; + if (result is Guid g) + { + userAccountId = g; + } + else if (result is string s && Guid.TryParse(s, out var parsed)) + { + userAccountId = parsed; + } + else if (result is byte[] bytes && bytes.Length == 16) + { + userAccountId = new Guid(bytes); + } + else + { + // Fallback: try to convert and parse string representation + try + { + var str = result.ToString(); + if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p)) + userAccountId = p; + } + catch + { + userAccountId = Guid.Empty; + } + } + } + + return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } public async Task GetUserByEmailAsync( diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs new file mode 100644 index 0000000..08332c4 --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Repository/Breweries/BreweryRepository.cs @@ -0,0 +1,147 @@ +using System.Data.Common; +using Domain.Entities; +using Infrastructure.Repository.Sql; + +namespace Infrastructure.Repository.Breweries; + +public class BreweryRepository(ISqlConnectionFactory connectionFactory) + : Repository(connectionFactory), IBreweryRepository +{ + private readonly ISqlConnectionFactory _connectionFactory = connectionFactory; + + public async Task GetByIdAsync(Guid id) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + + command.CommandText = "USP_GetBreweryById"; + AddParameter(command, "@BreweryPostID", id); + + await using var reader = await command.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return MapToEntity(reader); + } + return null; + } + + public Task> GetAllAsync(int? limit, int? offset) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(BreweryPost brewery) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(Guid id) + { + throw new NotImplementedException(); + } + + public async Task CreateAsync(BreweryPost brewery) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + + command.CommandText = "USP_CreateBrewery"; + command.CommandType = System.Data.CommandType.StoredProcedure; + + if (brewery.Location is null) + { + throw new ArgumentException("Location must be provided when creating a brewery."); + } + + AddParameter(command, "@BreweryName", brewery.BreweryName); + AddParameter(command, "@Description", brewery.Description); + AddParameter(command, "@PostedByID", brewery.PostedById); + AddParameter(command, "@CityID", brewery.Location?.CityId); + AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1); + AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2); + AddParameter(command, "@PostalCode", brewery.Location?.PostalCode); + AddParameter(command, "@Coordinates", brewery.Location?.Coordinates); + await command.ExecuteNonQueryAsync(); + + } + + protected override BreweryPost MapToEntity(DbDataReader reader) + { + var brewery = new BreweryPost(); + + var ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); + var ordPostedById = reader.GetOrdinal("PostedById"); + var ordBreweryName = reader.GetOrdinal("BreweryName"); + var ordDescription = reader.GetOrdinal("Description"); + var ordCreatedAt = reader.GetOrdinal("CreatedAt"); + var ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); + var ordTimer = reader.GetOrdinal("Timer"); + + brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId); + brewery.PostedById = reader.GetGuid(ordPostedById); + brewery.BreweryName = reader.GetString(ordBreweryName); + brewery.Description = reader.GetString(ordDescription); + brewery.CreatedAt = reader.GetDateTime(ordCreatedAt); + + brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt); + + // Read timer (varbinary/rowversion) robustly + if (reader.IsDBNull(ordTimer)) + { + brewery.Timer = null; + } + else + { + try + { + brewery.Timer = reader.GetFieldValue(ordTimer); + } + catch + { + var length = reader.GetBytes(ordTimer, 0, null, 0, 0); + var buffer = new byte[length]; + reader.GetBytes(ordTimer, 0, buffer, 0, (int)length); + brewery.Timer = buffer; + } + } + + // Map BreweryPostLocation if columns are present + try + { + var ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); + if (!reader.IsDBNull(ordLocationId)) + { + var location = new BreweryPostLocation + { + BreweryPostLocationId = reader.GetGuid(ordLocationId), + BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")), + CityId = reader.GetGuid(reader.GetOrdinal("CityId")), + AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")), + AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")), + PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")), + Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue(reader.GetOrdinal("Coordinates")) + }; + brewery.Location = location; + } + } + catch (IndexOutOfRangeException) + { + // Location columns not present, skip mapping location + } + + return brewery; + } + + private static void AddParameter( + DbCommand command, + string name, + object? value + ) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } +} diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs new file mode 100644 index 0000000..2d394cb --- /dev/null +++ b/src/Core/Infrastructure/Infrastructure.Repository/Breweries/IBreweryRepository.cs @@ -0,0 +1,12 @@ +using Domain.Entities; + +namespace Infrastructure.Repository.Breweries; + +public interface IBreweryRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(int? limit, int? offset); + Task UpdateAsync(BreweryPost brewery); + Task DeleteAsync(Guid id); + Task CreateAsync(BreweryPost brewery); +} \ No newline at end of file diff --git a/src/Core/Service/Service.Breweries.Tests/BreweryService.test.cs b/src/Core/Service/Service.Breweries.Tests/BreweryService.test.cs new file mode 100644 index 0000000..a6b49cb --- /dev/null +++ b/src/Core/Service/Service.Breweries.Tests/BreweryService.test.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Xunit; +using Service.Breweries; +using API.Core.Contracts.Breweries; +using Domain.Entities; + +namespace Service.Breweries.Tests; + +public class BreweryServiceTests +{ + private class FakeRepo : IBreweryRepository + { + public BreweryPost? Created; + + public Task GetByIdAsync(Guid id) => Task.FromResult(null); + public Task> GetAllAsync(int? limit, int? offset) => Task.FromResult>(Array.Empty()); + public Task UpdateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; } + public Task DeleteAsync(Guid id) => Task.CompletedTask; + public Task CreateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; } + } + + [Fact] + public async Task CreateAsync_ReturnsFailure_WhenLocationMissing() + { + var repo = new FakeRepo(); + var svc = new BreweryService(repo); + + var dto = new BreweryCreateDto + { + PostedById = Guid.NewGuid(), + BreweryName = "X", + Description = "Y", + Location = null! + }; + + var result = await svc.CreateAsync(dto); + result.Success.Should().BeFalse(); + result.Message.Should().Contain("Location"); + } + + [Fact] + public async Task CreateAsync_ReturnsSuccess_AndPersistsEntity() + { + var repo = new FakeRepo(); + var svc = new BreweryService(repo); + + var loc = new BreweryLocationCreateDto + { + CityId = Guid.NewGuid(), + AddressLine1 = "123 Main", + PostalCode = "12345" + }; + + var dto = new BreweryCreateDto + { + PostedById = Guid.NewGuid(), + BreweryName = "MyBrew", + Description = "Desc", + Location = loc + }; + + var result = await svc.CreateAsync(dto); + + result.Success.Should().BeTrue(); + repo.Created.Should().NotBeNull(); + repo.Created!.BreweryName.Should().Be("MyBrew"); + result.Brewery.BreweryName.Should().Be("MyBrew"); + } +} diff --git a/src/Core/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj b/src/Core/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj new file mode 100644 index 0000000..bd1c5c8 --- /dev/null +++ b/src/Core/Service/Service.Breweries.Tests/Service.Breweries.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + Service.Breweries.Tests + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/Service/Service.Breweries/BreweryService.cs b/src/Core/Service/Service.Breweries/BreweryService.cs new file mode 100644 index 0000000..5966f6c --- /dev/null +++ b/src/Core/Service/Service.Breweries/BreweryService.cs @@ -0,0 +1,65 @@ +using Domain.Entities; +using Infrastructure.Repository.Breweries; + +namespace Service.Breweries; + +public class BreweryService(IBreweryRepository repository) : IBreweryService +{ + public Task GetByIdAsync(Guid id) => + repository.GetByIdAsync(id); + + public Task> GetAllAsync(int? limit = null, int? offset = null) => + repository.GetAllAsync(limit, offset); + + 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); + } + + 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); + } + + public Task DeleteAsync(Guid id) => + repository.DeleteAsync(id); +} diff --git a/src/Core/Service/Service.Breweries/IBreweryService.cs b/src/Core/Service/Service.Breweries/IBreweryService.cs new file mode 100644 index 0000000..5331634 --- /dev/null +++ b/src/Core/Service/Service.Breweries/IBreweryService.cs @@ -0,0 +1,64 @@ +using Domain.Entities; + +namespace Service.Breweries; + +public record BreweryCreateRequest( + Guid PostedById, + string BreweryName, + string Description, + BreweryLocationCreateRequest Location +); + +public record BreweryLocationCreateRequest( + Guid CityId, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); + +public record BreweryUpdateRequest( + Guid BreweryPostId, + Guid PostedById, + string BreweryName, + string Description, + BreweryLocationUpdateRequest? Location +); + +public record BreweryLocationUpdateRequest( + Guid BreweryPostLocationId, + Guid CityId, + string AddressLine1, + string? AddressLine2, + string PostalCode, + byte[]? Coordinates +); + +public record BreweryServiceReturn +{ + public bool Success { get; init; } + public BreweryPost Brewery { get; init; } + public string Message { get; init; } = string.Empty; + + public BreweryServiceReturn(BreweryPost brewery) + { + Success = true; + Brewery = brewery; + } + + public BreweryServiceReturn(string message) + { + Success = false; + Brewery = default!; + Message = message; + } +} + +public interface IBreweryService +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(int? limit = null, int? offset = null); + Task CreateAsync(BreweryCreateRequest request); + Task UpdateAsync(BreweryUpdateRequest request); + Task DeleteAsync(Guid id); +} diff --git a/src/Core/Service/Service.Breweries/Service.Breweries.csproj b/src/Core/Service/Service.Breweries/Service.Breweries.csproj new file mode 100644 index 0000000..578b547 --- /dev/null +++ b/src/Core/Service/Service.Breweries/Service.Breweries.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + +