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 @@ -24,7 +24,11 @@ public async Task<Result<CartView>> Handle(AddCartItemCommand command, Cancellat

var cart = await ResolveCartAsync(command.CartId, command.UserId, ct);
var existing = cart.Items.FirstOrDefault(i => i.WidgetId == command.WidgetId);
var desired = Math.Min((existing?.Quantity ?? 0) + command.Quantity, widget.QuantityAvailable);
// Widened to long before adding. In int arithmetic a quantity near int.MaxValue wraps
// negative, Math.Min then picks the negative, and the shopper is told the item is out of
// stock - a misleading answer to a bad request rather than a dangerous one, but the clamp
// should do the clamping.
var desired = (int)Math.Min((long)(existing?.Quantity ?? 0) + command.Quantity, widget.QuantityAvailable);
if (desired <= 0)
{
return Result<CartView>.Fail("This widget is out of stock.");
Expand Down
14 changes: 12 additions & 2 deletions src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,17 @@ public static Order Create(
};
}

/// <summary>Human-quotable order number: WW-{date}-{6 chars}, e.g. WW-20260501-A1B2C3.</summary>
/// <summary>
/// Human-quotable order number: WW-{date}-{10 chars}, e.g. WW-20260501-A1B2C3D4E5.
///
/// The suffix is the head of the order's own v4 Guid, so it is random rather than sequential -
/// an order number cannot be incremented to reach the next customer's. Its width is the part
/// that matters: order_number carries a unique index, so a collision is not a data leak but it
/// is a failed checkout, and collisions arrive by the birthday bound rather than when the space
/// runs out. Six characters is 24 bits, which is a coin flip at roughly five thousand orders in
/// a single day; ten characters is 40 bits, which stays under a rounding error past a million.
/// The cost of the extra four characters is four characters.
/// </summary>
public static string NumberFor(DateTimeOffset now, Guid orderId)
=> $"WW-{now:yyyyMMdd}-{orderId.ToString("N")[..6].ToUpperInvariant()}";
=> $"WW-{now:yyyyMMdd}-{orderId.ToString("N")[..10].ToUpperInvariant()}";
}
29 changes: 29 additions & 0 deletions tests/WidgetWorks.UnitTests/CartHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,33 @@ public async Task Merge_never_absorbs_another_users_cart()
Assert.Equal("Cart not found.", result.Error);
Assert.NotNull(await carts.GetAsync(victimCart.Value!.Id, CancellationToken.None)); // untouched
}

[Fact]
public async Task An_absurd_quantity_clamps_to_stock_instead_of_wrapping_negative()
{
var widgets = new InMemoryWidgetRepository();
var carts = new InMemoryCartRepository();
var widgetId = Guid.NewGuid();
widgets.Store[widgetId] = new Widget
{
Id = widgetId,
Sku = "WW-001",
Name = "Standard Widget Block Cobalt",
Price = 9.99m,
IsActive = true,
QuantityOnHand = 5,
QuantityReserved = 0,
};

var handler = new AddCartItemHandler(carts, widgets, new FakeTimeProvider(
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));

var result = await handler.Handle(
new AddCartItemCommand(null, null, widgetId, int.MaxValue), CancellationToken.None);

// In int arithmetic this wrapped negative and answered "out of stock". The clamp should
// clamp: five are available, so five is what lands in the cart.
Assert.True(result.IsSuccess);
Assert.Equal(5, result.Value!.Items.Single().Quantity);
}
}
20 changes: 18 additions & 2 deletions tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,27 @@ public void The_quote_and_the_charge_are_the_same_calculation()
[Fact]
public void An_order_number_is_dated_and_short_enough_to_read_out()
{
var id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000000");
var id = Guid.Parse("a1b2c3d4-e5f6-0000-0000-000000000000");

var number = OrderDraft.NumberFor(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero), id);

Assert.Equal("WW-20260501-A1B2C3", number);
Assert.Equal("WW-20260501-A1B2C3D4E5", number);
}

[Fact]
public void An_order_number_carries_enough_of_the_id_to_make_a_collision_negligible()
{
var number = OrderDraft.NumberFor(
new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero),
Guid.NewGuid());

// order_number is uniquely indexed, so a collision is a failed checkout rather than a leak,
// and collisions arrive by the birthday bound. Ten hex characters is 40 bits; six was 24,
// which is a coin flip at about five thousand orders in one day. This pins the width so it
// cannot be shortened back for tidiness.
var suffix = number.Split('-')[2];
Assert.Equal(10, suffix.Length);
Assert.Equal(suffix.ToUpperInvariant(), suffix);
}

[Fact]
Expand Down