Add unit-of-measure catalog foundation
Phase 1 of structured quantities + UoM. Introduces a global UnitOfMeasure catalog (Code-keyed for stable backend lookup of canonical units like "each") and FamilyUnitOfMeasure for family-scoped customs, mirroring the product-catalog pattern. Endpoints expose the merged effective catalog plus CRUD for family customs. Abbreviation uniqueness is enforced per table at the DB layer and across tables at the API layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using YesChef.Api.Entities;
|
||||
using YesChef.Api.Features.Units;
|
||||
using YesChef.Api.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Features;
|
||||
|
||||
public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
private Task<int> GetFamilyIdAsync() => UseDbAsync(db =>
|
||||
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
|
||||
|
||||
[Test]
|
||||
public async Task List_returns_global_units_and_family_customs_merged()
|
||||
{
|
||||
var familyId = await GetFamilyIdAsync();
|
||||
await UseDbAsync(async db =>
|
||||
{
|
||||
db.UnitsOfMeasure.Add(new UnitOfMeasure
|
||||
{
|
||||
Code = "each", SingularName = "each", PluralName = "each",
|
||||
Abbreviation = "ea", Category = UnitCategory.Count, IsBase = true, SortOrder = 0,
|
||||
});
|
||||
db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = familyId,
|
||||
SingularName = "scoop", PluralName = "scoops",
|
||||
Abbreviation = "scp", Category = UnitCategory.Volume, SortOrder = 50,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var results = await Client.GetFromJsonAsync<List<UnitEndpoints.UnitDto>>("/api/units");
|
||||
|
||||
await Assert.That(results!.Select(r => r.Abbreviation)).IsEquivalentTo(new[] { "ea", "scp" });
|
||||
var each = results!.Single(r => r.Abbreviation == "ea");
|
||||
await Assert.That(each.Kind).IsEqualTo(UnitEndpoints.UnitKind.Global);
|
||||
await Assert.That(each.Code).IsEqualTo("each");
|
||||
var scoop = results!.Single(r => r.Abbreviation == "scp");
|
||||
await Assert.That(scoop.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family);
|
||||
await Assert.That(scoop.Code).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task List_does_not_leak_other_family_units()
|
||||
{
|
||||
await UseDbAsync(async db =>
|
||||
{
|
||||
var other = new Family { Name = "Other", InviteCode = "other-code" };
|
||||
db.Families.Add(other);
|
||||
await db.SaveChangesAsync();
|
||||
db.FamilyUnitsOfMeasure.Add(new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = other.Id,
|
||||
SingularName = "pinch", PluralName = "pinches",
|
||||
Abbreviation = "pn", Category = UnitCategory.Volume,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var results = await Client.GetFromJsonAsync<List<UnitEndpoints.UnitDto>>("/api/units");
|
||||
|
||||
await Assert.That(results!.Any(r => r.Abbreviation == "pn")).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_persists_family_unit_and_returns_201()
|
||||
{
|
||||
var response = await Client.PostAsJsonAsync("/api/units",
|
||||
new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||
var dto = await response.Content.ReadFromJsonAsync<UnitEndpoints.UnitDto>();
|
||||
await Assert.That(dto!.Kind).IsEqualTo(UnitEndpoints.UnitKind.Family);
|
||||
await Assert.That(dto.Abbreviation).IsEqualTo("slv");
|
||||
|
||||
var persisted = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync());
|
||||
await Assert.That(persisted.SingularName).IsEqualTo("sleeve");
|
||||
await Assert.That(persisted.Category).IsEqualTo(UnitCategory.Packaging);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_returns_409_when_abbreviation_collides_with_global()
|
||||
{
|
||||
await UseDbAsync(async db =>
|
||||
{
|
||||
db.UnitsOfMeasure.Add(new UnitOfMeasure
|
||||
{
|
||||
Code = "lb", SingularName = "pound", PluralName = "pounds",
|
||||
Abbreviation = "lb", Category = UnitCategory.Weight,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/units",
|
||||
new UnitEndpoints.CreateUnitRequest("librapound", "librapounds", "lb", UnitCategory.Weight));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_returns_409_when_abbreviation_collides_within_family()
|
||||
{
|
||||
await Client.PostAsJsonAsync("/api/units",
|
||||
new UnitEndpoints.CreateUnitRequest("sleeve", "sleeves", "slv", UnitCategory.Packaging));
|
||||
|
||||
var response = await Client.PostAsJsonAsync("/api/units",
|
||||
new UnitEndpoints.CreateUnitRequest("slab", "slabs", "slv", UnitCategory.Packaging));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_returns_400_when_required_field_missing()
|
||||
{
|
||||
var response = await Client.PostAsJsonAsync("/api/units",
|
||||
new UnitEndpoints.CreateUnitRequest(" ", "sleeves", "slv", UnitCategory.Packaging));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_family_unit_changes_fields()
|
||||
{
|
||||
var familyId = await GetFamilyIdAsync();
|
||||
var unit = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = familyId,
|
||||
SingularName = "old", PluralName = "olds",
|
||||
Abbreviation = "od", Category = UnitCategory.Count, SortOrder = 1,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u;
|
||||
});
|
||||
|
||||
var response = await Client.PutAsJsonAsync($"/api/units/family/{unit.Id}",
|
||||
new UnitEndpoints.UpdateUnitRequest("new", "news", "nw", UnitCategory.Weight, 2));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
var refreshed = await UseDbAsync(db => db.FamilyUnitsOfMeasure.SingleAsync(u => u.Id == unit.Id));
|
||||
await Assert.That(refreshed.SingularName).IsEqualTo("new");
|
||||
await Assert.That(refreshed.Abbreviation).IsEqualTo("nw");
|
||||
await Assert.That(refreshed.Category).IsEqualTo(UnitCategory.Weight);
|
||||
await Assert.That(refreshed.SortOrder).IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_family_unit_404_for_other_family_id()
|
||||
{
|
||||
var foreignId = await UseDbAsync(async db =>
|
||||
{
|
||||
var other = new Family { Name = "Other", InviteCode = "other-code" };
|
||||
db.Families.Add(other);
|
||||
await db.SaveChangesAsync();
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = other.Id,
|
||||
SingularName = "foreign", PluralName = "foreigns",
|
||||
Abbreviation = "fg", Category = UnitCategory.Count,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
|
||||
var response = await Client.PutAsJsonAsync($"/api/units/family/{foreignId}",
|
||||
new UnitEndpoints.UpdateUnitRequest("x", "xs", "xx", UnitCategory.Count, 0));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_family_unit_removes_row()
|
||||
{
|
||||
var familyId = await GetFamilyIdAsync();
|
||||
var unit = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = familyId,
|
||||
SingularName = "doomed", PluralName = "doomeds",
|
||||
Abbreviation = "dm", Category = UnitCategory.Count,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u;
|
||||
});
|
||||
|
||||
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
||||
var remaining = await UseDbAsync(db => db.FamilyUnitsOfMeasure.CountAsync());
|
||||
await Assert.That(remaining).IsEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Endpoints_require_authentication()
|
||||
{
|
||||
var response = await AnonymousClient.GetAsync("/api/units");
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user