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
43 changes: 40 additions & 3 deletions backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ namespace MenuApi.Integration.Tests;
[Collection("API Host Collection")]
public class IngredientIntegrationTests
{
private const string IngredientEndpoint = "/api/ingredient";

private readonly JsonSerializerOptions jsonOptions;
private readonly ApiTestFixture fixture;

Expand All @@ -28,7 +30,7 @@ public IngredientIntegrationTests(ApiTestFixture fixture)
public async Task Get_Ingredients_Returns_Ok()
{
using var client = await fixture.GetHttpClient();
using var response = await client.GetAsync("/api/ingredient");
using var response = await client.GetAsync(IngredientEndpoint);

await response.ShouldHaveStatusCode(HttpStatusCode.OK);

Expand Down Expand Up @@ -78,7 +80,7 @@ public async Task Create_Ingredient_Then_Get_Ingredients_Contains_Created([Strin

var (createdId, _, _) = await PostIngredientAsync(client, ingredientName, [3]);

using var response = await client.GetAsync("/api/ingredient");
using var response = await client.GetAsync(IngredientEndpoint);
await response.ShouldHaveStatusCode(HttpStatusCode.OK);

var data = await response.Content.ReadAsStringAsync();
Expand All @@ -100,12 +102,47 @@ public async Task Create_Ingredient_With_Duplicate_Units_Returns_Unique_Units([S
units.Should().ContainSingle(u => u.Name == "Grams");
}

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

var (firstId, _, _) = await PostIngredientAsync(client, ingredientName, [4]);
var (secondId, _, _) = await PostIngredientAsync(client, ingredientName, [1]);

secondId.Should().Be(firstId);

using var listResponse = await client.GetAsync(IngredientEndpoint);
await listResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

var data = await listResponse.Content.ReadAsStringAsync();
var ingredients = JsonSerializer.Deserialize<List<Ingredient>>(data, jsonOptions);
ingredients.Should().NotBeNull();
ingredients!.Count(i => string.Equals(i.Name, ingredientName, StringComparison.OrdinalIgnoreCase)).Should().Be(1);
}

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

// Prepend "X" to guarantee at least one cased letter so upper/lower are always distinct
var baseName = "X" + ingredientName;
var upperName = baseName.ToUpperInvariant();
var lowerName = baseName.ToLowerInvariant();

var (firstId, _, _) = await PostIngredientAsync(client, upperName, [4]);
var (secondId, _, _) = await PostIngredientAsync(client, lowerName, [1]);

secondId.Should().Be(firstId);
}

internal async Task<(int Id, string Name, List<IngredientUnit> Units)> PostIngredientAsync(
HttpClient client, string name, List<int> unitIds)
{
var body = new NewIngredient { Name = name, UnitIds = unitIds };
var requestContent = new StringContent(JsonSerializer.Serialize(body, jsonOptions), Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/api/ingredient", requestContent);
using var response = await client.PostAsync(IngredientEndpoint, requestContent);

await response.ShouldHaveStatusCode(HttpStatusCode.OK);

Expand Down
114 changes: 114 additions & 0 deletions backend/MenuApi.Tests/Repositories/IngredientRepositoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using AwesomeAssertions;
using MenuDB;
using MenuDB.Data;
using MenuApi.Repositories;
using MenuApi.ValueObjects;
using MenuApi.ViewModel;
using Microsoft.EntityFrameworkCore;
using Xunit;

namespace MenuApi.Tests.Repositories;

public class IngredientRepositoryTests
{
[Fact]
public async Task CreateIngredientAsync_Creates_New_Ingredient_When_Name_Does_Not_Exist()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var db = CreateDbContext();
await SeedUnitsAsync(db, cancellationToken);
var sut = new IngredientRepository(db);

var result = await sut.CreateIngredientAsync(new NewIngredient
{
Name = IngredientName.From("Sugar"),
UnitIds = [4],
});

result.Id.Value.Should().BeGreaterThan(0);
result.Name.Should().Be(IngredientName.From("Sugar"));
result.Units.Should().ContainSingle(u => u.Name == IngredientUnitName.From("Grams"));

var count = await db.Ingredients.CountAsync(cancellationToken);
count.Should().Be(1);
}

[Fact]
public async Task CreateIngredientAsync_Returns_Existing_Ingredient_When_Name_Already_Exists()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var db = CreateDbContext();
await SeedUnitsAsync(db, cancellationToken);

var unitType = new UnitTypeEntity { Id = 99, Name = "Other" };
var otherUnit = new UnitEntity { Id = 99, Name = "Cup", UnitTypeId = 99, UnitType = unitType };
db.UnitTypes.Add(unitType);
db.Units.Add(otherUnit);
await db.SaveChangesAsync(cancellationToken);

var sut = new IngredientRepository(db);
var first = await sut.CreateIngredientAsync(new NewIngredient
{
Name = IngredientName.From("Sugar"),
UnitIds = [4],
});

var second = await sut.CreateIngredientAsync(new NewIngredient
{
Name = IngredientName.From("Sugar"),
UnitIds = [99],
});

second.Id.Should().Be(first.Id);
second.Units.Should().ContainSingle(u => u.Name == IngredientUnitName.From("Grams"));

var count = await db.Ingredients.CountAsync(i => i.Name == "Sugar", cancellationToken);
count.Should().Be(1);
}

[Fact]
public async Task CreateIngredientAsync_Returns_Existing_Ingredient_When_Name_Already_Exists_Even_With_Unknown_UnitId()
{
// UnitIds are only used when inserting a new row. When the canonical row already exists,
// the provided UnitIds are intentionally ignored — the existing ingredient is returned as-is.
var cancellationToken = TestContext.Current.CancellationToken;
await using var db = CreateDbContext();
await SeedUnitsAsync(db, cancellationToken);

var sut = new IngredientRepository(db);
var first = await sut.CreateIngredientAsync(new NewIngredient
{
Name = IngredientName.From("Sugar"),
UnitIds = [4],
});

var second = await sut.CreateIngredientAsync(new NewIngredient
{
Name = IngredientName.From("Sugar"),
UnitIds = [9999], // non-existent unit ID — ignored because ingredient already exists
});

second.Id.Should().Be(first.Id);
second.Units.Should().ContainSingle(u => u.Name == IngredientUnitName.From("Grams"));
}

private static MenuDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<MenuDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;

return new MenuDbContext(options);
}

private static async Task SeedUnitsAsync(MenuDbContext db, CancellationToken cancellationToken)
{
var unitType = new UnitTypeEntity { Id = 3, Name = "Weight" };
var unit = new UnitEntity { Id = 4, Name = "Grams", UnitTypeId = 3, UnitType = unitType };

db.UnitTypes.Add(unitType);
db.Units.Add(unit);

await db.SaveChangesAsync(cancellationToken);
}
}
64 changes: 32 additions & 32 deletions backend/MenuApi/Repositories/IngredientRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,35 @@ public class IngredientRepository(MenuDbContext db) : IIngredientRepository
public async Task<IEnumerable<ViewModel.Ingredient>> GetIngredientsAsync()
{
var rows = await db.Ingredients
.Select(i => new
{
.Select(i => new IngredientProjection(
i.Id,
i.Name,
Units = i.IngredientUnits.Select(iu => new
{
iu.Unit.Name,
iu.Unit.Abbreviation,
UnitType = iu.Unit.UnitType.Name,
})
})
i.IngredientUnits.Select(iu => new UnitProjection(iu.Unit.Name, iu.Unit.Abbreviation, iu.Unit.UnitType.Name))))
.ToListAsync()
.ConfigureAwait(false);

return rows.Select(i => new ViewModel.Ingredient
{
Id = IngredientId.From(i.Id),
Name = IngredientName.From(i.Name),
Units = i.Units.Select(u => new ViewModel.IngredientUnit(
IngredientUnitName.From(u.Name),
u.Abbreviation is not null ? IngredientUnitAbbreviation.From(u.Abbreviation) : null,
IngredientUnitType.From(u.UnitType))),
});
return rows.Select(MapToViewModel);
}

public async Task<ViewModel.Ingredient> CreateIngredientAsync(ViewModel.NewIngredient newIngredient)
{
ArgumentNullException.ThrowIfNull(newIngredient);

var existing = await db.Ingredients
.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))))
.FirstOrDefaultAsync()
.ConfigureAwait(false);

if (existing is not null)
{
return MapToViewModel(existing);
}
Comment thread
dgee2 marked this conversation as resolved.

var unitIds = newIngredient.UnitIds.Distinct().ToList();

var entity = new IngredientEntity
Expand All @@ -53,28 +53,28 @@ public class IngredientRepository(MenuDbContext db) : IIngredientRepository

var created = await db.Ingredients
.Where(i => i.Id == entity.Id)
.Select(i => new
{
.Select(i => new IngredientProjection(
i.Id,
i.Name,
Units = i.IngredientUnits.Select(iu => new
{
iu.Unit.Name,
iu.Unit.Abbreviation,
UnitType = iu.Unit.UnitType.Name,
})
})
i.IngredientUnits.Select(iu => new UnitProjection(iu.Unit.Name, iu.Unit.Abbreviation, iu.Unit.UnitType.Name))))
.FirstAsync()
.ConfigureAwait(false);

return new ViewModel.Ingredient
return MapToViewModel(created);
}

private static ViewModel.Ingredient MapToViewModel(IngredientProjection p) =>
new()
{
Id = IngredientId.From(created.Id),
Name = IngredientName.From(created.Name),
Units = created.Units.Select(u => new ViewModel.IngredientUnit(
Id = IngredientId.From(p.Id),
Name = IngredientName.From(p.Name),
Units = p.Units.Select(u => new ViewModel.IngredientUnit(
IngredientUnitName.From(u.Name),
u.Abbreviation is not null ? IngredientUnitAbbreviation.From(u.Abbreviation) : null,
IngredientUnitType.From(u.UnitType))),
};
}

private sealed record IngredientProjection(int Id, string Name, IEnumerable<UnitProjection> Units);

private sealed record UnitProjection(string Name, string? Abbreviation, string UnitType);
}
Loading