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 @@ -91,7 +91,7 @@ private static async Task<IResult> UpdateProduct(
if (!tenant.IsResolved) return Results.Unauthorized();

var command = new UpdateProductCommand(
id, request.Name, request.DefaultUnit,
id, request.Version, request.Name, request.DefaultUnit,
request.DefaultPriceMinorUnits, request.EggGradeId, request.Notes);

var validation = await validator.ValidateAsync(command, ct);
Expand Down Expand Up @@ -127,7 +127,7 @@ private static async Task<IResult> UpdateConversion(
{
if (!tenant.IsResolved) return Results.Unauthorized();

var command = new UpdateEggUnitConversionCommand(id, request.EggsPerUnit, request.Active);
var command = new UpdateEggUnitConversionCommand(id, request.Version, request.EggsPerUnit, request.Active);
var validation = await validator.ValidateAsync(command, ct);
if (!validation.IsValid)
return ValidationResponse.Problem(validation);
Expand All @@ -141,6 +141,7 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error)
if (error.Code.EndsWith(".NotFound", StringComparison.Ordinal))
return Results.NotFound();
return error.Code is "Product.DuplicateName" or "Product.NotActive" or "Product.AlreadyActive"
or "Product.VersionMismatch" or "EggUnitConversion.VersionMismatch"
? Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict, title: error.Code)
: Results.Problem(error.Description, statusCode: 422, title: error.Code);
}
Expand All @@ -161,10 +162,10 @@ public sealed record CreateProductRequest(
long? DefaultPriceMinorUnits, Guid? EggGradeId, string? Notes);

public sealed record UpdateProductRequest(
string Name, string DefaultUnit,
int? Version, string Name, string DefaultUnit,
long? DefaultPriceMinorUnits, Guid? EggGradeId, string? Notes);

public sealed record EggUnitConversionResponse(
Guid Id, string UnitCode, int EggsPerUnit, bool Active, int Version);

public sealed record UpdateEggUnitConversionRequest(int EggsPerUnit, bool Active);
public sealed record UpdateEggUnitConversionRequest(int? Version, int EggsPerUnit, bool Active);
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public async Task<Result> HandleAsync(UpdateEggUnitConversionCommand command, Ca
var conversion = await conversions.GetByIdAsync(command.ConversionId, ct);
if (conversion is null)
return Result.Failure(Error.NotFound("EggUnitConversion", command.ConversionId));
if (conversion.Version != command.Version)
return Result.Failure(Error.Conflict(
"EggUnitConversion.VersionMismatch", "This packed unit was changed since you loaded it — reload and retry."));

var previous = conversion.EggsPerUnit;
var result = conversion.Update(command.EggsPerUnit, command.Active);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ public sealed class UpdateEggUnitConversionValidator : AbstractValidator<UpdateE
{
public UpdateEggUnitConversionValidator()
{
// An omitted Version binds to null, never 0 (UpdateCustomerValidator).
RuleFor(x => x.Version).NotNull().WithErrorCode("EggUnitConversion.Version.Required")
.GreaterThanOrEqualTo(0).WithErrorCode("EggUnitConversion.Version.NonNegative");
RuleFor(x => x.EggsPerUnit).GreaterThanOrEqualTo(1).WithErrorCode("EggUnitConversion.EggsPerUnit.Min");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public async Task<Result> HandleAsync(UpdateProductCommand command, Cancellation
var product = await products.GetByIdAsync(command.ProductId, ct);
if (product is null)
return Result.Failure(Error.NotFound("Product", command.ProductId));
if (product.Version != command.Version)
return Result.Failure(Error.Conflict(
"Product.VersionMismatch", "This product was changed since you loaded it — reload and retry."));

if (await products.NameExistsAsync(command.Name, excludeId: product.Id, ct))
return Result.Failure(Error.Conflict(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public sealed class UpdateProductValidator : AbstractValidator<UpdateProductComm
{
public UpdateProductValidator()
{
// An omitted Version binds to null, never 0 (UpdateCustomerValidator).
RuleFor(x => x.Version).NotNull().WithErrorCode("Product.Version.Required")
.GreaterThanOrEqualTo(0).WithErrorCode("Product.Version.NonNegative");
RuleFor(x => x.Name).NotEmpty().WithErrorCode("Product.Name.Required")
.MaximumLength(Product.MaxNameLength).WithErrorCode("Product.Name.MaxLength");
// All products are egg products this phase, so the egg-unit whitelist
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
namespace Cluckwork.Application.Modules.Commerce.Contracts;

public sealed record UpdateEggUnitConversionCommand(Guid ConversionId, int EggsPerUnit, bool Active);
public sealed record UpdateEggUnitConversionCommand(
Guid ConversionId, int? Version, int EggsPerUnit, bool Active);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace Cluckwork.Application.Modules.Commerce.Contracts;

public sealed record UpdateProductCommand(
Guid ProductId,
int? Version,
string Name,
string DefaultUnit,
long? DefaultPriceMinorUnits,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ public sealed class DailyEntryRepository(AppDbContext db) : IDailyEntryRepositor
{
// Grades are always eager-loaded: RecordProduction does a full replace of the
// lines, which requires the current lines to be tracked (orphan delete).
// Reads load them in catalog order (EggGradeRepository: SortOrder, then
// Name), not physical row order, which differs between databases. The two
// filter-free write lookups below show no lines, so they keep a plain Include.
private IQueryable<DailyEntry> WithGrades(IQueryable<DailyEntry> entries) =>
entries.Include(e => e.Grades
.OrderBy(g => db.EggGrades.Where(x => x.Id == g.EggGradeId && x.AccountId == g.AccountId)
.Select(x => x.SortOrder).FirstOrDefault())
.ThenBy(g => db.EggGrades.Where(x => x.Id == g.EggGradeId && x.AccountId == g.AccountId)
.Select(x => x.Name).FirstOrDefault())
.ThenBy(g => g.EggGradeId));

public Task<DailyEntry?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
db.DailyEntries
.Include(e => e.Grades)
WithGrades(db.DailyEntries)
.FirstOrDefaultAsync(e => e.Id == id, ct);

public Task<DailyEntry?> GetByIdForFlockScopedWriteAsync(
Expand Down Expand Up @@ -43,17 +53,13 @@ public sealed class DailyEntryRepository(AppDbContext db) : IDailyEntryRepositor
e.Status != DailyEntryStatus.Voided, ct);

public Task<DailyEntry?> GetReadOnlyAsync(Guid id, CancellationToken ct = default) =>
db.DailyEntries
.AsNoTracking()
.Include(e => e.Grades)
WithGrades(db.DailyEntries.AsNoTracking())
.FirstOrDefaultAsync(e => e.Id == id, ct);

public async Task<IReadOnlyList<DailyEntry>> ListAsync(
Guid? flockId, DateOnly? from, DateOnly? to, int limit, int offset,
CancellationToken ct = default) =>
await db.DailyEntries
.AsNoTracking()
.Include(e => e.Grades)
await WithGrades(db.DailyEntries.AsNoTracking())
.Where(e => (flockId == null || e.FlockId == flockId)
&& (from == null || e.Date >= from)
&& (to == null || e.Date <= to))
Expand Down
157 changes: 110 additions & 47 deletions tests/Cluckwork.Api.IntegrationTests/CatalogTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using Cluckwork.Api.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Cluckwork.Api.IntegrationTests;

Expand Down Expand Up @@ -92,7 +93,7 @@ public async Task Update_RepointsGrade_DeactivateHidesFromDefaultList()
{
Content = JsonContent.Create(new
{
name = "Best Eggs", defaultUnit = "Carton",
version = 0, name = "Best Eggs", defaultUnit = "Carton",
defaultPriceMinorUnits = (long?)600, eggGradeId = grades["Medium"],
notes = "premium",
})
Expand Down Expand Up @@ -134,7 +135,7 @@ public async Task Conversions_SeededDefaults_UpdateGuards_IndividualImmutable()
// A market where the carton is 30 eggs.
var carton = conversions.Single(c => c.UnitCode == "Carton");
var put = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/egg-unit-conversions/{carton.Id}")
{ Content = JsonContent.Create(new { eggsPerUnit = 30, active = true }) };
{ Content = JsonContent.Create(new { version = carton.Version, eggsPerUnit = 30, active = true }) };
put.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(put)).StatusCode);

Expand All @@ -144,12 +145,12 @@ public async Task Conversions_SeededDefaults_UpdateGuards_IndividualImmutable()
// Individual is immutable → 422; eggsPerUnit < 1 → 400.
var individual = conversions.Single(c => c.UnitCode == "Individual");
var immutable = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/egg-unit-conversions/{individual.Id}")
{ Content = JsonContent.Create(new { eggsPerUnit = 6, active = true }) };
{ Content = JsonContent.Create(new { version = individual.Version, eggsPerUnit = 6, active = true }) };
immutable.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.SendAsync(immutable)).StatusCode);

var zero = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/egg-unit-conversions/{carton.Id}")
{ Content = JsonContent.Create(new { eggsPerUnit = 0, active = true }) };
{ Content = JsonContent.Create(new { version = carton.Version + 1, eggsPerUnit = 0, active = true }) };
zero.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.BadRequest, (await client.SendAsync(zero)).StatusCode);
}
Expand All @@ -171,57 +172,119 @@ public async Task Create_RejectsNonEggUnits_AndNumericEnumValues()
(await CreateProductAsync(client, "P-numtype", grades["Large"], type: "1")).StatusCode);
}

// AGENTS.md race rule: every aggregate mutation bumps Version by exactly 1
// per success — parallel writers can conflict (409) but never lose updates.
// AGENTS.md race rule: two admins editing from the same loaded Version.
// Exactly one wins, the other gets 409, and the stored row is wholly the
// winner's payload — never last-writer-wins.
[Fact]
public async Task ParallelWrites_NeverLoseUpdates()
public async Task Product_Update_SameBaseVersion_ExactlyOneWinner()
{
var (client, _, _, grades) = await SetupAsync();
var (client, accountId, _, grades) = await SetupAsync();
var created = await CreateProductAsync(client, "Race Eggs", grades["Large"]);
var id = (await created.Content.ReadFromJsonAsync<Created>())!.Id;

Task<HttpResponseMessage> Update(string name)
{
var put = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/products/{id}")
Task<HttpResponseMessage> Update(string name, long price) =>
client.PutWithKeyAsync($"/api/v1/products/{id}", Guid.NewGuid().ToString(), new
{
Content = JsonContent.Create(new
{
name, defaultUnit = "Dozen",
defaultPriceMinorUnits = (long?)500, eggGradeId = grades["Large"],
notes = (string?)null,
})
};
put.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
return client.SendAsync(put);
}

var responses = await Task.WhenAll(Update("Race Eggs A"), Update("Race Eggs B"));
var successes = responses.Count(r => r.StatusCode == HttpStatusCode.NoContent);
Assert.True(successes >= 1);
Assert.All(responses, r => Assert.Contains(r.StatusCode,
new[] { HttpStatusCode.NoContent, HttpStatusCode.Conflict }));

// Version moved exactly once per success — a lost update would show
// fewer bumps than successes.
var row = Assert.Single((await client.GetFromJsonAsync<List<ProductRow>>("/api/v1/products"))!);
Assert.Equal(successes, row.Version);
version = 0, name, defaultUnit = "Dozen",
defaultPriceMinorUnits = (long?)price, eggGradeId = grades["Large"],
notes = (string?)null,
});

// Same rule for conversions.
var conversions = await client.GetFromJsonAsync<List<ConversionRow>>("/api/v1/egg-unit-conversions");
var carton = conversions!.Single(c => c.UnitCode == "Carton");
Task<HttpResponseMessage> UpdateConv(int eggs)
var responses = await Task.WhenAll(Update("Race Eggs A", 500), Update("Race Eggs B", 900));
Assert.Single(responses, r => r.StatusCode == HttpStatusCode.NoContent);
Assert.Single(responses, r => r.StatusCode == HttpStatusCode.Conflict);

var row = await factory.WithTenantScopeAsync(accountId, db => db.Products.SingleAsync(p => p.Id == id));
Assert.Equal(1, row.Version);
Assert.True(
(row.Name == "Race Eggs A" && row.DefaultPriceMinorUnits == 500) ||
(row.Name == "Race Eggs B" && row.DefaultPriceMinorUnits == 900),
"final row must be wholly one winner's payload");
}

[Fact]
public async Task Conversion_Update_SameBaseVersion_ExactlyOneWinner()
{
var (client, accountId, _, _) = await SetupAsync();
var carton = (await client.GetFromJsonAsync<List<ConversionRow>>("/api/v1/egg-unit-conversions"))!
.Single(c => c.UnitCode == "Carton");

Task<HttpResponseMessage> Update(int eggs) =>
client.PutWithKeyAsync($"/api/v1/egg-unit-conversions/{carton.Id}", Guid.NewGuid().ToString(),
new { version = carton.Version, eggsPerUnit = eggs, active = true });

var responses = await Task.WhenAll(Update(18), Update(30));
Assert.Single(responses, r => r.StatusCode == HttpStatusCode.NoContent);
Assert.Single(responses, r => r.StatusCode == HttpStatusCode.Conflict);

var row = await factory.WithTenantScopeAsync(accountId,
db => db.EggUnitConversions.SingleAsync(c => c.Id == carton.Id));
Assert.Equal(carton.Version + 1, row.Version);
Assert.Contains(row.EggsPerUnit, new[] { 18, 30 });
}

// The deterministic half: a form loaded before someone else's save is
// refused even when the two requests never overlap.
[Fact]
public async Task Product_StaleBaseVersion_Is409_AndChangesNothing()
{
var (client, accountId, _, grades) = await SetupAsync();
var created = await CreateProductAsync(client, "Stale Eggs", grades["Large"]);
var id = (await created.Content.ReadFromJsonAsync<Created>())!.Id;
object Body(string name) => new
{
var put = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/egg-unit-conversions/{carton.Id}")
{ Content = JsonContent.Create(new { eggsPerUnit = eggs, active = true }) };
put.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
return client.SendAsync(put);
}
var convResponses = await Task.WhenAll(UpdateConv(18), UpdateConv(30));
var convSuccesses = convResponses.Count(r => r.StatusCode == HttpStatusCode.NoContent);
Assert.True(convSuccesses >= 1);
var after = (await client.GetFromJsonAsync<List<ConversionRow>>("/api/v1/egg-unit-conversions"))!
version = 0, name, defaultUnit = "Dozen",
defaultPriceMinorUnits = (long?)500, eggGradeId = grades["Large"], notes = (string?)null,
};

Assert.Equal(HttpStatusCode.NoContent,
(await client.PutWithKeyAsync($"/api/v1/products/{id}", Guid.NewGuid().ToString(), Body("First"))).StatusCode);
var stale = await client.PutWithKeyAsync($"/api/v1/products/{id}", Guid.NewGuid().ToString(), Body("Second"));
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.Contains("Product.VersionMismatch", await stale.Content.ReadAsStringAsync());

var product = await factory.WithTenantScopeAsync(accountId, db => db.Products.SingleAsync(p => p.Id == id));
Assert.Equal(("First", 1), (product.Name, product.Version));
}

[Fact]
public async Task Conversion_StaleBaseVersion_Is409_AndChangesNothing()
{
var (client, accountId, _, _) = await SetupAsync();
var carton = (await client.GetFromJsonAsync<List<ConversionRow>>("/api/v1/egg-unit-conversions"))!
.Single(c => c.UnitCode == "Carton");
Assert.Equal(carton.Version + convSuccesses, after.Version);
object Conv(int eggs) => new { version = carton.Version, eggsPerUnit = eggs, active = true };
Assert.Equal(HttpStatusCode.NoContent, (await client.PutWithKeyAsync(
$"/api/v1/egg-unit-conversions/{carton.Id}", Guid.NewGuid().ToString(), Conv(18))).StatusCode);
var staleConv = await client.PutWithKeyAsync(
$"/api/v1/egg-unit-conversions/{carton.Id}", Guid.NewGuid().ToString(), Conv(30));
Assert.Equal(HttpStatusCode.Conflict, staleConv.StatusCode);
Assert.Contains("EggUnitConversion.VersionMismatch", await staleConv.Content.ReadAsStringAsync());

var conversion = await factory.WithTenantScopeAsync(accountId,
db => db.EggUnitConversions.SingleAsync(c => c.Id == carton.Id));
Assert.Equal((18, carton.Version + 1), (conversion.EggsPerUnit, conversion.Version));
}

// An omitted version is a 400, not a silent pass as version 0.
[Fact]
public async Task MissingVersion_Is400()
{
var (client, _, _, grades) = await SetupAsync();
var created = await CreateProductAsync(client, "No Version", grades["Large"]);
var id = (await created.Content.ReadFromJsonAsync<Created>())!.Id;
var carton = (await client.GetFromJsonAsync<List<ConversionRow>>("/api/v1/egg-unit-conversions"))!
.Single(c => c.UnitCode == "Carton");

Assert.Equal(HttpStatusCode.BadRequest, (await client.PutWithKeyAsync(
$"/api/v1/products/{id}", Guid.NewGuid().ToString(), new
{
name = "X", defaultUnit = "Dozen", defaultPriceMinorUnits = (long?)500,
eggGradeId = grades["Large"], notes = (string?)null,
})).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PutWithKeyAsync(
$"/api/v1/egg-unit-conversions/{carton.Id}", Guid.NewGuid().ToString(),
new { eggsPerUnit = 18, active = true })).StatusCode);
}

// The lower(Name) unique index is the real duplicate guarantee — the
Expand Down Expand Up @@ -264,7 +327,7 @@ public async Task Writes_AreAdminOnly_ReadsOpen()
(await worker.PostWithKeyAsync($"/api/v1/products/{id}/deactivate", Guid.NewGuid().ToString())).StatusCode);
var put = new HttpRequestMessage(HttpMethod.Put,
$"/api/v1/egg-unit-conversions/{conversions![0].Id}")
{ Content = JsonContent.Create(new { eggsPerUnit = 24, active = true }) };
{ Content = JsonContent.Create(new { version = conversions[0].Version, eggsPerUnit = 24, active = true }) };
put.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await worker.SendAsync(put)).StatusCode);
}
Expand Down
Loading