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
45 changes: 45 additions & 0 deletions backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,51 @@ public async Task Create_Recipe_And_Get_Ingredients(
ingredients![0].Name.Should().Be(ingredientName);
}

[Theory, AutoData]
public async Task Create_Recipe_With_Duplicate_Name_Returns_UnprocessableEntity(
[NoAutoProperties] NewRecipe recipe,
[StringLength(500, MinimumLength = 1)] string recipeName,
[StringLength(50, MinimumLength = 1)] string ingredientName)
{
using var client = await fixture.GetHttpClient();
await PostIngredientAsync(client, ingredientName);
recipe.Name = recipeName;
recipe.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 100 }];

await PostRecipeAsync(client, recipe);

using var requestContent = new StringContent(JsonSerializer.Serialize(recipe), Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/api/recipe", requestContent);

await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
}

Comment thread
dgee2 marked this conversation as resolved.
[Theory, AutoData]
public async Task Update_Recipe_To_Duplicate_Name_Returns_UnprocessableEntity(
[NoAutoProperties] NewRecipe recipe1,
[StringLength(500, MinimumLength = 1)] string recipeName1,
[NoAutoProperties] NewRecipe recipe2,
[StringLength(500, MinimumLength = 1)] string recipeName2,
[StringLength(50, MinimumLength = 1)] string ingredientName)
{
using var client = await fixture.GetHttpClient();
await PostIngredientAsync(client, ingredientName);
recipe1.Name = recipeName1;
recipe1.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 100 }];
recipe2.Name = recipeName2;
recipe2.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 200 }];

await PostRecipeAsync(client, recipe1);
var (id2, _) = await PostRecipeAsync(client, recipe2);

// Try to rename recipe2 to recipe1's name - should be 422
recipe2.Name = recipeName1;
using var requestContent = new StringContent(JsonSerializer.Serialize(recipe2), Encoding.UTF8, "application/json");
using var response = await client.PutAsync($"/api/recipe/{id2}", requestContent);

await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
}

private static async Task PostIngredientAsync(HttpClient client, string name)
{
var body = new { name, unitIds = new[] { 4 } }; // Grams
Expand Down
23 changes: 23 additions & 0 deletions backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

namespace MenuApi.Exceptions;

public static class DbUpdateExceptionExtensions
{
private const int SqlServerUniqueConstraintViolationError = 2627;
private const int SqlServerUniqueIndexViolationError = 2601;

public static bool IsUniqueConstraintViolation(this DbUpdateException exception)
{
return exception.InnerException is SqlException sqlEx &&
(sqlEx.Number == SqlServerUniqueConstraintViolationError ||
sqlEx.Number == SqlServerUniqueIndexViolationError);
}

public static bool IsUniqueConstraintViolation(this SqlException exception)
{
return exception.Number == SqlServerUniqueConstraintViolationError ||
exception.Number == SqlServerUniqueIndexViolationError;
}
}
29 changes: 28 additions & 1 deletion backend/MenuApi/Repositories/IngredientRepository.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using MenuDB;
using MenuDB.Data;
using MenuApi.Exceptions;
using MenuApi.ValueObjects;
using Microsoft.EntityFrameworkCore;

Expand Down Expand Up @@ -49,7 +50,33 @@ public class IngredientRepository(MenuDbContext db) : IIngredientRepository
.ToList(),
};
db.Ingredients.Add(entity);
await db.SaveChangesAsync().ConfigureAwait(false);
try
{
await db.SaveChangesAsync().ConfigureAwait(false);
}
catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation())
{
// Detach the failed entities so EF doesn't retry the insert.
foreach (var unit in entity.IngredientUnits)
{
db.Entry(unit).State = EntityState.Detached;
}

db.Entry(entity).State = EntityState.Detached;

// Race condition: another request inserted the same name between our read and write.
// Re-query and return the now-existing ingredient.
var racedExisting = await db.Ingredients
Comment thread
dgee2 marked this conversation as resolved.
.Where(i => i.Name == newIngredient.Name.Value)
.OrderBy(i => i.Id)
.Select(i => new IngredientProjection(
i.Id,
i.Name,
i.IngredientUnits.Select(iu => new UnitProjection(iu.Unit.Name, iu.Unit.Abbreviation, iu.Unit.UnitType.Name))))
.FirstAsync()
.ConfigureAwait(false);
return MapToViewModel(racedExisting);
}
Comment thread
dgee2 marked this conversation as resolved.
Comment thread
dgee2 marked this conversation as resolved.

var created = await db.Ingredients
.Where(i => i.Id == entity.Id)
Expand Down
26 changes: 21 additions & 5 deletions backend/MenuApi/Repositories/RecipeRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MenuApi.DBModel;
using MenuApi.Exceptions;
using MenuApi.ValueObjects;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

namespace MenuApi.Repositories;
Expand Down Expand Up @@ -47,7 +48,15 @@ public async Task<RecipeId> CreateRecipeAsync(RecipeName name)
{
var entity = new RecipeEntity { Name = name.Value };
db.Recipes.Add(entity);
await db.SaveChangesAsync().ConfigureAwait(false);
try
{
await db.SaveChangesAsync().ConfigureAwait(false);
}
catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation())
{
throw new BusinessValidationException($"A recipe named '{name.Value}' already exists.");
}
Comment thread
dgee2 marked this conversation as resolved.
Comment thread
dgee2 marked this conversation as resolved.
Comment thread
dgee2 marked this conversation as resolved.

return RecipeId.From(entity.Id);
}

Expand Down Expand Up @@ -114,9 +123,16 @@ public async Task UpsertRecipeIngredientsAsync(RecipeId recipeId, IEnumerable<DB

public async Task UpdateRecipeAsync(RecipeId recipeId, RecipeName name)
{
await db.Recipes
.Where(r => r.Id == recipeId.Value)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Name, name.Value))
.ConfigureAwait(false);
try
{
await db.Recipes
.Where(r => r.Id == recipeId.Value)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.Name, name.Value))
.ConfigureAwait(false);
}
catch (SqlException ex) when (ex.IsUniqueConstraintViolation())
{
throw new BusinessValidationException($"A recipe named '{name.Value}' already exists.");
}
}
}
5 changes: 5 additions & 0 deletions backend/MenuDB/MenuDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
e.HasKey(x => x.Id);
e.Property(x => x.Id).UseIdentityColumn();
e.Property(x => x.Name).HasColumnType("varchar(500)").IsRequired();
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Recipe_Name");
});

modelBuilder.Entity<IngredientEntity>(e =>
Expand All @@ -28,6 +29,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
e.HasKey(x => x.Id);
e.Property(x => x.Id).UseIdentityColumn();
e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired();
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Ingredient_Name");
});

modelBuilder.Entity<UnitTypeEntity>(e =>
Expand All @@ -36,6 +38,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
e.HasKey(x => x.Id);
e.Property(x => x.Id).ValueGeneratedNever();
e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired();
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_UnitType_Name");
e.HasData(
new UnitTypeEntity { Id = 1, Name = "Volume" },
new UnitTypeEntity { Id = 2, Name = "Quantity" },
Expand All @@ -49,6 +52,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
e.Property(x => x.Id).ValueGeneratedNever();
e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired();
e.Property(x => x.Abbreviation).HasColumnType("varchar(5)");
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Unit_Name");
e.HasIndex(x => x.Abbreviation).IsUnique().HasDatabaseName("UX_Unit_Abbreviation").HasFilter("[Abbreviation] IS NOT NULL");
e.HasOne(x => x.UnitType)
.WithMany(x => x.Units)
.HasForeignKey(x => x.UnitTypeId)
Expand Down
Loading
Loading