Repository: glucopilot/GlucoPilot
Pull request #104: feat: New ingredient endpoint
Author: login: Loko3610
Created: 2025-04-11T22:06:07Z | Merged: 2025-04-11T22:14:38Z



Diff:
diff --git a/src/GlucoPilot.Api.Tests/Endpoints/Ingredients/NewIngredientTests.cs b/src/GlucoPilot.Api.Tests/Endpoints/Ingredients/NewIngredientTests.cs
new file mode 100644
index 00000000..aee6637d
--- /dev/null
+++ b/src/GlucoPilot.Api.Tests/Endpoints/Ingredients/NewIngredientTests.cs
@@ -0,0 +1,74 @@
+﻿using FluentValidation;
+using GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+using GlucoPilot.Data.Entities;
+using GlucoPilot.Data.Enums;
+using GlucoPilot.Data.Repository;
+using GlucoPilot.Identity.Authentication;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Moq;
+using NUnit.Framework;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GlucoPilot.Tests.Endpoints.Ingredients.NewIngredient;
+
+[TestFixture]
+public class EndpointTests
+{
+    private Mock<IValidator<NewIngredientRequest>> _validatorMock;
+    private Mock<ICurrentUser> _currentUserMock;
+    private Mock<IRepository<Ingredient>> _ingredientRepositoryMock;
+
+    [SetUp]
+    public void SetUp()
+    {
+        _validatorMock = new Mock<IValidator<NewIngredientRequest>>();
+        _currentUserMock = new Mock<ICurrentUser>();
+        _ingredientRepositoryMock = new Mock<IRepository<Ingredient>>();
+    }
+
+    [Test]
+    public async Task HandleAsync_Returns_ValidationProblem_When_Request_Is_Invalid()
+    {
+        var request = new NewIngredientRequest { Name = "Test", Carbs = 10, Protein = 5, Fat = 2, Calories = 100, Uom = UnitOfMeasurement.Grams };
+        var validationResult = new FluentValidation.Results.ValidationResult(new[] { new FluentValidation.Results.ValidationFailure("Name", "Name is required") });
+
+        _validatorMock
+            .Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
+            .ReturnsAsync(validationResult);
+
+        var result = await Endpoint.HandleAsync(request, _validatorMock.Object, _currentUserMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None);
+
+        Assert.That(result.Result, Is.TypeOf<ValidationProblem>());
+    }
+
+    [Test]
+    public async Task HandleAsync_Returns_Ok_With_Response_When_Request_Is_Valid()
+    {
+        var request = new NewIngredientRequest { Name = "Test", Carbs = 10, Protein = 5, Fat = 2, Calories = 100, Uom = UnitOfMeasurement.Grams };
+        var validationResult = new FluentValidation.Results.ValidationResult();
+        var userId = Guid.NewGuid();
+
+        _validatorMock
+            .Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
+            .ReturnsAsync(validationResult);
+
+        _currentUserMock
+            .Setup(c => c.GetUserId())
+            .Returns(userId);
+
+        _ingredientRepositoryMock.Setup(r => r.Add(It.IsAny<Ingredient>()));
+
+        var result = await Endpoint.HandleAsync(request, _validatorMock.Object, _currentUserMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None);
+
+        Assert.That(result.Result, Is.TypeOf<Ok<NewIngredientResponse>>());
+        var okResult = result.Result as Ok<NewIngredientResponse>;
+        Assert.That(okResult!.Value.Name, Is.EqualTo(request.Name));
+        Assert.That(okResult.Value.Carbs, Is.EqualTo(request.Carbs));
+        Assert.That(okResult.Value.Protein, Is.EqualTo(request.Protein));
+        Assert.That(okResult.Value.Fat, Is.EqualTo(request.Fat));
+        Assert.That(okResult.Value.Calories, Is.EqualTo(request.Calories));
+        Assert.That(okResult.Value.Uom, Is.EqualTo(request.Uom));
+    }
+}
\ No newline at end of file
diff --git a/src/GlucoPilot.Api.Tests/Endpoints/Meals/UpdateMealTests.cs b/src/GlucoPilot.Api.Tests/Endpoints/Meals/UpdateMealTests.cs
index 6923cd25..bdcb2b6a 100644
--- a/src/GlucoPilot.Api.Tests/Endpoints/Meals/UpdateMealTests.cs
+++ b/src/GlucoPilot.Api.Tests/Endpoints/Meals/UpdateMealTests.cs
@@ -12,10 +12,13 @@
 using GlucoPilot.Api.Models;
 using GlucoPilot.AspNetCore.Exceptions;
 using System.Linq.Expressions;
+using FluentValidation;
+using FluentValidation.Results;
 
 [TestFixture]
 public class UpdateMealTests
 {
+    private Mock<IValidator<UpdateMealRequest>> _validatorMock;
     private Mock<IRepository<Meal>> _mealRepositoryMock;
     private Mock<IRepository<Ingredient>> _ingredientRepositoryMock;
     private Mock<ICurrentUser> _currentUserMock;
@@ -23,11 +26,42 @@ public class UpdateMealTests
     [SetUp]
     public void SetUp()
     {
+        _validatorMock = new Mock<IValidator<UpdateMealRequest>>();
         _mealRepositoryMock = new Mock<IRepository<Meal>>();
         _ingredientRepositoryMock = new Mock<IRepository<Ingredient>>();
         _currentUserMock = new Mock<ICurrentUser>();
     }
 
+    [Test]
+    public async Task HandleAsync_ReturnsValidationProblem_WhenRequestIsInvalid()
+    {
+        var request = new UpdateMealRequest
+        {
+            Id = Guid.NewGuid(),
+            Name = "",
+            MealIngredients = new List<NewMealIngredientRequest>()
+        };
+
+        var validationResult = new ValidationResult(new[]
+        {
+            new ValidationFailure("Name", "Name is required.")
+        });
+
+        _validatorMock
+            .Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
+            .ReturnsAsync(validationResult);
+
+        var result = await Endpoint.HandleAsync(
+            request,
+            _validatorMock.Object,
+            _currentUserMock.Object,
+            _mealRepositoryMock.Object,
+            _ingredientRepositoryMock.Object,
+            CancellationToken.None);
+
+        Assert.That(result.Result, Is.TypeOf<ValidationProblem>());
+    }
+
     [Test]
     public async Task HandleAsync_Should_Return_NoContent_When_Update_Is_Successful()
     {
@@ -70,7 +104,7 @@ public async Task HandleAsync_Should_Return_NoContent_When_Update_Is_Successful(
             .Setup(i => i.CountAsync(It.IsAny<Expression<Func<Ingredient, bool>>>(), It.IsAny<CancellationToken>()))
             .ReturnsAsync(1);
 
-        var result = await Endpoint.HandleAsync(request, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None);
+        var result = await Endpoint.HandleAsync(request, _validatorMock.Object, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None);
 
         Assert.That(result.Result, Is.InstanceOf<NoContent>());
         _mealRepositoryMock.Verify(m => m.UpdateAsync(It.IsAny<Meal>(), It.IsAny<CancellationToken>()), Times.Once);
@@ -93,7 +127,7 @@ public void HandleAsync_Should_Throw_NotFoundException_When_Meal_Is_Not_Found()
             .Setup(m => m.FindOneAsync(It.IsAny<Expression<Func<Meal, bool>>>(), It.IsAny<FindOptions>(), It.IsAny<CancellationToken>()))
             .ReturnsAsync((Meal)null);
 
-        Assert.That(async () => await Endpoint.HandleAsync(request, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None),
+        Assert.That(async () => await Endpoint.HandleAsync(request, _validatorMock.Object, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None),
             Throws.TypeOf<NotFoundException>().With.Message.EqualTo("MEAL_NOT_FOUND"));
     }
 
@@ -139,7 +173,7 @@ public void HandleAsync_Should_Throw_NotFoundException_When_Ingredient_Is_Not_Fo
             .Setup(i => i.AnyAsync(It.IsAny<Expression<Func<Ingredient, bool>>>(), It.IsAny<CancellationToken>()))
             .ReturnsAsync(true);
 
-        Assert.That(async () => await Endpoint.HandleAsync(request, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None),
+        Assert.That(async () => await Endpoint.HandleAsync(request, _validatorMock.Object, _currentUserMock.Object, _mealRepositoryMock.Object, _ingredientRepositoryMock.Object, CancellationToken.None),
             Throws.TypeOf<NotFoundException>().With.Message.EqualTo("INGREDIENT_NOT_FOUND"));
     }
 }
\ No newline at end of file
diff --git a/src/GlucoPilot.Api.Tests/Validators/NewIngredientValidatorTests.cs b/src/GlucoPilot.Api.Tests/Validators/NewIngredientValidatorTests.cs
new file mode 100644
index 00000000..19fa35a2
--- /dev/null
+++ b/src/GlucoPilot.Api.Tests/Validators/NewIngredientValidatorTests.cs
@@ -0,0 +1,122 @@
+﻿using FluentValidation.TestHelper;
+using GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+using GlucoPilot.Data.Enums;
+using NUnit.Framework;
+using static GlucoPilot.Api.Endpoints.Ingredients.NewIngredient.NewIngredientRequest;
+
+namespace GlucoPilot.Api.Tests.Validators
+{
+    [TestFixture]
+    public class NewIngredientRequestValidatorTests
+    {
+        private NewIngredientRequestValidator _validator;
+
+        [SetUp]
+        public void SetUp()
+        {
+            _validator = new NewIngredientRequestValidator();
+        }
+
+        [Test]
+        public void Should_Have_Error_When_Name_Is_Empty()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = string.Empty,
+                Carbs = 10,
+                Protein = 5,
+                Fat = 2,
+                Calories = 100,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldHaveValidationErrorFor(x => x.Name);
+        }
+
+        [Test]
+        public void Should_Have_Error_When_Carbs_Is_Negative()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = "Test Ingredient",
+                Carbs = -1,
+                Protein = 5,
+                Fat = 2,
+                Calories = 100,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldHaveValidationErrorFor(x => x.Carbs);
+        }
+
+        [Test]
+        public void Should_Have_Error_When_Protein_Is_Negative()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = "Test Ingredient",
+                Carbs = 10,
+                Protein = -1,
+                Fat = 2,
+                Calories = 100,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldHaveValidationErrorFor(x => x.Protein);
+        }
+
+        [Test]
+        public void Should_Have_Error_When_Fat_Is_Negative()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = "Test Ingredient",
+                Carbs = 10,
+                Protein = 5,
+                Fat = -1,
+                Calories = 100,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldHaveValidationErrorFor(x => x.Fat);
+        }
+
+        [Test]
+        public void Should_Have_Error_When_Calories_Is_Negative()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = "Test Ingredient",
+                Carbs = 10,
+                Protein = 5,
+                Fat = 2,
+                Calories = -1,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldHaveValidationErrorFor(x => x.Calories);
+        }
+
+        [Test]
+        public void Should_Not_Have_Error_When_Request_Is_Valid()
+        {
+            var model = new NewIngredientRequest
+            {
+                Name = "Test Ingredient",
+                Carbs = 10,
+                Protein = 5,
+                Fat = 2,
+                Calories = 100,
+                Uom = UnitOfMeasurement.Grams
+            };
+
+            var result = _validator.TestValidate(model);
+            result.ShouldNotHaveAnyValidationErrors();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/GlucoPilot.Api/Endpoints/Ingredients/IngredientsEndpoints.cs b/src/GlucoPilot.Api/Endpoints/Ingredients/IngredientsEndpoints.cs
new file mode 100644
index 00000000..03c0faa7
--- /dev/null
+++ b/src/GlucoPilot.Api/Endpoints/Ingredients/IngredientsEndpoints.cs
@@ -0,0 +1,26 @@
+﻿using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using System.Diagnostics.CodeAnalysis;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients;
+
+[ExcludeFromCodeCoverage]
+public static class IngredientsEndpoints
+{
+    internal static IEndpointRouteBuilder MapIngredientsEndpoints(this IEndpointRouteBuilder endpoints)
+    {
+        var group = endpoints.NewVersionedApi().MapGroup("api/v{version:apiVersion}/ingredients")
+            .WithTags("Ingredients");
+
+        group.MapGet("/", List.Endpoint.HandleAsync)
+            .HasApiVersion(1.0)
+            .RequireAuthorization();
+
+        group.MapPost("/", NewIngredient.Endpoint.HandleAsync)
+            .HasApiVersion(1.0)
+            .RequireAuthorization();
+
+        return endpoints;
+    }
+}
diff --git a/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/Endpoint.cs b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/Endpoint.cs
new file mode 100644
index 00000000..f5896c97
--- /dev/null
+++ b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/Endpoint.cs
@@ -0,0 +1,59 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Entities;
+using GlucoPilot.Data.Repository;
+using GlucoPilot.Identity.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+internal static class Endpoint
+{
+    internal static async Task<Results<Ok<NewIngredientResponse>, ValidationProblem>> HandleAsync(
+        [FromBody] NewIngredientRequest request,
+        [FromServices] IValidator<NewIngredientRequest> validator,
+        [FromServices] ICurrentUser currentUser,
+        [FromServices] IRepository<Ingredient> ingredientRepository,
+        CancellationToken cancellationToken)
+    {
+        if (await validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false) is
+            { IsValid: false } validation)
+        {
+            return TypedResults.ValidationProblem(validation.ToDictionary());
+        }
+
+        var userId = currentUser.GetUserId();
+
+        var ingredient = new Ingredient
+        {
+            Name = request.Name,
+            Created = DateTimeOffset.UtcNow,
+            Carbs = request.Carbs,
+            Protein = request.Protein,
+            Fat = request.Fat,
+            Calories = request.Calories,
+            Uom = request.Uom,
+            UserId = userId,
+        };
+
+        ingredientRepository.Add(ingredient);
+
+        var response = new NewIngredientResponse
+        {
+            Id = ingredient.Id,
+            Created = ingredient.Created,
+            Name = ingredient.Name,
+            Carbs = ingredient.Carbs,
+            Protein = ingredient.Protein,
+            Fat = ingredient.Fat,
+            Calories = ingredient.Calories,
+            Uom = ingredient.Uom,
+        };
+
+        return TypedResults.Ok(response);
+    }
+}
diff --git a/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
new file mode 100644
index 00000000..d51397c5
--- /dev/null
+++ b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
@@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GREATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GREATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GREATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GREATER_THAN_ZERO");
+        }
+    }
+}
diff --git a/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientResponse.cs b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientResponse.cs
new file mode 100644
index 00000000..88d8f1c5
--- /dev/null
+++ b/src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientResponse.cs
@@ -0,0 +1,16 @@
+﻿using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public sealed record NewIngredientResponse
+{
+    public required Guid Id { get; set; }
+    public required DateTimeOffset Created { get; set; }
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+}
diff --git a/src/GlucoPilot.Api/Endpoints/Meals/MealsEndpoints.cs b/src/GlucoPilot.Api/Endpoints/Meals/MealsEndpoints.cs
index a533b65a..274ae4ae 100644
--- a/src/GlucoPilot.Api/Endpoints/Meals/MealsEndpoints.cs
+++ b/src/GlucoPilot.Api/Endpoints/Meals/MealsEndpoints.cs
@@ -25,6 +25,14 @@ internal static IEndpointRouteBuilder MapMealsEndpoints(this IEndpointRouteBuild
             .HasApiVersion(1.0)
             .RequireAuthorization();
 
+        group.MapPatch("/", UpdateMeal.Endpoint.HandleAsync)
+            .HasApiVersion(1.0)
+            .RequireAuthorization();
+
+        group.MapDelete("/{id:guid}", RemoveMeal.Endpoint.HandleAsync)
+            .HasApiVersion(1.0)
+            .RequireAuthorization();
+
         return endpoints;
     }
 }
diff --git a/src/GlucoPilot.Api/Endpoints/Meals/UpdateMeal/Endpoint.cs b/src/GlucoPilot.Api/Endpoints/Meals/UpdateMeal/Endpoint.cs
index 76f7e770..d1c8c1a5 100644
--- a/src/GlucoPilot.Api/Endpoints/Meals/UpdateMeal/Endpoint.cs
+++ b/src/GlucoPilot.Api/Endpoints/Meals/UpdateMeal/Endpoint.cs
@@ -1,4 +1,6 @@
-﻿using GlucoPilot.AspNetCore.Exceptions;
+﻿using FluentValidation;
+using GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+using GlucoPilot.AspNetCore.Exceptions;
 using GlucoPilot.Data.Entities;
 using GlucoPilot.Data.Repository;
 using GlucoPilot.Identity.Authentication;
@@ -16,11 +18,18 @@ internal static class Endpoint
 {
     internal static async Task<Results<Ok, NoContent, ValidationProblem>> HandleAsync(
         [FromBody] UpdateMealRequest request,
+        [FromServices] IValidator<UpdateMealRequest> validator,
         [FromServices] ICurrentUser currentUser,
         [FromServices] IRepository<Meal> mealRepository,
         [FromServices] IRepository<Ingredient> ingredientRepository,
         CancellationToken cancellationToken)
     {
+        if (await validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false) is
+            { IsValid: false } validation)
+        {
+            return TypedResults.ValidationProblem(validation.ToDictionary());
+        }
+
         var userId = currentUser.GetUserId();
         var meal = await mealRepository.FindOneAsync(m => m.Id == request.Id && m.UserId == userId, new FindOptions() { IsAsNoTracking = true }).ConfigureAwait(false);
 


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T22:07:08Z
user: login: Loko3610
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T22:08:39Z
user: login: Loko3610
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T22:08:52Z
user: login: Loko3610
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T22:09:02Z
user: login: Loko3610
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T22:09:13Z
user: login: jcsawyer
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T22:13:38Z

Review comments:
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Copilot
body: There is a typo in the error message: "GEATER" should be "GREATER" for CARBS.
```suggestion
                .WithMessage("CARBS_GREATER_THAN_ZERO");
            RuleFor(x => x.Protein)
                .GreaterThan(0)
                .WithMessage("PROTEIN_GREATER_THAN_ZERO");
            RuleFor(x => x.Fat)
                .GreaterThan(0)
                .WithMessage("FAT_GREATER_THAN_ZERO");
            RuleFor(x => x.Calories)
                .GreaterThan(0)
                .WithMessage("CALORIES_GREATER_THAN_ZERO");
```
created_at: 2025-04-11T22:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Copilot
body: There is a typo in the error message: "GEATER" should be "GREATER" for PROTEIN.
```suggestion
                .WithMessage("CARBS_GREATER_THAN_ZERO");
            RuleFor(x => x.Protein)
                .GreaterThan(0)
                .WithMessage("PROTEIN_GREATER_THAN_ZERO");
            RuleFor(x => x.Fat)
                .GreaterThan(0)
                .WithMessage("FAT_GREATER_THAN_ZERO");
            RuleFor(x => x.Calories)
                .GreaterThan(0)
                .WithMessage("CALORIES_GREATER_THAN_ZERO");
```
created_at: 2025-04-11T22:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Copilot
body: There is a typo in the error message: "GEATER" should be "GREATER" for FAT.
```suggestion
                .WithMessage("CARBS_GREATER_THAN_ZERO");
            RuleFor(x => x.Protein)
                .GreaterThan(0)
                .WithMessage("PROTEIN_GREATER_THAN_ZERO");
            RuleFor(x => x.Fat)
                .GreaterThan(0)
                .WithMessage("FAT_GREATER_THAN_ZERO");
            RuleFor(x => x.Calories)
                .GreaterThan(0)
                .WithMessage("CALORIES_GREATER_THAN_ZERO");
```
created_at: 2025-04-11T22:07:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Copilot
body: There is a typo in the error message: "GEATER" should be "GREATER" for CALORIES.
```suggestion
                .WithMessage("CARBS_GREATER_THAN_ZERO");
            RuleFor(x => x.Protein)
                .GreaterThan(0)
                .WithMessage("PROTEIN_GREATER_THAN_ZERO");
            RuleFor(x => x.Fat)
                .GreaterThan(0)
                .WithMessage("FAT_GREATER_THAN_ZERO");
            RuleFor(x => x.Calories)
                .GreaterThan(0)
                .WithMessage("CALORIES_GREATER_THAN_ZERO");
```
created_at: 2025-04-11T22:07:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Loko3610
body: Fixed, thanks.
created_at: 2025-04-11T22:08:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Loko3610
body: Fixed, thanks.
created_at: 2025-04-11T22:08:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Loko3610
body: Fixed, thanks.
created_at: 2025-04-11T22:09:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+﻿using FluentValidation;
+using GlucoPilot.Data.Enums;
+using System;
+
+namespace GlucoPilot.Api.Endpoints.Ingredients.NewIngredient;
+
+public record NewIngredientRequest
+{
+    public required string Name { get; set; }
+    public int Carbs { get; set; }
+    public int Protein { get; set; }
+    public int Fat { get; set; }
+    public int Calories { get; set; }
+    public required UnitOfMeasurement Uom { get; set; }
+
+    public sealed class NewIngredientRequestValidator : AbstractValidator<NewIngredientRequest>
+    {
+        public NewIngredientRequestValidator()
+        {
+            RuleFor(x => x.Name)
+                .NotEmpty()
+                .WithMessage("NAME_IS_REQUIRED");
+            RuleFor(x => x.Carbs)
+                .GreaterThan(0)
+                .WithMessage("CARBS_GEATER_THAN_ZERO");
+            RuleFor(x => x.Protein)
+                .GreaterThan(0)
+                .WithMessage("PROTEIN_GEATER_THAN_ZERO");
+            RuleFor(x => x.Fat)
+                .GreaterThan(0)
+                .WithMessage("FAT_GEATER_THAN_ZERO");
+            RuleFor(x => x.Calories)
+                .GreaterThan(0)
+                .WithMessage("CALORIES_GEATER_THAN_ZERO");
path: src/GlucoPilot.Api/Endpoints/Ingredients/NewIngredient/NewIngredientRequest.cs
user: login: Loko3610
body: Fixed, thanks.
created_at: 2025-04-11T22:09:12Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-11T22:10:06Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=glucopilot_GlucoPilot&pullRequest=104) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=glucopilot_GlucoPilot&pullRequest=104&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=glucopilot_GlucoPilot&pullRequest=104&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=glucopilot_GlucoPilot&pullRequest=104&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=glucopilot_GlucoPilot&pullRequest=104&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=glucopilot_GlucoPilot&pullRequest=104&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=glucopilot_GlucoPilot&pullRequest=104)


author_association: NONE