Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,71 @@
returnedIngredients[0].Amount.Should().Be(125m);
}

[Theory, AutoData]
public async Task Create_Recipe_With_Conflicting_Ingredient_Amounts_Returns_UnprocessableEntity(
[StringLength(50, MinimumLength = 1)] string ingredientName,
[StringLength(500, MinimumLength = 1)] string recipeName)
{
using var client = await fixture.GetHttpClient();

await PostIngredientAsync(client, ingredientName, [4]);

var newRecipe = new NewRecipe
{
Name = recipeName,
Ingredients =
[
new RecipeIngredient { Name = ingredientName, Unit = "Grams", Amount = 100m },
new RecipeIngredient { Name = ingredientName, Unit = "Grams", Amount = 200m },
]
};

using var content = new StringContent(JsonSerializer.Serialize(newRecipe, jsonOptions), Encoding.UTF8, "application/json");

Check warning on line 257 in backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'application/json' 5 times.

See more on https://sonarcloud.io/project/issues?id=dgee2_Menu&issues=AZ5WqO-s-AUzBe-lB8wL&open=AZ5WqO-s-AUzBe-lB8wL&pullRequest=1063
using var response = await client.PostAsync("/api/recipe", content);

await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);

var responseBody = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseBody);
doc.RootElement.GetProperty("status").GetInt32().Should().Be(422);
}

[Theory, AutoData]
public async Task Update_Recipe_With_Conflicting_Ingredient_Amounts_Returns_UnprocessableEntity(
[StringLength(50, MinimumLength = 1)] string ingredientName,
[StringLength(500, MinimumLength = 1)] string recipeName,
[StringLength(500, MinimumLength = 1)] string updatedRecipeName)
{
using var client = await fixture.GetHttpClient();

await PostIngredientAsync(client, ingredientName, [4]);

var (recipeId, _, _) = await PostRecipeAsync(client, new NewRecipe
{
Name = recipeName,
Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = "Grams", Amount = 100m }]
});

var updateBody = new NewRecipe
{
Name = updatedRecipeName,
Ingredients =
[
new RecipeIngredient { Name = ingredientName, Unit = "Grams", Amount = 100m },
new RecipeIngredient { Name = ingredientName, Unit = "Grams", Amount = 200m },
]
};

using var content = new StringContent(JsonSerializer.Serialize(updateBody, jsonOptions), Encoding.UTF8, "application/json");
using var response = await client.PutAsync($"/api/recipe/{recipeId}", content);

await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);

var responseBody = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseBody);
doc.RootElement.GetProperty("status").GetInt32().Should().Be(422);
}

private async Task PostIngredientAsync(HttpClient client, string name, List<int> unitIds)
{
var body = new NewIngredient { Name = name, UnitIds = unitIds };
Expand Down
79 changes: 79 additions & 0 deletions backend/MenuApi.Tests/Services/RecipeServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AwesomeAssertions;
using FakeItEasy;
using MenuDB;
using MenuApi.Exceptions;
using MenuApi.Repositories;
using MenuApi.Services;
using MenuApi.ValueObjects;
Expand Down Expand Up @@ -189,4 +190,82 @@ public async Task UpdateRecipe_Should_Throw_Exception_For_null_newRecipeAsync(Re
var result = await fun.Should().ThrowAsync<ArgumentNullException>();
result.And.ParamName.Should().Be("newRecipe");
}

[Fact]
public async Task CreateRecipeAsync_Throws_BusinessValidationException_When_Same_IngredientUnit_Has_Conflicting_Amounts()
{
var recipeName = RecipeName.From("Cake");

await sut.Invoking(s => s.CreateRecipeAsync(new NewRecipe
{
Name = recipeName,
Ingredients =
[
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(100m) },
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(200m) },
],
})).Should().ThrowAsync<BusinessValidationException>();

A.CallTo(() => recipeRepository.CreateRecipeAsync(A<RecipeName>._)).MustNotHaveHappened();
A.CallTo(() => recipeRepository.UpsertRecipeIngredientsAsync(A<RecipeId>._, A<IEnumerable<DBModel.RecipeIngredient>>._)).MustNotHaveHappened();
}

[Fact]
public async Task UpdateRecipeAsync_Throws_BusinessValidationException_When_Same_IngredientUnit_Has_Conflicting_Amounts()
{
var recipeId = RecipeId.From(1);
var recipeName = RecipeName.From("Cake");

await sut.Invoking(s => s.UpdateRecipeAsync(recipeId, new NewRecipe
{
Name = recipeName,
Ingredients =
[
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(100m) },
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(200m) },
],
})).Should().ThrowAsync<BusinessValidationException>();

A.CallTo(() => recipeRepository.UpdateRecipeAsync(A<RecipeId>._, A<RecipeName>._)).MustNotHaveHappened();
A.CallTo(() => recipeRepository.UpsertRecipeIngredientsAsync(A<RecipeId>._, A<IEnumerable<DBModel.RecipeIngredient>>._)).MustNotHaveHappened();
}

[Fact]
public async Task CreateRecipeAsync_Reports_All_Conflicting_IngredientUnit_Pairs()
{
var recipeName = RecipeName.From("Cake");

var ex = await sut.Invoking(s => s.CreateRecipeAsync(new NewRecipe
{
Name = recipeName,
Ingredients =
[
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(100m) },
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(200m) },
new RecipeIngredient { Name = IngredientName.From("Flour"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(300m) },
new RecipeIngredient { Name = IngredientName.From("Flour"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(400m) },
],
})).Should().ThrowAsync<BusinessValidationException>();

ex.And.Message.Should().Contain("Sugar");
ex.And.Message.Should().Contain("Flour");
}

[Fact]
public async Task CreateRecipeAsync_Exact_Duplicate_Then_Conflicting_Detects_Conflict()
{
// Three entries: two exact duplicates (silently absorbed) + one with different amount = conflict
var recipeName = RecipeName.From("Cake");

await sut.Invoking(s => s.CreateRecipeAsync(new NewRecipe
{
Name = recipeName,
Ingredients =
[
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(100m) },
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(100m) },
new RecipeIngredient { Name = IngredientName.From("Sugar"), Unit = IngredientUnitName.From("Grams"), Amount = IngredientAmount.From(200m) },
],
})).Should().ThrowAsync<BusinessValidationException>();
}
}
20 changes: 19 additions & 1 deletion backend/MenuApi/Services/RecipeService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MenuDB;
using MenuApi.Exceptions;
using MenuApi.MappingProfiles;
using MenuApi.Repositories;
using MenuApi.ValueObjects;
Expand Down Expand Up @@ -71,6 +72,23 @@ await strategy.ExecuteAsync(async () =>
private static IReadOnlyList<DBModel.RecipeIngredient> NormalizeRecipeIngredients(IEnumerable<ViewModel.RecipeIngredient> recipeIngredients)
{
ArgumentNullException.ThrowIfNull(recipeIngredients);
return [.. ViewModelMapper.Map(recipeIngredients).Distinct()];

var deduped = ViewModelMapper.Map(recipeIngredients).Distinct().ToList();

// After exact-duplicate removal, any (IngredientName, UnitName) group with more than one
// entry represents the same ingredient+unit with conflicting amounts — a business conflict.
var conflicts = deduped
.GroupBy(i => (i.IngredientName, i.UnitName))
.Where(g => g.Count() > 1)
.Select(g => $"'{g.Key.IngredientName.Value}' with unit '{g.Key.UnitName.Value}'")
.ToList();

if (conflicts.Count > 0)
{
throw new BusinessValidationException(
$"The following ingredients appear more than once with conflicting amounts: {string.Join(", ", conflicts)}.");
}

return deduped;
}
}
Loading