From 9ee5643c71a3242937653565ab350c78f3e4a536 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:03:19 -0500 Subject: [PATCH 1/9] test: cover the untested handlers and the webhook signature check Backend coverage was 46.3% line / 39.1% branch, and the gaps were not evenly spread: whole handlers had no test at all, and the one unauthenticated write path in the application -- the payment webhook -- had none either. Adds 104 tests across four areas: - Refresh-token rotation, logout, registration. The rotation cases are the point: a refresh token is single-use, so replaying a spent one has to revoke the entire family rather than mint a new token, and an expired one behaves the same way. - 2FA confirm/disable/recovery. Every factor change must rotate the security stamp, and a recovery code must work exactly once -- both now asserted rather than assumed. - Read-side handlers and projections, including the ownership boundary: another user's order id returns the same "Order not found." as a nonexistent one, because confirming an id exists is itself a leak. - Webhook verification, in depth. A signature made with the wrong secret, a genuine signature for a substituted body, a shifted timestamp, a short signature, a missing header, and an unconfigured secret all have to fail closed; rotation (two v1 values) and uppercase hex have to succeed. One fake was lying: InMemoryCartRepository.TouchAsync did nothing, so a handler could forget to stamp the cart and still pass. It now mirrors the real repository. Application 67.5% -> 90.5%, overall 46.3% -> 58.2%. Co-Authored-By: Claude Opus 5 --- .../WidgetWorks.UnitTests/AuthSessionTests.cs | 284 ++++++++++++ tests/WidgetWorks.UnitTests/Fakes.cs | 12 +- .../WidgetWorks.UnitTests/OrderQueryTests.cs | 404 ++++++++++++++++++ .../TwoFactorManagementTests.cs | 271 ++++++++++++ .../WebhookParserTests.cs | 293 +++++++++++++ .../WidgetWorks.UnitTests.csproj | 4 + web/package-lock.json | 242 +++++++++++ web/package.json | 1 + 8 files changed, 1510 insertions(+), 1 deletion(-) create mode 100644 tests/WidgetWorks.UnitTests/AuthSessionTests.cs create mode 100644 tests/WidgetWorks.UnitTests/OrderQueryTests.cs create mode 100644 tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs create mode 100644 tests/WidgetWorks.UnitTests/WebhookParserTests.cs diff --git a/tests/WidgetWorks.UnitTests/AuthSessionTests.cs b/tests/WidgetWorks.UnitTests/AuthSessionTests.cs new file mode 100644 index 0000000..b62e724 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/AuthSessionTests.cs @@ -0,0 +1,284 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Auth.Logout; +using WidgetWorks.Application.Auth.Refresh; +using WidgetWorks.Application.Auth.Register; +using WidgetWorks.Domain.Auth; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Refresh-token rotation, logout, and registration. The rotation cases matter most: a refresh +/// token is single-use, so replaying one has to revoke the whole family rather than mint a token. +/// +public class AuthSessionTests +{ + private static readonly DateTimeOffset Now = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero); + + private sealed record Ctx( + FakeTimeProvider Clock, + InMemoryUserRepository Users, + InMemoryRefreshTokenRepository Refresh, + StubTokenService Tokens, + User User); + + private static Ctx Setup() + { + var users = new InMemoryUserRepository(); + var user = new User + { + Id = Guid.NewGuid(), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + PasswordHash = "hash:pw", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + }; + users.Store[user.Id] = user; + return new Ctx(new FakeTimeProvider(Now), users, new InMemoryRefreshTokenRepository(), new StubTokenService(), user); + } + + private static RefreshToken Issue(Ctx c, Guid familyId, DateTimeOffset? expiresAt = null, DateTimeOffset? revokedAt = null) + { + var token = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = c.User.Id, + TokenHash = c.Tokens.HashRefreshToken("raw-token"), + FamilyId = familyId, + ExpiresAt = expiresAt ?? Now.AddDays(14), + CreatedAt = Now.AddMinutes(-5), + RevokedAt = revokedAt, + }; + c.Refresh.Tokens.Add(token); + return token; + } + + private static RefreshHandler Refresh(Ctx c) => new(c.Users, c.Refresh, c.Tokens, c.Clock); + + private static LogoutHandler Logout(Ctx c) => new(c.Refresh, c.Tokens, c.Clock); + + // ---- refresh ------------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Refresh_requires_a_token(string raw) + { + var c = Setup(); + var result = await Refresh(c).Handle(new RefreshCommand(raw), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Refresh token is required.", result.Error); + } + + [Fact] + public async Task Refresh_rejects_a_token_it_has_never_seen() + { + var c = Setup(); + var result = await Refresh(c).Handle(new RefreshCommand("never-issued"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid refresh token.", result.Error); + } + + [Fact] + public async Task Refresh_rotates_the_token_and_keeps_the_family() + { + var c = Setup(); + var family = Guid.NewGuid(); + var original = Issue(c, family); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("access-token", result.Value!.AccessToken); + Assert.Equal(UserRoles.Customer, result.Value.Role); + + // The presented token is spent, and points at what replaced it. + Assert.Equal(Now, original.RevokedAt); + Assert.NotNull(original.ReplacedBy); + + var replacement = Assert.Single(c.Refresh.Tokens, t => t.Id != original.Id); + Assert.Equal(family, replacement.FamilyId); + Assert.Equal(original.ReplacedBy, replacement.Id); + Assert.Null(replacement.RevokedAt); + } + + [Fact] + public async Task Refresh_of_a_revoked_token_revokes_the_whole_family() + { + var c = Setup(); + var family = Guid.NewGuid(); + Issue(c, family, revokedAt: Now.AddMinutes(-1)); + var sibling = Issue(c, family); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Refresh token no longer valid.", result.Error); + + // Reuse detection: a stolen token being replayed must not leave live siblings behind. + Assert.Equal(Now, sibling.RevokedAt); + Assert.All(c.Refresh.Tokens, t => Assert.NotNull(t.RevokedAt)); + } + + [Fact] + public async Task Refresh_of_an_expired_token_revokes_the_family_too() + { + var c = Setup(); + var family = Guid.NewGuid(); + Issue(c, family, expiresAt: Now.AddSeconds(-1)); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.All(c.Refresh.Tokens, t => Assert.NotNull(t.RevokedAt)); + } + + [Fact] + public async Task Refresh_fails_when_the_user_behind_the_token_is_gone() + { + var c = Setup(); + Issue(c, Guid.NewGuid()); + c.Users.Store.Clear(); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid refresh token.", result.Error); + } + + // ---- logout -------------------------------------------------------------------------- + + [Fact] + public async Task Logout_revokes_the_presented_token() + { + var c = Setup(); + var token = Issue(c, Guid.NewGuid()); + + var result = await Logout(c).Handle(new LogoutCommand("raw-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(Now, token.RevokedAt); + } + + [Fact] + public async Task Logout_without_a_token_succeeds_quietly() + { + var c = Setup(); + var result = await Logout(c).Handle(new LogoutCommand(" "), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(c.Refresh.Tokens); + } + + [Fact] + public async Task Logout_of_an_unknown_token_succeeds_without_touching_anything() + { + var c = Setup(); + var token = Issue(c, Guid.NewGuid()); + + var result = await Logout(c).Handle(new LogoutCommand("some-other-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Null(token.RevokedAt); + } + + [Fact] + public async Task Logout_twice_keeps_the_first_revocation_time() + { + var c = Setup(); + var revokedEarlier = Now.AddMinutes(-10); + var token = Issue(c, Guid.NewGuid(), revokedAt: revokedEarlier); + + await Logout(c).Handle(new LogoutCommand("raw-token"), CancellationToken.None); + + Assert.Equal(revokedEarlier, token.RevokedAt); + } + + // ---- registration -------------------------------------------------------------------- + + private static RegisterHandler Register(Ctx c, IEmailSender email) + => new(c.Users, new FakePasswordHasher(), email, c.Clock); + + [Theory] + [InlineData("not-an-email")] + [InlineData("")] + [InlineData(" ")] + public async Task Register_requires_an_email_address(string email) + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()).Handle(new RegisterCommand(email, "long-enough-pw"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("A valid email is required.", result.Error); + } + + [Theory] + [InlineData("short")] + [InlineData("")] + [InlineData(null)] + public async Task Register_requires_eight_characters_of_password(string? password) + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()).Handle(new RegisterCommand("new@example.com", password!), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Password must be at least 8 characters.", result.Error); + } + + [Fact] + public async Task Register_does_not_reveal_that_an_email_is_already_taken() + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()) + .Handle(new RegisterCommand(" Jane@Example.com ", "long-enough-pw"), CancellationToken.None); + + Assert.False(result.IsSuccess); + + // Deliberately generic: the message must not distinguish "taken" from any other failure. + Assert.Equal("Unable to register with the provided details.", result.Error); + Assert.DoesNotContain("exists", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Register_creates_a_customer_and_sends_a_welcome() + { + var c = Setup(); + var email = new FakeEmailSender(); + + var result = await Register(c, email).Handle(new RegisterCommand(" New@Example.com ", "long-enough-pw"), CancellationToken.None); + + Assert.True(result.IsSuccess); + var created = Assert.Single(c.Users.Store.Values, u => u.NormalizedEmail == "NEW@EXAMPLE.COM"); + Assert.Equal("New@Example.com", created.Email); + Assert.Equal(UserRoles.Customer, created.Role); + Assert.Equal("hash:long-enough-pw", created.PasswordHash); + Assert.NotEqual(Guid.Empty, created.SecurityStamp); + Assert.Equal(Now, created.CreatedAt); + Assert.Single(email.Sent, m => m.To == "New@Example.com"); + } + + [Fact] + public async Task Register_still_succeeds_when_the_welcome_email_fails() + { + var c = Setup(); + + var result = await Register(c, new ThrowingEmailSender()) + .Handle(new RegisterCommand("new@example.com", "long-enough-pw"), CancellationToken.None); + + // A dead mail server must not cost someone their account. + Assert.True(result.IsSuccess); + Assert.Contains(c.Users.Store.Values, u => u.NormalizedEmail == "NEW@EXAMPLE.COM"); + } + + private sealed class ThrowingEmailSender : IEmailSender + { + public Task SendAsync(EmailMessage message, CancellationToken ct) + => throw new InvalidOperationException("smtp is down"); + } +} diff --git a/tests/WidgetWorks.UnitTests/Fakes.cs b/tests/WidgetWorks.UnitTests/Fakes.cs index e3c002d..7675083 100644 --- a/tests/WidgetWorks.UnitTests/Fakes.cs +++ b/tests/WidgetWorks.UnitTests/Fakes.cs @@ -151,7 +151,17 @@ public Task DeleteAsync(Guid cartId, CancellationToken ct) return Task.CompletedTask; } - public Task TouchAsync(Guid cartId, DateTimeOffset now, CancellationToken ct) => Task.CompletedTask; + public Task TouchAsync(Guid cartId, DateTimeOffset now, CancellationToken ct) + { + // Mirrors the real repository, which stamps updated_at -- a no-op here would let a handler + // forget to touch the cart and still pass. + if (Store.TryGetValue(cartId, out var cart)) + { + cart.UpdatedAt = now; + } + + return Task.CompletedTask; + } private static Cart Clone(Cart c) => new() { diff --git a/tests/WidgetWorks.UnitTests/OrderQueryTests.cs b/tests/WidgetWorks.UnitTests/OrderQueryTests.cs new file mode 100644 index 0000000..ab2846a --- /dev/null +++ b/tests/WidgetWorks.UnitTests/OrderQueryTests.cs @@ -0,0 +1,404 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Carts.GetCart; +using WidgetWorks.Application.Carts.RemoveItem; +using WidgetWorks.Application.Orders; +using WidgetWorks.Application.Orders.Admin; +using WidgetWorks.Application.Orders.GetMine; +using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; +using WidgetWorks.Application.Orders.Lookup; +using WidgetWorks.Application.TwoFactor.Enroll; +using WidgetWorks.Domain.Carts; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Read-side handlers and the projections they return. The case that matters beyond mapping is +/// ownership: "my order" must be unreachable by anyone else's id, and a guest lookup must require +/// the email that placed it. +/// +public class OrderQueryTests +{ + private static readonly DateTimeOffset Now = new(2026, 5, 1, 8, 0, 0, TimeSpan.Zero); + + private static Order MakeOrder(Guid? userId, string email = "jane@example.com", string number = "WW-20260501-ABC123", + string status = OrderStatus.Paid, DateTimeOffset? createdAt = null, params (string Sku, int Qty, decimal Price)[] lines) + { + var items = (lines.Length == 0 ? [("WW-1", 2, 12.50m)] : lines) + .Select(l => new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = Guid.NewGuid(), + Sku = l.Item1, + Name = "Widget " + l.Item1, + UnitPrice = l.Item3, + Quantity = l.Item2, + LineSubtotal = l.Item3 * l.Item2, + }) + .ToList(); + + return new Order + { + Id = Guid.NewGuid(), + OrderNumber = number, + UserId = userId, + Email = email, + Subtotal = items.Sum(i => i.LineSubtotal), + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 1.81m, + Total = items.Sum(i => i.LineSubtotal) + 6.99m + 1.81m, + Status = status, + PaymentProvider = "Mock", + PaymentReference = "mock_ref_1", + TrackingNumber = "1Z999AA10123456784", + CreatedAt = createdAt ?? Now, + UpdatedAt = createdAt ?? Now, + Items = items, + }; + } + + private static InMemoryOrderRepository Repo(params Order[] orders) + { + var repo = new InMemoryOrderRepository(new InMemoryWidgetRepository()); + repo.Orders.AddRange(orders); + return repo; + } + + // ---- my orders ----------------------------------------------------------------------- + + [Fact] + public async Task My_order_returns_the_full_view() + { + var userId = Guid.NewGuid(); + var order = MakeOrder(userId); + var result = await new GetMyOrderHandler(Repo(order)).Handle(new GetMyOrderQuery(userId, order.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + var view = result.Value!; + Assert.Equal(order.OrderNumber, view.OrderNumber); + Assert.Equal("CA", view.TaxState); + Assert.Equal(0.0725m, view.TaxRate); + Assert.Equal(order.Total, view.Total); + Assert.Equal("1Z999AA10123456784", view.TrackingNumber); + Assert.Single(view.Items); + } + + [Fact] + public async Task My_order_refuses_to_return_someone_elses_order() + { + var owner = Guid.NewGuid(); + var order = MakeOrder(owner); + + var result = await new GetMyOrderHandler(Repo(order)) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), order.Id), CancellationToken.None); + + // Same wording as a missing order: knowing an id exists is itself a leak. + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task My_order_refuses_a_guest_order_that_has_no_owner() + { + var order = MakeOrder(userId: null); + var result = await new GetMyOrderHandler(Repo(order)) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), order.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task My_order_fails_for_an_id_that_does_not_exist() + { + var result = await new GetMyOrderHandler(Repo()) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task My_orders_lists_only_mine_newest_first() + { + var me = Guid.NewGuid(); + var older = MakeOrder(me, number: "WW-1", createdAt: Now.AddDays(-2)); + var newer = MakeOrder(me, number: "WW-2", createdAt: Now); + var theirs = MakeOrder(Guid.NewGuid(), number: "WW-3"); + + var list = await new ListMyOrdersHandler(Repo(older, newer, theirs)) + .Handle(new ListMyOrdersQuery(me), CancellationToken.None); + + Assert.Equal(["WW-2", "WW-1"], list.Select(o => o.OrderNumber)); + } + + [Fact] + public async Task My_orders_is_empty_for_someone_with_no_orders() + { + var list = await new ListMyOrdersHandler(Repo(MakeOrder(Guid.NewGuid()))) + .Handle(new ListMyOrdersQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.Empty(list); + } + + // ---- admin --------------------------------------------------------------------------- + + [Fact] + public async Task Admin_can_open_any_order_regardless_of_owner() + { + var order = MakeOrder(Guid.NewGuid()); + var result = await new GetOrderByIdHandler(Repo(order)).Handle(new GetOrderByIdQuery(order.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(order.OrderNumber, result.Value!.OrderNumber); + } + + [Fact] + public async Task Admin_order_lookup_fails_for_an_unknown_id() + { + var result = await new GetOrderByIdHandler(Repo()).Handle(new GetOrderByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task Recent_orders_carries_the_item_count_from_the_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 2, 5m), ("B", 3, 5m)]); + + var list = await new ListRecentOrdersHandler(Repo(order)).Handle(new ListRecentOrdersQuery(50), CancellationToken.None); + + // Regression: an "optimization" once skipped loading item rows, and every row showed 0. + Assert.Equal(5, Assert.Single(list).ItemCount); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(5000)] + public async Task Recent_orders_clamps_a_nonsense_limit_to_the_default(int limit) + { + var repo = Repo(Enumerable.Range(0, 60).Select(i => MakeOrder(Guid.NewGuid(), number: $"WW-{i}")).ToArray()); + + var list = await new ListRecentOrdersHandler(repo).Handle(new ListRecentOrdersQuery(limit), CancellationToken.None); + + Assert.Equal(50, list.Count); + } + + [Fact] + public async Task Recent_orders_honours_a_sensible_limit() + { + var repo = Repo(Enumerable.Range(0, 10).Select(i => MakeOrder(Guid.NewGuid(), number: $"WW-{i}")).ToArray()); + + var list = await new ListRecentOrdersHandler(repo).Handle(new ListRecentOrdersQuery(3), CancellationToken.None); + + Assert.Equal(3, list.Count); + } + + // ---- guest lookup -------------------------------------------------------------------- + + [Theory] + [InlineData("", "jane@example.com")] + [InlineData("WW-1", "")] + [InlineData(" ", " ")] + public async Task Guest_lookup_requires_both_fields(string number, string email) + { + var result = await new GuestOrderLookupHandler(Repo()) + .Handle(new GuestOrderLookupQuery(number, email), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order number and email are required.", result.Error); + } + + [Fact] + public async Task Guest_lookup_finds_an_order_by_number_and_email() + { + var order = MakeOrder(userId: null, email: "guest@example.com", number: "WW-20260501-XYZ999"); + + var result = await new GuestOrderLookupHandler(Repo(order)) + .Handle(new GuestOrderLookupQuery(" WW-20260501-XYZ999 ", " guest@example.com "), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(order.Id, result.Value!.Id); + } + + [Fact] + public async Task Guest_lookup_with_the_wrong_email_finds_nothing() + { + var order = MakeOrder(userId: null, email: "guest@example.com"); + + var result = await new GuestOrderLookupHandler(Repo(order)) + .Handle(new GuestOrderLookupQuery(order.OrderNumber, "someone-else@example.com"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + // ---- projections --------------------------------------------------------------------- + + [Fact] + public void OrderView_copies_every_money_field_and_all_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 1, 10m), ("B", 4, 2.50m)]); + + var view = OrderView.From(order); + + Assert.Equal(order.Subtotal, view.Subtotal); + Assert.Equal(order.Shipping, view.Shipping); + Assert.Equal(order.Tax, view.Tax); + Assert.Equal(order.Total, view.Total); + Assert.Equal(order.ShippingMethod, view.ShippingMethod); + Assert.Equal(order.PaymentProvider, view.PaymentProvider); + Assert.Equal(order.PaymentReference, view.PaymentReference); + Assert.Equal(order.CreatedAt, view.CreatedAt); + Assert.Equal(2, view.Items.Count); + Assert.Equal(10m, view.Items[0].LineSubtotal); + Assert.Equal(10m, view.Items[1].LineSubtotal); + } + + [Fact] + public void OrderSummary_counts_units_not_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 2, 5m), ("B", 3, 5m)]); + + var summary = OrderSummary.From(order); + + Assert.Equal(5, summary.ItemCount); + Assert.Equal(order.Total, summary.Total); + Assert.Equal(order.Status, summary.Status); + } + + [Fact] + public void OrderView_of_an_order_with_no_tracking_leaves_it_null() + { + var order = MakeOrder(Guid.NewGuid()); + order.TrackingNumber = null; + + Assert.Null(OrderView.From(order).TrackingNumber); + } + + // ---- cart reads ---------------------------------------------------------------------- + + private sealed record CartCtx(InMemoryCartRepository Carts, InMemoryWidgetRepository Widgets, Cart Cart, Widget Widget); + + private static CartCtx CartSetup() + { + var widgets = new InMemoryWidgetRepository(); + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-1", + Name = "Standard Widget", + Price = 12.50m, + QuantityOnHand = 10, + IsActive = true, + }; + widgets.Store[widget.Id] = widget; + + var carts = new InMemoryCartRepository(); + var cart = new Cart { Id = Guid.NewGuid(), CreatedAt = Now, UpdatedAt = Now }; + cart.Items.Add(new CartItem { CartId = cart.Id, WidgetId = widget.Id, Quantity = 2 }); + carts.Store[cart.Id] = cart; + + return new CartCtx(carts, widgets, cart, widget); + } + + [Fact] + public async Task Get_cart_prices_the_lines() + { + var c = CartSetup(); + var result = await new GetCartHandler(c.Carts, c.Widgets).Handle(new GetCartQuery(c.Cart.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.ItemCount); + Assert.Equal(25.00m, result.Value.Subtotal); + } + + [Fact] + public async Task Get_cart_fails_for_an_unknown_cart() + { + var c = CartSetup(); + var result = await new GetCartHandler(c.Carts, c.Widgets).Handle(new GetCartQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + [Fact] + public async Task Removing_an_item_empties_the_cart_and_touches_it() + { + var c = CartSetup(); + var clock = new FakeTimeProvider(Now.AddHours(1)); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, clock) + .Handle(new RemoveCartItemCommand(c.Cart.Id, c.Widget.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value!.ItemCount); + Assert.Empty(result.Value.Items); + Assert.Equal(Now.AddHours(1), c.Carts.Store[c.Cart.Id].UpdatedAt); + } + + [Fact] + public async Task Removing_an_item_that_is_not_in_the_cart_is_a_no_op() + { + var c = CartSetup(); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, new FakeTimeProvider(Now)) + .Handle(new RemoveCartItemCommand(c.Cart.Id, Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.ItemCount); + } + + [Fact] + public async Task Removing_from_an_unknown_cart_fails() + { + var c = CartSetup(); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, new FakeTimeProvider(Now)) + .Handle(new RemoveCartItemCommand(Guid.NewGuid(), c.Widget.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + // ---- 2FA enrollment start ------------------------------------------------------------ + + [Fact] + public async Task Enroll_stores_a_pending_secret_and_returns_the_otpauth_uri() + { + var users = new InMemoryUserRepository(); + var user = new User { Id = Guid.NewGuid(), Email = "jane@example.com", NormalizedEmail = "JANE@EXAMPLE.COM" }; + users.Store[user.Id] = user; + var twoFactor = new InMemoryTwoFactorRepository(); + + var result = await new EnrollHandler(users, twoFactor, new FakeTotpService()) + .Handle(new EnrollCommand(user.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("SECRETBASE32", result.Value!.SecretBase32); + Assert.StartsWith("otpauth://", result.Value.OtpAuthUri); + + // Pending, not confirmed: the code still has to be proven. + Assert.False(twoFactor.Secrets[user.Id].IsConfirmed); + } + + [Fact] + public async Task Enroll_fails_for_an_unknown_user() + { + var result = await new EnrollHandler(new InMemoryUserRepository(), new InMemoryTwoFactorRepository(), new FakeTotpService()) + .Handle(new EnrollCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } +} diff --git a/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs b/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs new file mode 100644 index 0000000..c286100 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs @@ -0,0 +1,271 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.TwoFactor.Confirm; +using WidgetWorks.Application.TwoFactor.Disable; +using WidgetWorks.Application.TwoFactor.Recovery; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Confirming enrollment, disabling 2FA, and signing in with a recovery code. Two invariants are +/// under test throughout: any change to a factor rotates the security stamp (killing other +/// sessions), and a recovery code works exactly once. +/// +public class TwoFactorManagementTests +{ + private static readonly DateTimeOffset Now = new(2026, 4, 1, 9, 30, 0, TimeSpan.Zero); + + private sealed record Ctx( + FakeTimeProvider Clock, + InMemoryUserRepository Users, + InMemoryTwoFactorRepository TwoFactor, + InMemoryRefreshTokenRepository Refresh, + FakeTotpService Totp, + FakeRecoveryCodes Recovery, + StubTokenService Tokens, + RecordingAuditLog Audit, + User User); + + private static Ctx Setup(bool twoFactorEnabled = false) + { + var users = new InMemoryUserRepository(); + var user = new User + { + Id = Guid.NewGuid(), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + PasswordHash = "hash:pw", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + TwoFactorEnabled = twoFactorEnabled, + }; + users.Store[user.Id] = user; + return new Ctx(new FakeTimeProvider(Now), users, new InMemoryTwoFactorRepository(), + new InMemoryRefreshTokenRepository(), new FakeTotpService(), new FakeRecoveryCodes(), + new StubTokenService(), new RecordingAuditLog(), user); + } + + private static ConfirmEnrollHandler Confirm(Ctx c) + => new(c.Users, c.TwoFactor, c.Totp, c.Recovery, c.Audit, c.Clock); + + private static DisableTwoFactorHandler Disable(Ctx c) + => new(c.Users, c.TwoFactor, c.Audit); + + private static RecoveryLoginHandler RecoveryLogin(Ctx c) + => new(c.Users, c.Refresh, c.TwoFactor, c.Recovery, c.Tokens, c.Audit, c.Clock); + + // ---- confirm enrollment -------------------------------------------------------------- + + [Fact] + public async Task Confirm_fails_for_an_unknown_user() + { + var c = Setup(); + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(Guid.NewGuid(), "654321"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } + + [Fact] + public async Task Confirm_fails_when_enrollment_was_never_started() + { + var c = Setup(); + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, "654321"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("No pending 2FA enrollment. Start enrollment first.", result.Error); + } + + [Fact] + public async Task Confirm_rejects_a_wrong_code_and_leaves_2fa_off() + { + var c = Setup(); + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, "000000"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid authenticator code.", result.Error); + Assert.False(c.User.TwoFactorEnabled); + Assert.False(c.TwoFactor.Secrets[c.User.Id].IsConfirmed); + } + + [Fact] + public async Task Confirm_enables_2fa_issues_recovery_codes_and_rotates_the_stamp() + { + var c = Setup(); + var stampBefore = c.User.SecurityStamp; + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, c.Totp.ValidCode), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value!.RecoveryCodes.Count); + Assert.Equal(10, result.Value.RecoveryCodes.Distinct().Count()); + Assert.True(c.User.TwoFactorEnabled); + Assert.True(c.TwoFactor.Secrets[c.User.Id].IsConfirmed); + + // Turning on a factor must sign other devices out. + Assert.NotEqual(stampBefore, c.User.SecurityStamp); + Assert.Contains("2fa.enabled", c.Audit.Actions); + } + + [Fact] + public async Task Confirm_replaces_any_previous_recovery_codes() + { + var c = Setup(); + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("stale-code")], Now, CancellationToken.None); + + await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, c.Totp.ValidCode), CancellationToken.None); + + // The old set is gone, not merged with the new one. + var stillUsable = await c.TwoFactor.ConsumeRecoveryCodeAsync( + c.User.Id, c.Recovery.Hash("stale-code"), Now, CancellationToken.None); + Assert.False(stillUsable); + } + + // ---- disable ------------------------------------------------------------------------- + + [Fact] + public async Task Disable_fails_for_an_unknown_user() + { + var c = Setup(); + var result = await Disable(c).Handle(new DisableTwoFactorCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } + + [Fact] + public async Task Disable_clears_the_secret_and_the_recovery_codes() + { + var c = Setup(twoFactorEnabled: true); + var stampBefore = c.User.SecurityStamp; + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + + var result = await Disable(c).Handle(new DisableTwoFactorCommand(c.User.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(c.User.TwoFactorEnabled); + Assert.Empty(c.TwoFactor.Secrets); + Assert.NotEqual(stampBefore, c.User.SecurityStamp); + Assert.Contains("2fa.disabled", c.Audit.Actions); + + var stillUsable = await c.TwoFactor.ConsumeRecoveryCodeAsync( + c.User.Id, c.Recovery.Hash("code-1"), Now, CancellationToken.None); + Assert.False(stillUsable); + } + + // ---- recovery-code login ------------------------------------------------------------- + + [Fact] + public async Task Recovery_login_rejects_an_unparseable_challenge() + { + var c = Setup(twoFactorEnabled: true); + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand("not-a-challenge", "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid or expired challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_rejects_a_challenge_for_a_user_who_is_gone() + { + var c = Setup(twoFactorEnabled: true); + var challenge = c.Tokens.CreateChallengeToken(c.User); + c.Users.Store.Clear(); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_rejects_a_user_who_never_enabled_2fa() + { + var c = Setup(twoFactorEnabled: false); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_with_an_unknown_code_is_audited_and_refused() + { + var c = Setup(twoFactorEnabled: true); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "never-issued"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid recovery code.", result.Error); + Assert.Contains("2fa.recovery_failed", c.Audit.Actions); + Assert.Empty(c.Refresh.Tokens); + } + + [Fact] + public async Task Recovery_login_signs_in_and_issues_a_refresh_token() + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("access-token", result.Value!.AccessToken); + Assert.Equal(UserRoles.Customer, result.Value.Role); + var issued = Assert.Single(c.Refresh.Tokens); + Assert.Equal(c.User.Id, issued.UserId); + Assert.Equal(Now, issued.CreatedAt); + Assert.Contains("2fa.recovery_success", c.Audit.Actions); + } + + [Theory] + [InlineData(" CODE-1 ")] + [InlineData("Code-1")] + public async Task Recovery_codes_are_matched_case_and_whitespace_insensitively(string entered) + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, entered), CancellationToken.None); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task A_recovery_code_works_exactly_once() + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var first = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + var second = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.True(first.IsSuccess); + Assert.False(second.IsSuccess); + Assert.Equal("Invalid recovery code.", second.Error); + Assert.Single(c.Refresh.Tokens); + } + + /// Deterministic stand-in for the real generator: the "hash" is just a marked prefix. + private sealed class FakeRecoveryCodes : IRecoveryCodes + { + public IReadOnlyList Generate(int count) + => Enumerable.Range(1, count).Select(i => new RecoveryCode($"code-{i}", Hash($"code-{i}"))).ToList(); + + public string Hash(string code) => "rc:" + code; + } +} diff --git a/tests/WidgetWorks.UnitTests/WebhookParserTests.cs b/tests/WidgetWorks.UnitTests/WebhookParserTests.cs new file mode 100644 index 0000000..d6d0882 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/WebhookParserTests.cs @@ -0,0 +1,293 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Infrastructure.Payments; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Webhook verification and parsing. This is the app's only unauthenticated write path — anyone on +/// the internet can POST to it — so the signature check is the security boundary, and every way it +/// can be fooled is worth a test: no header, no signature, a signature for a different payload, a +/// signature for a different secret. +/// +public class WebhookParserTests +{ + private const string Secret = "whsec_test_do_not_use"; + + private static StripePaymentWebhookParser Stripe(string secret = Secret) + => new(Options.Create(new StripeOptions { WebhookSecret = secret })); + + private static MockPaymentWebhookParser Mock(string secret = "") + => new(Options.Create(new MockPaymentOptions { WebhookSecret = secret })); + + /// Builds the header Stripe would send: t=timestamp, v1=HMAC-SHA256 of "{t}.{payload}". + private static string SignatureFor(string payload, string secret = Secret, string timestamp = "1735689600") + { + var signed = $"{timestamp}.{payload}"; + var hex = Convert.ToHexStringLower( + HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(signed))); + return $"t={timestamp},v1={hex}"; + } + + // Built by substitution rather than interpolation: the JSON ends in three braces, which an + // interpolated raw string reads as an interpolation hole. + private static string Intent(string type, string id = "pi_3ABC123") => + """{"type":"__TYPE__","data":{"object":{"id":"__ID__","object":"payment_intent"}}}""" + .Replace("__TYPE__", type) + .Replace("__ID__", id); + + // ---- stripe: signature --------------------------------------------------------------- + + [Fact] + public void Stripe_refuses_to_run_without_a_configured_secret() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe(secret: "").TryParse(payload, SignatureFor(payload), out var evt, out var error); + + // Fail closed: an unconfigured secret must never mean "accept everything". + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Stripe webhook secret is not configured.", error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("garbage")] + [InlineData("t=1735689600")] // timestamp but no signature + [InlineData("v1=abc123")] // signature but no timestamp + [InlineData("t=1735689600,v1")] // malformed pair, skipped -> no signatures left + public void Stripe_rejects_a_header_it_cannot_verify(string? header) + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, header, out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_signature_made_with_a_different_secret() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, SignatureFor(payload, secret: "whsec_someone_elses"), out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_valid_signature_for_a_different_payload() + { + var signed = Intent("payment_intent.succeeded", "pi_ORIGINAL"); + var tampered = Intent("payment_intent.succeeded", "pi_ATTACKER"); + + // The signature is genuine — but for the body the attacker replaced. + var ok = Stripe().TryParse(tampered, SignatureFor(signed), out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_signature_bound_to_a_different_timestamp() + { + var payload = Intent("payment_intent.succeeded"); + var header = SignatureFor(payload, timestamp: "1735689600").Replace("t=1735689600", "t=1735689999"); + + var ok = Stripe().TryParse(payload, header, out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_accepts_the_correct_signature() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal("Stripe", evt!.Provider); + Assert.Equal("pi_3ABC123", evt.Reference); + Assert.Equal(PaymentEventType.Succeeded, evt.Type); + } + + [Fact] + public void Stripe_accepts_when_one_of_several_signatures_matches() + { + var payload = Intent("payment_intent.succeeded"); + var real = SignatureFor(payload); + + // During a secret rotation Stripe sends more than one v1. + var header = real + ",v1=" + new string('a', 64); + + Assert.True(Stripe().TryParse(payload, header, out _, out _)); + } + + [Fact] + public void Stripe_accepts_an_uppercase_hex_signature() + { + var payload = Intent("payment_intent.succeeded"); + var header = SignatureFor(payload).ToUpperInvariant().Replace("T=", "t=").Replace("V1=", "v1="); + + Assert.True(Stripe().TryParse(payload, header, out _, out _)); + } + + [Fact] + public void Stripe_rejects_a_signature_of_the_wrong_length_without_throwing() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, "t=1735689600,v1=abcd", out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + // ---- stripe: event mapping ----------------------------------------------------------- + + [Theory] + [InlineData("payment_intent.succeeded", PaymentEventType.Succeeded)] + [InlineData("payment_intent.payment_failed", PaymentEventType.Failed)] + [InlineData("payment_intent.canceled", PaymentEventType.Failed)] + public void Stripe_maps_the_intent_events_it_cares_about(string type, PaymentEventType expected) + { + var payload = Intent(type); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out _); + + Assert.True(ok); + Assert.Equal(expected, evt!.Type); + } + + [Theory] + [InlineData("charge.refunded")] + [InlineData("customer.created")] + [InlineData("")] + public void Stripe_declines_events_it_does_not_handle(string type) + { + var payload = Intent(type); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Contains("Unhandled event type", error); + } + + [Theory] + [InlineData("""{"type":"payment_intent.succeeded"}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{}}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{"object":{}}}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{"object":{"id":""}}}""")] + public void Stripe_requires_a_payment_intent_id(string payload) + { + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Missing PaymentIntent id.", error); + } + + [Fact] + public void Stripe_reports_malformed_json_rather_than_throwing() + { + const string payload = "{not json"; + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Malformed webhook payload.", error); + } + + // ---- mock provider ------------------------------------------------------------------- + + [Fact] + public void Mock_skips_verification_when_no_secret_is_configured() + { + var ok = Mock().TryParse("""{"reference":"mock_pi_1","outcome":"succeeded"}""", null, out var evt, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal("Mock", evt!.Provider); + Assert.Equal("mock_pi_1", evt.Reference); + } + + [Fact] + public void Mock_enforces_the_shared_secret_once_one_is_configured() + { + var parser = Mock(secret: "shhh"); + + Assert.False(parser.TryParse("""{"reference":"mock_pi_1"}""", "wrong", out _, out var error)); + Assert.Equal("Invalid webhook signature.", error); + + Assert.False(parser.TryParse("""{"reference":"mock_pi_1"}""", null, out _, out _)); + Assert.True(parser.TryParse("""{"reference":"mock_pi_1"}""", "shhh", out _, out _)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Mock_rejects_an_empty_body(string payload) + { + var ok = Mock().TryParse(payload, null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Empty webhook payload.", error); + } + + [Theory] + [InlineData("""{"outcome":"succeeded"}""")] + [InlineData("""{"reference":""}""")] + [InlineData("""{"reference":" "}""")] + [InlineData("""{"reference":null}""")] + public void Mock_requires_a_reference(string payload) + { + var ok = Mock().TryParse(payload, null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Missing 'reference'.", error); + } + + [Theory] + [InlineData("""{"reference":"r","outcome":"failed"}""", PaymentEventType.Failed)] + [InlineData("""{"reference":"r","outcome":"FAILED"}""", PaymentEventType.Failed)] + [InlineData("""{"reference":"r","outcome":"succeeded"}""", PaymentEventType.Succeeded)] + [InlineData("""{"reference":"r"}""", PaymentEventType.Succeeded)] + [InlineData("""{"reference":"r","outcome":"anything-else"}""", PaymentEventType.Succeeded)] + public void Mock_treats_only_an_explicit_failure_as_a_failure(string payload, PaymentEventType expected) + { + var ok = Mock().TryParse(payload, null, out var evt, out _); + + Assert.True(ok); + Assert.Equal(expected, evt!.Type); + } + + [Fact] + public void Mock_reports_malformed_json_rather_than_throwing() + { + var ok = Mock().TryParse("{oops", null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Malformed webhook payload.", error); + } + + [Fact] + public void Parsers_declare_the_provider_key_the_route_matches_on() + { + Assert.Equal("Stripe", Stripe().Provider); + Assert.Equal("Mock", Mock().Provider); + } +} diff --git a/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj b/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj index 77c48f2..a465b92 100644 --- a/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj +++ b/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj @@ -5,6 +5,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/web/package-lock.json b/web/package-lock.json index 10a42c2..cfcd933 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,11 +16,82 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -28,6 +99,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -369,6 +451,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -492,6 +605,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -599,6 +731,62 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -888,6 +1076,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1082,6 +1298,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -1119,6 +1348,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/web/package.json b/web/package.json index 78b5d90..54dd714 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" From 833f7c24a30d2873146d03550fc8cad9350ee1c5 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:09:00 -0500 Subject: [PATCH 2/9] refactor: put the fulfilment rules on the order and share one pricer Three of the weaknesses found while reviewing the design against SOLID, each fixed rather than documented away. The order state machine lived in UpdateOrderStatusHandler, so the rule that governs an order was held by the code that happened to call it -- the entity would let anything assign anything. Order now owns it: OrderStatus.AllowedNext/CanTransition hold the table, and Order.TransitionTo applies a move or throws. The handler asks permission first and reports a refusal as a result, because a rejected transition is an expected outcome at that boundary, not an exception. Order.UnitCount joins it, so "how many items" is answered in one place instead of being re-summed by each projection. Quoting and checkout each computed shipping + tax + total themselves. They agreed, but only by inspection -- a change to one was free to diverge from the other, and the difference between the price shown and the price charged is the kind of bug you find in a chargeback. OrderPricer is now the single calculation both call. Building the order row moved out too (OrderDraft), which drops CheckoutHandler from 165 lines and eight dependencies to 130 and seven, and leaves it sequencing steps rather than performing them. The webhook endpoint knew the literal header "Stripe-Signature" -- one provider's detail inside provider-agnostic transport. Parsers now declare their own SignatureHeaders and the endpoint just asks. 40 tests cover the moved rules: every illegal transition including out of a terminal state, tracking numbers surviving a delivery update, tax never applying to shipping, and an empty cart never being quoted for delivery. Co-Authored-By: Claude Opus 5 --- .../Abstractions/IPaymentWebhookParser.cs | 6 + .../Checkout/PlaceOrder/CheckoutHandler.cs | 43 +-- .../Checkout/PlaceOrder/OrderDraft.cs | 65 +++++ .../Checkout/Quote/QuoteCartHandler.cs | 30 +-- .../DependencyInjection.cs | 2 + .../Orders/OrderView.cs | 2 +- .../UpdateStatus/UpdateOrderStatusHandler.cs | 22 +- .../Pricing/OrderPricer.cs | 45 ++++ src/WidgetWorks.Domain/Orders/Order.cs | 41 +++ .../Payments/MockPaymentWebhookParser.cs | 2 + .../Payments/StripePaymentWebhookParser.cs | 2 + .../Payments/PaymentWebhookEndpoints.cs | 4 +- tests/WidgetWorks.UnitTests/CheckoutTests.cs | 7 +- .../ConfirmPaymentTests.cs | 4 +- .../OrderStateMachineTests.cs | 248 ++++++++++++++++++ tests/WidgetWorks.UnitTests/PricingTests.cs | 5 +- 16 files changed, 449 insertions(+), 79 deletions(-) create mode 100644 src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs create mode 100644 src/WidgetWorks.Application/Pricing/OrderPricer.cs create mode 100644 tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs diff --git a/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs b/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs index 0832917..1627198 100644 --- a/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs +++ b/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs @@ -19,5 +19,11 @@ public interface IPaymentWebhookParser /// Provider key, matched case-insensitively against the gateway Name and the route segment. string Provider { get; } + /// + /// Request header(s) this provider delivers its signature in, tried in order. Kept here rather + /// than in the endpoint so transport code never has to know one provider's header name. + /// + IReadOnlyList SignatureHeaders { get; } + bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error); } diff --git a/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs b/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs index 36c6b09..ee285b6 100644 --- a/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs +++ b/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs @@ -1,6 +1,7 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Carts; using WidgetWorks.Application.Notifications; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Common; using WidgetWorks.Domain.Orders; @@ -36,8 +37,7 @@ public sealed class CheckoutHandler( ICartRepository carts, IWidgetRepository widgets, IOrderRepository orders, - IShippingCalculator shipping, - ITaxCalculator tax, + OrderPricer pricer, IPaymentGateway payments, IEmailSender email, TimeProvider clock) @@ -75,45 +75,10 @@ public async Task> Handle(CheckoutCommand command, Cancel } // Re-price server-side; never trust client-supplied totals. - var shippingQuote = shipping.Calculate(command.ShippingMethod, view.Subtotal, view.ItemCount); - var taxLine = tax.Calculate(ship.State, view.Subtotal); - var total = view.Subtotal + shippingQuote.Amount + taxLine.Amount; + var priced = pricer.Price(view, ship.State, command.ShippingMethod); var now = clock.GetUtcNow(); - var order = new Order - { - Id = Guid.NewGuid(), - OrderNumber = $"WW-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}", - UserId = command.UserId, - Email = normalizedEmail, - ShipName = ship.Name?.Trim() ?? string.Empty, - ShipLine1 = ship.Line1.Trim(), - ShipLine2 = string.IsNullOrWhiteSpace(ship.Line2) ? null : ship.Line2.Trim(), - ShipCity = ship.City.Trim(), - ShipState = ship.State.Trim().ToUpperInvariant(), - ShipPostalCode = ship.PostalCode.Trim(), - ShipCountry = string.IsNullOrWhiteSpace(ship.Country) ? "US" : ship.Country.Trim().ToUpperInvariant(), - Subtotal = view.Subtotal, - ShippingMethod = shippingQuote.Method, - Shipping = shippingQuote.Amount, - TaxState = taxLine.StateCode, - TaxRate = taxLine.Rate, - Tax = taxLine.Amount, - Total = total, - Status = OrderStatus.Pending, - CreatedAt = now, - UpdatedAt = now, - Items = view.Items.Select(l => new OrderItem - { - Id = Guid.NewGuid(), - WidgetId = l.WidgetId, - Sku = l.Sku, - Name = l.Name, - UnitPrice = l.UnitPrice, - Quantity = l.Quantity, - LineSubtotal = l.LineSubtotal, - }).ToList(), - }; + var order = OrderDraft.Create(view, priced, ship, normalizedEmail, command.UserId, now, Guid.NewGuid(), Guid.NewGuid); var placed = await orders.TryPlaceAsync(order, ct); if (!placed) diff --git a/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs b/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs new file mode 100644 index 0000000..538c162 --- /dev/null +++ b/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs @@ -0,0 +1,65 @@ +using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Pricing; +using WidgetWorks.Domain.Orders; + +namespace WidgetWorks.Application.Checkout.PlaceOrder; + +/// +/// Turns a priced cart and a shipping address into the order that will be persisted. Separated from +/// so the shape of an order — its number format, which fields are +/// trimmed, what gets snapshotted — can change without touching the payment sequence. +/// +public static class OrderDraft +{ + public static Order Create( + CartView cart, + PricedCart priced, + ShippingAddressInput ship, + string email, + Guid? userId, + DateTimeOffset now, + Guid orderId, + Func newItemId) + { + return new Order + { + Id = orderId, + OrderNumber = NumberFor(now, orderId), + UserId = userId, + Email = email, + ShipName = ship.Name?.Trim() ?? string.Empty, + ShipLine1 = ship.Line1.Trim(), + ShipLine2 = string.IsNullOrWhiteSpace(ship.Line2) ? null : ship.Line2.Trim(), + ShipCity = ship.City.Trim(), + ShipState = ship.State.Trim().ToUpperInvariant(), + ShipPostalCode = ship.PostalCode.Trim(), + ShipCountry = string.IsNullOrWhiteSpace(ship.Country) ? "US" : ship.Country.Trim().ToUpperInvariant(), + Subtotal = priced.Subtotal, + ShippingMethod = priced.ShippingMethod, + Shipping = priced.Shipping, + + // Snapshot the tax that was actually charged: a later rate change must not rewrite history. + TaxState = priced.StateCode, + TaxRate = priced.TaxRate, + Tax = priced.Tax, + Total = priced.Total, + Status = OrderStatus.Pending, + CreatedAt = now, + UpdatedAt = now, + Items = cart.Items.Select(l => new OrderItem + { + Id = newItemId(), + WidgetId = l.WidgetId, + Sku = l.Sku, + Name = l.Name, + UnitPrice = l.UnitPrice, + Quantity = l.Quantity, + LineSubtotal = l.LineSubtotal, + }).ToList(), + }; + } + + /// Human-quotable order number: WW-{date}-{6 chars}, e.g. WW-20260501-A1B2C3. + public static string NumberFor(DateTimeOffset now, Guid orderId) + => $"WW-{now:yyyyMMdd}-{orderId.ToString("N")[..6].ToUpperInvariant()}"; +} diff --git a/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs b/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs index cde3aaf..d7edd2f 100644 --- a/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs +++ b/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs @@ -1,5 +1,6 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Common; namespace WidgetWorks.Application.Checkout.Quote; @@ -21,8 +22,7 @@ public sealed record OrderQuoteView( public sealed class QuoteCartHandler( ICartRepository carts, IWidgetRepository widgets, - IShippingCalculator shipping, - ITaxCalculator tax) + OrderPricer pricer) { public async Task> Handle(QuoteCartCommand command, CancellationToken ct) { @@ -33,21 +33,17 @@ public async Task> Handle(QuoteCartCommand command, Cance } var view = await CartAssembler.BuildAsync(cart, widgets, ct); - var ship = shipping.Calculate(command.ShippingMethod, view.Subtotal, view.ItemCount); - var shippingAmount = view.ItemCount == 0 ? 0m : ship.Amount; - var taxLine = tax.Calculate(command.StateCode, view.Subtotal); - var total = view.Subtotal + shippingAmount + taxLine.Amount; + var priced = pricer.Price(view, command.StateCode, command.ShippingMethod); - var quote = new OrderQuoteView( - view.Subtotal, - ship.Method, - shippingAmount, - taxLine.StateCode, - taxLine.Rate, - taxLine.Amount, - total, - view.ItemCount, - view.ItemCount == 0); - return Result.Success(quote); + return Result.Success(new OrderQuoteView( + priced.Subtotal, + priced.ShippingMethod, + priced.Shipping, + priced.StateCode, + priced.TaxRate, + priced.Tax, + priced.Total, + priced.ItemCount, + priced.IsEmpty)); } } diff --git a/src/WidgetWorks.Application/DependencyInjection.cs b/src/WidgetWorks.Application/DependencyInjection.cs index b11eef3..387aae8 100644 --- a/src/WidgetWorks.Application/DependencyInjection.cs +++ b/src/WidgetWorks.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ using WidgetWorks.Application.Auth.PasswordReset; using WidgetWorks.Application.Auth.Refresh; using WidgetWorks.Application.Auth.Register; +using WidgetWorks.Application.Pricing; using WidgetWorks.Application.Carts.AddItem; using WidgetWorks.Application.Carts.GetCart; using WidgetWorks.Application.Carts.Merge; @@ -39,6 +40,7 @@ public static class DependencyInjection /// Registers the application layer (use-case handlers). No MediatR — plain handlers. public static IServiceCollection AddApplication(this IServiceCollection services) { + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/WidgetWorks.Application/Orders/OrderView.cs b/src/WidgetWorks.Application/Orders/OrderView.cs index 1fc66c8..31f57c1 100644 --- a/src/WidgetWorks.Application/Orders/OrderView.cs +++ b/src/WidgetWorks.Application/Orders/OrderView.cs @@ -43,5 +43,5 @@ public sealed record OrderView( public sealed record OrderSummary(Guid Id, string OrderNumber, string Status, decimal Total, int ItemCount, DateTimeOffset CreatedAt) { - public static OrderSummary From(Order o) => new(o.Id, o.OrderNumber, o.Status, o.Total, o.Items.Sum(i => i.Quantity), o.CreatedAt); + public static OrderSummary From(Order o) => new(o.Id, o.OrderNumber, o.Status, o.Total, o.UnitCount, o.CreatedAt); } diff --git a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs index e41bc63..b737c74 100644 --- a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs +++ b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs @@ -7,15 +7,13 @@ namespace WidgetWorks.Application.Orders.UpdateStatus; public sealed record UpdateOrderStatusCommand(Guid OrderId, string Status, string? TrackingNumber); +/// +/// Drives fulfilment. The legal transitions belong to the order itself (see +/// ); this handler asks permission, persists what the entity +/// decided, and notifies the customer. +/// public sealed class UpdateOrderStatusHandler(IOrderRepository orders, IEmailSender email, TimeProvider clock) { - // Allowed forward transitions. Anything not listed is rejected. - private static readonly IReadOnlyDictionary Allowed = new Dictionary - { - [OrderStatus.Paid] = [OrderStatus.Shipped, OrderStatus.Cancelled], - [OrderStatus.Shipped] = [OrderStatus.Delivered], - }; - public async Task> Handle(UpdateOrderStatusCommand command, CancellationToken ct) { var order = await orders.GetByIdAsync(command.OrderId, ct); @@ -25,17 +23,15 @@ public async Task> Handle(UpdateOrderStatusCommand command, Ca } var target = (command.Status ?? string.Empty).Trim(); - if (!Allowed.TryGetValue(order.Status, out var next) || Array.IndexOf(next, target) < 0) + if (!order.CanTransitionTo(target)) { + // Asked, not caught: a refused transition is an expected outcome here, not an exception. return Result.Fail($"Cannot change status from {order.Status} to '{target}'."); } var now = clock.GetUtcNow(); - var tracking = string.IsNullOrWhiteSpace(command.TrackingNumber) ? order.TrackingNumber : command.TrackingNumber.Trim(); - await orders.UpdateStatusAsync(order.Id, target, tracking, now, ct); - order.Status = target; - order.TrackingNumber = tracking; - order.UpdatedAt = now; + order.TransitionTo(target, command.TrackingNumber, now); + await orders.UpdateStatusAsync(order.Id, order.Status, order.TrackingNumber, now, ct); try { diff --git a/src/WidgetWorks.Application/Pricing/OrderPricer.cs b/src/WidgetWorks.Application/Pricing/OrderPricer.cs new file mode 100644 index 0000000..4f48f26 --- /dev/null +++ b/src/WidgetWorks.Application/Pricing/OrderPricer.cs @@ -0,0 +1,45 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Carts; + +namespace WidgetWorks.Application.Pricing; + +/// A fully priced cart: the same numbers whether they are being previewed or charged. +public sealed record PricedCart( + decimal Subtotal, + string ShippingMethod, + decimal Shipping, + string StateCode, + decimal TaxRate, + decimal Tax, + decimal Total, + int ItemCount) +{ + public bool IsEmpty => ItemCount == 0; +} + +/// +/// The single place a cart turns into money. Quoting and checkout both go through it, so the total +/// a shopper is shown and the total they are charged are the same calculation rather than two +/// implementations that happen to agree today. +/// +public sealed class OrderPricer(IShippingCalculator shipping, ITaxCalculator tax) +{ + public PricedCart Price(CartView cart, string? stateCode, string? shippingMethod) + { + var quote = shipping.Calculate(shippingMethod, cart.Subtotal, cart.ItemCount); + + // Nothing in the basket, nothing to deliver -- don't quote a delivery charge on it. + var shippingAmount = cart.ItemCount == 0 ? 0m : quote.Amount; + var taxLine = tax.Calculate(stateCode, cart.Subtotal); + + return new PricedCart( + cart.Subtotal, + quote.Method, + shippingAmount, + taxLine.StateCode, + taxLine.Rate, + taxLine.Amount, + cart.Subtotal + shippingAmount + taxLine.Amount, + cart.ItemCount); + } +} diff --git a/src/WidgetWorks.Domain/Orders/Order.cs b/src/WidgetWorks.Domain/Orders/Order.cs index 94630ef..b48a574 100644 --- a/src/WidgetWorks.Domain/Orders/Order.cs +++ b/src/WidgetWorks.Domain/Orders/Order.cs @@ -15,6 +15,25 @@ public static class OrderStatus public const string Shipped = "Shipped"; public const string Delivered = "Delivered"; public const string Cancelled = "Cancelled"; + + /// + /// The fulfilment state machine. Only a settled (Paid) order can ship or be cancelled, and only + /// a shipped one can be delivered -- so an order still awaiting payment can never be dispatched. + /// Everything absent from this table is a forbidden transition, including any move out of a + /// terminal state. + /// + private static readonly Dictionary Transitions = new(StringComparer.Ordinal) + { + [Paid] = [Shipped, Cancelled], + [Shipped] = [Delivered], + }; + + /// The statuses an order in may legally move to. + public static IReadOnlyList AllowedNext(string? from) + => from is not null && Transitions.TryGetValue(from, out var next) ? next : []; + + public static bool CanTransition(string? from, string? to) + => to is not null && AllowedNext(from).Contains(to, StringComparer.Ordinal); } /// A placed order with a shipping address, computed totals, payment result, and line items. @@ -70,6 +89,28 @@ public sealed class Order public DateTimeOffset UpdatedAt { get; set; } public List Items { get; set; } = []; + + /// Number of units on the order (quantities summed, not lines counted). + public int UnitCount => Items.Sum(i => i.Quantity); + + public bool CanTransitionTo(string? target) => OrderStatus.CanTransition(Status, target); + + /// + /// Applies a fulfilment transition, keeping the rule with the data it guards. A blank tracking + /// number leaves the existing one alone rather than erasing it. Callers that want to report a + /// refusal rather than throw should ask first. + /// + public void TransitionTo(string target, string? trackingNumber, DateTimeOffset now) + { + if (!CanTransitionTo(target)) + { + throw new InvalidOperationException($"Cannot change status from {Status} to '{target}'."); + } + + Status = target; + TrackingNumber = string.IsNullOrWhiteSpace(trackingNumber) ? TrackingNumber : trackingNumber.Trim(); + UpdatedAt = now; + } } public sealed class OrderItem diff --git a/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs b/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs index b9f335e..c858236 100644 --- a/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs +++ b/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs @@ -13,6 +13,8 @@ public sealed class MockPaymentWebhookParser(IOptions option { public string Provider => "Mock"; + public IReadOnlyList SignatureHeaders { get; } = ["X-Webhook-Signature"]; + public bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error) { evt = null; diff --git a/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs b/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs index e15f7bc..ff15e33 100644 --- a/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs +++ b/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs @@ -15,6 +15,8 @@ public sealed class StripePaymentWebhookParser(IOptions options) { public string Provider => "Stripe"; + public IReadOnlyList SignatureHeaders { get; } = ["Stripe-Signature"]; + public bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error) { evt = null; diff --git a/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs b/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs index 30eefc7..4b503ca 100644 --- a/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs +++ b/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs @@ -29,7 +29,9 @@ public static void MapPaymentWebhookEndpoints(this IEndpointRouteBuilder routes) payload = await reader.ReadToEndAsync(ct); } - var signature = FirstHeader(request, "Stripe-Signature") ?? FirstHeader(request, "X-Webhook-Signature"); + var signature = parser.SignatureHeaders + .Select(name => FirstHeader(request, name)) + .FirstOrDefault(value => value is not null); if (!parser.TryParse(payload, signature, out var evt, out var error) || evt is null) { diff --git a/tests/WidgetWorks.UnitTests/CheckoutTests.cs b/tests/WidgetWorks.UnitTests/CheckoutTests.cs index 0931c3f..7bcec8b 100644 --- a/tests/WidgetWorks.UnitTests/CheckoutTests.cs +++ b/tests/WidgetWorks.UnitTests/CheckoutTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Time.Testing; using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Domain.Orders; using WidgetWorks.Infrastructure.Payments; @@ -33,8 +34,7 @@ private static async Task SetupAsync(int onHand = 10, decimal price = 10m, } private static CheckoutHandler Handler(Ctx c, MockPaymentGateway gateway, FakeEmailSender email) - => new(c.Carts, c.Widgets, c.Orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), gateway, email, Clock()); + => new(c.Carts, c.Widgets, c.Orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), gateway, email, Clock()); [Fact] public async Task Successful_checkout_pays_reserves_clears_cart_and_emails_receipt() @@ -113,8 +113,7 @@ public async Task Empty_cart_cannot_checkout() var carts = new InMemoryCartRepository(); var cart = await carts.CreateAsync(null, CancellationToken.None); var orders = new InMemoryOrderRepository(widgets); - var handler = new CheckoutHandler(carts, widgets, orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), new MockPaymentGateway(), new FakeEmailSender(), Clock()); + var handler = new CheckoutHandler(carts, widgets, orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), new MockPaymentGateway(), new FakeEmailSender(), Clock()); var result = await handler.Handle(new CheckoutCommand(cart.Id, null, "jane@example.com", Address(), "Standard", "tok_ok"), CancellationToken.None); diff --git a/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs b/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs index 4eaa7bf..e421d55 100644 --- a/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs +++ b/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs @@ -2,6 +2,7 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Checkout.ConfirmPayment; using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Domain.Orders; using WidgetWorks.Infrastructure.Payments; @@ -31,8 +32,7 @@ private sealed record Ctx(InMemoryWidgetRepository Widgets, InMemoryOrderReposit var orders = new InMemoryOrderRepository(widgets); var email = new FakeEmailSender(); - var handler = new CheckoutHandler(carts, widgets, orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), new MockPaymentGateway(), email, Clock()); + var handler = new CheckoutHandler(carts, widgets, orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), new MockPaymentGateway(), email, Clock()); var result = await handler.Handle( new CheckoutCommand(cart.Id, null, "jane@example.com", Address(), "Standard", "klarna_demo"), CancellationToken.None); diff --git a/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs b/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs new file mode 100644 index 0000000..5b45131 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs @@ -0,0 +1,248 @@ +using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Infrastructure.Pricing; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The fulfilment state machine, now owned by the order itself, and the pricer both quoting and +/// checkout share. The pricer is the interesting one: it exists so the total a shopper is shown and +/// the total they are charged cannot drift apart. +/// +public class OrderStateMachineTests +{ + private static readonly DateTimeOffset Now = new(2026, 6, 1, 10, 0, 0, TimeSpan.Zero); + + private static Order OrderIn(string status) => new() + { + Id = Guid.NewGuid(), + Status = status, + CreatedAt = Now.AddDays(-1), + UpdatedAt = Now.AddDays(-1), + }; + + // ---- allowed transitions ------------------------------------------------------------- + + [Theory] + [InlineData(OrderStatus.Paid, OrderStatus.Shipped)] + [InlineData(OrderStatus.Paid, OrderStatus.Cancelled)] + [InlineData(OrderStatus.Shipped, OrderStatus.Delivered)] + public void A_legal_transition_is_allowed(string from, string to) + { + Assert.True(OrderStatus.CanTransition(from, to)); + Assert.True(OrderIn(from).CanTransitionTo(to)); + } + + [Theory] + // Nothing ships before it is paid for -- including an order still settling. + [InlineData(OrderStatus.Pending, OrderStatus.Shipped)] + [InlineData(OrderStatus.AwaitingPayment, OrderStatus.Shipped)] + [InlineData(OrderStatus.AwaitingPayment, OrderStatus.Delivered)] + [InlineData(OrderStatus.PaymentFailed, OrderStatus.Shipped)] + // No skipping a step, and no going backwards. + [InlineData(OrderStatus.Paid, OrderStatus.Delivered)] + [InlineData(OrderStatus.Shipped, OrderStatus.Cancelled)] + [InlineData(OrderStatus.Shipped, OrderStatus.Paid)] + // Terminal states are terminal. + [InlineData(OrderStatus.Delivered, OrderStatus.Shipped)] + [InlineData(OrderStatus.Cancelled, OrderStatus.Paid)] + [InlineData(OrderStatus.Cancelled, OrderStatus.Shipped)] + // A status is not a transition to itself. + [InlineData(OrderStatus.Paid, OrderStatus.Paid)] + public void An_illegal_transition_is_refused(string from, string to) + { + Assert.False(OrderStatus.CanTransition(from, to)); + Assert.False(OrderIn(from).CanTransitionTo(to)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Shipped ")] + [InlineData("shipped")] + [InlineData("Nonsense")] + public void An_unrecognized_target_is_refused_rather_than_matched_loosely(string? target) + { + Assert.False(OrderIn(OrderStatus.Paid).CanTransitionTo(target)); + } + + [Fact] + public void Allowed_next_reports_the_options_for_a_status() + { + Assert.Equal([OrderStatus.Shipped, OrderStatus.Cancelled], OrderStatus.AllowedNext(OrderStatus.Paid)); + Assert.Equal([OrderStatus.Delivered], OrderStatus.AllowedNext(OrderStatus.Shipped)); + Assert.Empty(OrderStatus.AllowedNext(OrderStatus.Delivered)); + Assert.Empty(OrderStatus.AllowedNext("not-a-status")); + Assert.Empty(OrderStatus.AllowedNext(null)); + } + + // ---- applying a transition ----------------------------------------------------------- + + [Fact] + public void Transitioning_sets_the_status_tracking_and_timestamp() + { + var order = OrderIn(OrderStatus.Paid); + + order.TransitionTo(OrderStatus.Shipped, " 1Z999AA10123456784 ", Now); + + Assert.Equal(OrderStatus.Shipped, order.Status); + Assert.Equal("1Z999AA10123456784", order.TrackingNumber); + Assert.Equal(Now, order.UpdatedAt); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Transitioning_without_a_tracking_number_keeps_the_existing_one(string? tracking) + { + var order = OrderIn(OrderStatus.Shipped); + order.TrackingNumber = "1Z-ORIGINAL"; + + order.TransitionTo(OrderStatus.Delivered, tracking, Now); + + // Marking delivered must not wipe the number the customer is tracking with. + Assert.Equal("1Z-ORIGINAL", order.TrackingNumber); + } + + [Fact] + public void Transitioning_illegally_throws_and_changes_nothing() + { + var order = OrderIn(OrderStatus.AwaitingPayment); + + var ex = Assert.Throws( + () => order.TransitionTo(OrderStatus.Shipped, "1Z-NEW", Now)); + + Assert.Contains("AwaitingPayment", ex.Message); + Assert.Equal(OrderStatus.AwaitingPayment, order.Status); + Assert.Null(order.TrackingNumber); + Assert.Equal(Now.AddDays(-1), order.UpdatedAt); + } + + [Fact] + public void Unit_count_sums_quantities_rather_than_counting_lines() + { + var order = OrderIn(OrderStatus.Paid); + order.Items = + [ + new OrderItem { Quantity = 2 }, + new OrderItem { Quantity = 3 }, + ]; + + Assert.Equal(5, order.UnitCount); + Assert.Equal(0, OrderIn(OrderStatus.Paid).UnitCount); + } + + // ---- the shared pricer --------------------------------------------------------------- + + private static readonly OrderPricer Pricer = new( + new FlatRateShippingCalculator(), + new StateSalesTaxCalculator(new StaticStateTaxRateProvider())); + + private static CartView Cart(decimal subtotal, int itemCount) => + new(Guid.NewGuid(), null, [], subtotal, itemCount); + + [Fact] + public void Pricing_adds_shipping_and_tax_to_the_subtotal() + { + var priced = Pricer.Price(Cart(20m, 2), "CA", "Standard"); + + Assert.Equal(20m, priced.Subtotal); + Assert.Equal(7.74m, priced.Shipping); // 6.99 + one extra item at 0.75 + Assert.Equal(0.0725m, priced.TaxRate); + Assert.Equal(1.45m, priced.Tax); // 20 * 0.0725 + Assert.Equal(29.19m, priced.Total); + Assert.False(priced.IsEmpty); + } + + [Fact] + public void Tax_is_charged_on_the_subtotal_only_never_on_shipping() + { + var standard = Pricer.Price(Cart(20m, 1), "CA", "Standard"); + var express = Pricer.Price(Cart(20m, 1), "CA", "Express"); + + Assert.NotEqual(standard.Shipping, express.Shipping); + Assert.Equal(standard.Tax, express.Tax); + } + + [Theory] + [InlineData("OR")] + [InlineData("AK")] + [InlineData("DE")] + [InlineData("MT")] + [InlineData("NH")] + [InlineData("XX")] + [InlineData("")] + [InlineData(null)] + public void A_state_with_no_rate_costs_no_tax(string? state) + { + var priced = Pricer.Price(Cart(100m, 1), state, "Standard"); + + Assert.Equal(0m, priced.Tax); + Assert.Equal(0m, priced.TaxRate); + Assert.Equal(100m, priced.Total); // free shipping over 75, no tax + } + + [Fact] + public void An_empty_cart_is_not_charged_for_delivery() + { + var priced = Pricer.Price(Cart(0m, 0), "CA", "Express"); + + Assert.True(priced.IsEmpty); + Assert.Equal(0m, priced.Shipping); + Assert.Equal(0m, priced.Total); + } + + [Fact] + public void The_quote_and_the_charge_are_the_same_calculation() + { + // Same inputs through the one component both paths use: they cannot drift. + var cart = Cart(89.97m, 3); + + var shown = Pricer.Price(cart, "CA", "Standard"); + var charged = Pricer.Price(cart, "CA", "Standard"); + + Assert.Equal(shown, charged); + Assert.Equal(0m, shown.Shipping); // over the free threshold + Assert.Equal(6.52m, shown.Tax); // round(89.97 * 0.0725) + Assert.Equal(96.49m, shown.Total); + } + + // ---- order drafting ------------------------------------------------------------------ + + [Fact] + public void An_order_number_is_dated_and_short_enough_to_read_out() + { + var id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000000"); + + var number = OrderDraft.NumberFor(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero), id); + + Assert.Equal("WW-20260501-A1B2C3", number); + } + + [Fact] + public void A_drafted_order_snapshots_the_price_and_normalizes_the_address() + { + var cart = new CartView(Guid.NewGuid(), null, + [new CartLineView(Guid.NewGuid(), "WW-1", "Standard Widget", 10m, 2, 5, 20m)], 20m, 2); + var priced = Pricer.Price(cart, "ca", "Standard"); + var address = new ShippingAddressInput(" Jane Doe ", " 1 Main St ", " ", " Springfield ", " ca ", " 90210 ", null); + + var order = OrderDraft.Create(cart, priced, address, "jane@example.com", null, Now, Guid.NewGuid(), Guid.NewGuid); + + Assert.Equal("Jane Doe", order.ShipName); + Assert.Equal("1 Main St", order.ShipLine1); + Assert.Null(order.ShipLine2); // whitespace-only becomes absent, not blank + Assert.Equal("CA", order.ShipState); + Assert.Equal("US", order.ShipCountry); // defaulted + Assert.Equal(OrderStatus.Pending, order.Status); + Assert.Equal(priced.Tax, order.Tax); + Assert.Equal(priced.TaxRate, order.TaxRate); + Assert.Equal(priced.Total, order.Total); + Assert.Equal(2, order.UnitCount); + Assert.Equal(Now, order.CreatedAt); + } +} diff --git a/tests/WidgetWorks.UnitTests/PricingTests.cs b/tests/WidgetWorks.UnitTests/PricingTests.cs index d29b2e0..d036d37 100644 --- a/tests/WidgetWorks.UnitTests/PricingTests.cs +++ b/tests/WidgetWorks.UnitTests/PricingTests.cs @@ -1,4 +1,5 @@ using WidgetWorks.Application.Checkout.Quote; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Infrastructure.Pricing; using WidgetWorks.UnitTests.Fakes; @@ -73,7 +74,7 @@ public async Task Quote_assembles_subtotal_shipping_tax_total() var cart = await carts.CreateAsync(null, CancellationToken.None); await carts.UpsertItemAsync(cart.Id, widget.Id, 2, default, CancellationToken.None); - var handler = new QuoteCartHandler(carts, widgets, _shipping, _tax); + var handler = new QuoteCartHandler(carts, widgets, new OrderPricer(_shipping, _tax)); var result = await handler.Handle(new QuoteCartCommand(cart.Id, "CA", "Standard"), CancellationToken.None); Assert.True(result.IsSuccess); @@ -91,7 +92,7 @@ public async Task Quote_for_empty_cart_is_all_zero() var carts = new InMemoryCartRepository(); var cart = await carts.CreateAsync(null, CancellationToken.None); - var handler = new QuoteCartHandler(carts, widgets, _shipping, _tax); + var handler = new QuoteCartHandler(carts, widgets, new OrderPricer(_shipping, _tax)); var result = await handler.Handle(new QuoteCartCommand(cart.Id, "CA", "Standard"), CancellationToken.None); Assert.True(result.IsSuccess); From 857f0b2de9318ee98583260c5463bccd78dbf62b Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:17:52 -0500 Subject: [PATCH 3/9] test(web): add component tests and coverage reporting The frontend had 21 tests across two pure modules and no way to render anything: no @testing-library, no jsdom, no coverage tooling. A component that threw on mount, a conditional that stopped rendering, or a confirm dialog that stopped confirming would all have shipped green. Adds jsdom, Testing Library, and v8 coverage, plus 39 tests over the paths where a silent break costs the most: - ProtectedRoute, every combination of signed-in/staff/role -- including the half-written session (refresh token, no role) that must not open an admin screen, and the deliberate difference between bouncing to /login and bouncing to /store. - AdminWidgetsPage delete: nothing is sent before confirmation, cancelling sends nothing at all, a Manager is never shown the control, and an archive is reported differently from a delete because the outcomes differ. - CheckoutPage: totals come from the server and are re-fetched when the state or shipping method changes, the picked payment method is the token actually submitted, and a decline leaves the shopper on the page with the reason rather than stranding them. - AddToCartButton: the busy guard that stops a double-click ordering two. - DemoGuidePage: the "no payment is ever taken" reassurance, all three credentials, and the role differences -- the claims a reviewer would call a lie if they silently vanished. jsdom ships without showModal/close, so the setup file supplies them; without that every modal-based component throws on mount. Frontend now 60 tests, 36.4% statements measured (was unmeasured). Co-Authored-By: Claude Opus 5 --- web/package-lock.json | 815 ++++++++++++++++++ web/package.json | 4 + web/src/components/AddToCartButton.test.tsx | 129 +++ web/src/components/ProtectedRoute.test.tsx | 62 ++ web/src/pages/CheckoutPage.test.tsx | 200 +++++ web/src/pages/DemoGuidePage.test.tsx | 81 ++ web/src/pages/admin/AdminWidgetsPage.test.tsx | 140 +++ web/src/test/render.tsx | 71 ++ web/src/test/setup.ts | 27 + web/vitest.config.ts | 22 +- 10 files changed, 1547 insertions(+), 4 deletions(-) create mode 100644 web/src/components/AddToCartButton.test.tsx create mode 100644 web/src/components/ProtectedRoute.test.tsx create mode 100644 web/src/pages/CheckoutPage.test.tsx create mode 100644 web/src/pages/DemoGuidePage.test.tsx create mode 100644 web/src/pages/admin/AdminWidgetsPage.test.tsx create mode 100644 web/src/test/render.tsx create mode 100644 web/src/test/setup.ts diff --git a/web/package-lock.json b/web/package-lock.json index cfcd933..3fd0f6a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,15 +13,75 @@ "react-router-dom": "^7.18.2" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^30.0.1", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -58,6 +118,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -82,6 +152,177 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -372,6 +613,113 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -595,6 +943,41 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -624,6 +1007,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -654,6 +1047,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -661,6 +1075,52 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -671,6 +1131,27 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -741,6 +1222,19 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -748,6 +1242,23 @@ "dev": true, "license": "MIT" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -793,6 +1304,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1066,6 +1618,27 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1104,6 +1677,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1137,6 +1727,19 @@ "node": ">=12.20.0" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1193,6 +1796,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1218,6 +1847,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -1256,6 +1893,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", @@ -1289,6 +1950,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1348,6 +2022,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1361,6 +2048,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1405,6 +2099,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1419,6 +2159,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", @@ -1587,6 +2337,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1603,6 +2401,23 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/web/package.json b/web/package.json index 54dd714..ae43d9e 100644 --- a/web/package.json +++ b/web/package.json @@ -17,10 +17,14 @@ "react-router-dom": "^7.18.2" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^30.0.1", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" diff --git a/web/src/components/AddToCartButton.test.tsx b/web/src/components/AddToCartButton.test.tsx new file mode 100644 index 0000000..5fcfa8b --- /dev/null +++ b/web/src/components/AddToCartButton.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AddToCartButton } from './AddToCartButton' +import { renderWithProviders, stubFetch } from '../test/render' + +/** + * The control every product surface shares. Its whole reason to exist is feedback while the + * request is in flight, so the states — idle, busy, done, error — are what get asserted, along + * with the guard that stops a double-click becoming two line items. + */ +describe('AddToCartButton', () => { + const cart = { + id: 'cart-1', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 1, quantityAvailable: 4, lineSubtotal: 10 }], + subtotal: 10, + itemCount: 1, + } + + it('adds the widget and reports success', async () => { + const calls = stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + expect(await screen.findByRole('button', { name: '✓ Added to cart' })).toBeInTheDocument() + const post = calls.find((c) => c.init?.method === 'POST') + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ widgetId: 'w-1', quantity: 1 }) + }) + + it('sends the quantity it was given', async () => { + const calls = stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await waitFor(() => { + const post = calls.find((c) => c.init?.method === 'POST') + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ quantity: 3 }) + }) + }) + + it('ignores a second click while the first is still in flight', async () => { + let release: (() => void) | null = null + const gate = new Promise((resolve) => { release = resolve }) + + vi.stubGlobal('fetch', vi.fn(async () => { + await gate + return new Response(JSON.stringify(cart), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + const user = userEvent.setup() + + renderWithProviders() + const button = screen.getByRole('button') + await user.click(button) + + // Disabled while busy: an impatient double-click must not order two. + expect(await screen.findByRole('button', { name: 'Adding…' })).toBeDisabled() + await user.click(button) + + release?.() + await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)) + }) + + it('shows the reason when the API refuses', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Only 2 left in stock.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + expect(await screen.findByText('Only 2 left in stock.')).toBeInTheDocument() + }) + + it('is inert and self-explanatory when out of stock', async () => { + stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + const button = screen.getByRole('button', { name: 'Out of stock' }) + + expect(button).toBeDisabled() + await user.click(button) + expect(screen.queryByText('Adding…')).not.toBeInTheDocument() + }) + + it('honours an explicit disabled without claiming to be out of stock', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Add to cart' })).toBeDisabled() + }) + + it('uses a caller-supplied label', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Buy now' })).toBeInTheDocument() + }) + + it('notifies the caller after a successful add', async () => { + stubFetch([['/cart', () => cart]]) + const onAdded = vi.fn() + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await waitFor(() => expect(onAdded).toHaveBeenCalledOnce()) + }) + + it('does not notify the caller when the add failed', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'nope' }), { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const onAdded = vi.fn() + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await screen.findByText('nope') + expect(onAdded).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/ProtectedRoute.test.tsx b/web/src/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..e021757 --- /dev/null +++ b/web/src/components/ProtectedRoute.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { ProtectedRoute } from './ProtectedRoute' +import { renderWithProviders, signIn } from '../test/render' + +/** + * The client-side half of authorization. It is not the security boundary — the API enforces the + * real thing — but a hole here shows a signed-out visitor an admin screen, which is its own kind + * of broken. Every combination of (signed in?, staff route?, role) is checked. + */ +describe('ProtectedRoute', () => { + const Secret =

Order history

+ const routes = { + '/login':

Sign in

, + '/store':

Storefront

, + } + + it('sends a signed-out visitor to the sign-in page', () => { + renderWithProviders({Secret}, { at: '/orders', routes }) + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByText('Order history')).not.toBeInTheDocument() + }) + + it('lets a signed-in customer through an ordinary protected route', () => { + signIn('Customer') + renderWithProviders({Secret}, { at: '/orders', routes }) + + expect(screen.getByRole('heading', { name: 'Order history' })).toBeInTheDocument() + }) + + it('bounces a customer off a staff route to the store, not to sign-in', () => { + signIn('Customer') + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + // Signed in but not permitted: sending them to /login would be a confusing dead end. + expect(screen.getByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(screen.queryByText('Order history')).not.toBeInTheDocument() + }) + + it.each(['Manager', 'Administrator'] as const)('lets a %s into a staff route', (role) => { + signIn(role) + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Order history' })).toBeInTheDocument() + }) + + it('sends a signed-out visitor to sign-in even for a staff route', () => { + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + }) + + it('treats a session with a token but no role as not staff', () => { + // Half-written session: a refresh token without a role must not open admin screens. + localStorage.setItem('ww.refreshToken', 'refresh-token-for-tests') + + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/CheckoutPage.test.tsx b/web/src/pages/CheckoutPage.test.tsx new file mode 100644 index 0000000..7795a76 --- /dev/null +++ b/web/src/pages/CheckoutPage.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CheckoutPage } from './CheckoutPage' +import { renderWithProviders, stubFetch, useCartId } from '../test/render' + +/** + * The screen where a mistake costs money. Three things are worth guarding: the totals shown come + * from the server and are re-fetched when the inputs that change them change; the selected payment + * method is the one actually submitted; and a decline leaves the shopper on the page with the + * reason, rather than dropping them somewhere with an empty cart. + */ +describe('CheckoutPage', () => { + const cart = { + id: 'cart-1', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, quantityAvailable: 5, lineSubtotal: 20 }], + subtotal: 20, + itemCount: 2, + } + + const quoteFor = (state: string, method: string) => ({ + subtotal: 20, + shippingMethod: method, + shipping: method === 'Express' ? 21.49 : 7.74, + stateCode: state, + taxRate: state === 'CA' ? 0.0725 : 0, + tax: state === 'CA' ? 1.45 : 0, + total: 20 + (method === 'Express' ? 21.49 : 7.74) + (state === 'CA' ? 1.45 : 0), + itemCount: 2, + isEmpty: false, + }) + + function stubCheckout(onCheckout?: (init?: RequestInit) => unknown) { + return stubFetch([ + ['/checkout/quote', (init) => { + const body = JSON.parse(String(init?.body)) + return quoteFor(body.stateCode, body.shippingMethod) + }], + ['/checkout', onCheckout ?? (() => ({ + orderNumber: 'WW-20260501-ABC123', + orderId: 'o-1', + status: 'Paid', + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + }))], + [`/cart/cart-1`, () => cart], + ]) + } + + // The page offers the same submit twice — inline under the form and in the sticky summary. + const placeOrderButton = () => screen.getAllByRole('button', { name: /Place your order/i })[0] + + async function fillAddress(user: ReturnType) { + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.type(screen.getByLabelText('Full name'), 'Jane Doe') + await user.type(screen.getByLabelText('Address line 1'), '1 Main St') + await user.type(screen.getByLabelText('City'), 'Springfield') + await user.type(screen.getByLabelText('ZIP code'), '90210') + } + + it('shows the server-calculated totals rather than adding up in the browser', async () => { + useCartId('cart-1') + stubCheckout() + + renderWithProviders(, { at: '/checkout' }) + + expect(await screen.findByText('$29.19')).toBeInTheDocument() // 20 + 7.74 + 1.45 + expect(screen.getByText(/7\.25% CA/)).toBeInTheDocument() + expect(screen.getByText('$1.45')).toBeInTheDocument() + }) + + it('re-quotes when the destination state changes', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout' }) + await screen.findByText('$29.19') + + await user.selectOptions(screen.getByLabelText('State'), 'OR') + + // Oregon has no sales tax, so the total must drop — and it must come from a new quote call. + await waitFor(() => expect(screen.getByText('$27.74')).toBeInTheDocument()) + const quotes = calls.filter((c) => c.url.includes('/checkout/quote')) + expect(quotes.length).toBeGreaterThan(1) + expect(JSON.parse(String(quotes.at(-1)?.body ?? quotes.at(-1)?.init?.body))).toMatchObject({ stateCode: 'OR' }) + }) + + it('re-quotes when the shipping method changes', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout' }) + await screen.findByText('$29.19') + + await user.click(screen.getByLabelText(/Express shipping/)) + + await waitFor(() => expect(screen.getByText('$42.94')).toBeInTheDocument()) + expect(calls.filter((c) => c.url.includes('/checkout/quote')).length).toBeGreaterThan(1) + }) + + it('submits the token for the payment method the shopper picked', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(screen.getByLabelText(/Klarna/)) + await user.click(placeOrderButton()) + + await waitFor(() => { + const order = calls.find((c) => c.init?.method === 'POST' && c.url.endsWith('/checkout')) + expect(JSON.parse(String(order?.init?.body))).toMatchObject({ + paymentToken: 'klarna_demo', + email: 'jane@example.com', + state: 'CA', + }) + }) + }) + + it('defaults to the card token when nothing is picked', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(placeOrderButton()) + + await waitFor(() => { + const order = calls.find((c) => c.init?.method === 'POST' && c.url.endsWith('/checkout')) + expect(JSON.parse(String(order?.init?.body))).toMatchObject({ paymentToken: 'tok_visa_ok' }) + }) + }) + + it('moves to the confirmation page once the order is placed', async () => { + useCartId('cart-1') + stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(placeOrderButton()) + + expect(await screen.findByRole('heading', { name: 'Thank you' })).toBeInTheDocument() + }) + + it('keeps the shopper on the page with the reason when payment is declined', async () => { + useCartId('cart-1') + stubFetch([ + ['/checkout/quote', (init) => { + const body = JSON.parse(String(init?.body)) + return quoteFor(body.stateCode, body.shippingMethod) + }], + ['/cart/cart-1', () => cart], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + await fillAddress(user) + + // Swap in a declining gateway only for the order call. + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/checkout') && init?.method === 'POST') { + return new Response(JSON.stringify({ error: 'Your card was declined.' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify(quoteFor('CA', 'Standard')), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + + await user.click(placeOrderButton()) + + expect(await screen.findByText('Your card was declined.')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Thank you' })).not.toBeInTheDocument() + }) + + it('offers nothing to check out when the cart is empty', async () => { + stubFetch([['/cart/', () => ({ ...cart, items: [], itemCount: 0, subtotal: 0 })]]) + + renderWithProviders(, { at: '/checkout' }) + + expect(await screen.findByText('There is nothing to check out')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Place your order/i })).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/DemoGuidePage.test.tsx b/web/src/pages/DemoGuidePage.test.tsx new file mode 100644 index 0000000..91c9df6 --- /dev/null +++ b/web/src/pages/DemoGuidePage.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DemoGuidePage } from './DemoGuidePage' +import { renderWithProviders } from '../test/render' + +/** + * The page everyone lands on. Its job is not decorative: it has to state plainly that no money + * changes hands, hand over working credentials for all three roles, and say what each role can do. + * If the reassurance or a credential silently disappears, the demo becomes untrustworthy — so + * they are asserted rather than eyeballed. + */ +describe('DemoGuidePage', () => { + const render = () => renderWithProviders(, { + at: '/', + routes: { '/store':

Storefront

}, + }) + + it('states up front that no payment is ever taken', () => { + render() + + expect(screen.getByText(/No payment is ever taken/i)).toBeInTheDocument() + expect(screen.getByText(/mock payment gateway/i)).toBeInTheDocument() + expect(screen.getByText(/test mode/i)).toBeInTheDocument() + }) + + it('publishes all three demo accounts', () => { + render() + + for (const email of ['demo@widgetworks.demo', 'manager@widgetworks.demo', 'admin@widgetworks.demo']) { + expect(screen.getByText(email)).toBeInTheDocument() + } + + expect(screen.getByRole('heading', { name: 'Customer' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Manager' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Administrator' })).toBeInTheDocument() + }) + + it('explains what separates the roles, not just that they exist', () => { + render() + + // The distinction a reviewer is most likely to probe. + expect(screen.getByText(/Delete or retire a widget/i)).toBeInTheDocument() + expect(screen.getByText(/Manage users/i)).toBeInTheDocument() + }) + + it('copies a credential to the clipboard and confirms it did', async () => { + // user-event installs its own clipboard, so this goes through the real navigator API. + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button', { name: 'Copy' })[0]) + + expect(await navigator.clipboard.readText()).toBe('demo@widgetworks.demo') + await waitFor(() => expect(screen.getByRole('button', { name: '✓ Copied' })).toBeInTheDocument()) + }) + + it('survives a browser that refuses clipboard access', async () => { + const user = userEvent.setup() + render() + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')) + + await user.click(screen.getAllByRole('button', { name: 'Copy' })[0]) + + // No crash, and the value stays on screen so it can be typed instead. + expect(screen.getByText('demo@widgetworks.demo')).toBeInTheDocument() + }) + + it('points at the store and at the order history for receipts', () => { + render() + + expect(screen.getAllByRole('link', { name: /Enter the store/i }).length).toBeGreaterThan(0) + expect(screen.getByRole('link', { name: /Your orders/i })).toBeInTheDocument() + }) + + it('explains where the email goes instead of an inbox', () => { + render() + + expect(screen.getByText(/written to the application log/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/admin/AdminWidgetsPage.test.tsx b/web/src/pages/admin/AdminWidgetsPage.test.tsx new file mode 100644 index 0000000..af7ac0b --- /dev/null +++ b/web/src/pages/admin/AdminWidgetsPage.test.tsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AdminWidgetsPage } from './AdminWidgetsPage' +import { renderWithProviders, signIn, stubFetch } from '../../test/render' + +/** + * Catalog administration. The delete path is the one that can destroy data, so what matters is + * that nothing is sent before the confirmation, that cancelling sends nothing at all, and that a + * Manager is never shown the control in the first place. + */ +describe('AdminWidgetsPage', () => { + const widget = { + id: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + description: 'A dependable widget.', + imageUrl: null, + price: 12.5, + quantityOnHand: 10, + quantityReserved: 0, + quantityAvailable: 10, + isActive: true, + } + + const catalog = () => ({ items: [widget], page: 1, pageSize: 100, total: 1 }) + + it('lists the catalog it loads', async () => { + signIn('Administrator') + stubFetch([['/admin/catalog/widgets', catalog]]) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('WW-001')).toBeInTheDocument() + }) + + it('asks before deleting, and sends nothing until confirmed', async () => { + signIn('Administrator') + const calls = stubFetch([['/admin/catalog/widgets', catalog]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + + // The dialog names the widget and explains the two outcomes before anything happens. + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText(/Delete this widget\?/)).toBeInTheDocument() + expect(within(dialog).getByText(/archived/)).toBeInTheDocument() + expect(calls.some((c) => c.init?.method === 'DELETE')).toBe(false) + }) + + it('cancelling closes the dialog without touching the API', async () => { + signIn('Administrator') + const calls = stubFetch([['/admin/catalog/widgets', catalog]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(screen.queryByText('Delete this widget?')).not.toBeVisible()) + expect(calls.some((c) => c.init?.method === 'DELETE')).toBe(false) + expect(screen.getByText('Standard Widget')).toBeInTheDocument() + }) + + it('confirming deletes and reports that it was removed outright', async () => { + signIn('Administrator') + const calls = stubFetch([ + ['/admin/catalog/widgets/w-1', () => ({ outcome: 'Deleted' })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Delete widget' })) + + await waitFor(() => expect(screen.getByText(/was deleted/)).toBeInTheDocument()) + expect(screen.getByText(/no order history/)).toBeInTheDocument() + + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('/admin/catalog/widgets/w-1') + }) + + it('reports an archive differently from a delete, because the outcome differs', async () => { + signIn('Administrator') + stubFetch([ + ['/admin/catalog/widgets/w-1', () => ({ outcome: 'Archived', orderLineCount: 3 })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Delete widget' })) + + // Staff need to know the widget still exists behind past orders, and on how many. + await waitFor(() => expect(screen.getByText(/archived instead/)).toBeInTheDocument()) + expect(screen.getByText(/3 order lines/)).toBeInTheDocument() + }) + + it('does not offer delete to a manager', async () => { + signIn('Manager') + stubFetch([['/admin/catalog/widgets', catalog]]) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete Standard Widget' })).not.toBeInTheDocument() + }) + + it('shows the API error instead of failing silently', async () => { + signIn('Administrator') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Catalog unavailable.' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText(/Catalog unavailable/)).toBeInTheDocument() + }) + + it('adjusting stock posts the delta', async () => { + signIn('Administrator') + const calls = stubFetch([ + ['/inventory', () => ({ ...widget, quantityOnHand: 20, quantityAvailable: 20 })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Add 10 to Standard Widget' })) + + await waitFor(() => expect(calls.some((c) => c.url.includes('/inventory'))).toBe(true)) + const call = calls.find((c) => c.url.includes('/inventory')) + expect(JSON.parse(String(call?.init?.body))).toMatchObject({ quantityOnHandDelta: 10 }) + }) +}) diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx new file mode 100644 index 0000000..469e11c --- /dev/null +++ b/web/src/test/render.tsx @@ -0,0 +1,71 @@ +import { render, type RenderResult } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import type { ReactElement, ReactNode } from 'react' +import { vi } from 'vitest' +import { AuthProvider } from '../auth/AuthContext' +import { CartProvider } from '../cart/CartContext' + +export const REFRESH_KEY = 'ww.refreshToken' +export const ROLE_KEY = 'ww.role' +export const CART_KEY = 'ww.cartId' + +/** Puts a signed-in session in storage, the way a real login would. */ +export function signIn(role: 'Customer' | 'Manager' | 'Administrator' = 'Customer') { + localStorage.setItem(REFRESH_KEY, 'refresh-token-for-tests') + localStorage.setItem(ROLE_KEY, role) +} + +export function useCartId(id: string) { + localStorage.setItem(CART_KEY, id) +} + +/** + * Renders a component inside the providers it expects, on a memory router so navigation is + * observable without a browser. `at` sets the starting URL; any route in `routes` renders a + * marker so a redirect can be asserted by what lands on screen. + */ +export function renderWithProviders( + ui: ReactElement, + { at = '/', routes = {} as Record } = {}, +): RenderResult { + return render( + + + + + + {Object.entries(routes).map(([path, element]) => ( + + ))} + + + + , + ) +} + +/** + * Stubs fetch with a table of [url fragment, responder] pairs. Anything unmatched fails loudly + * rather than returning undefined — a silent 200-with-nothing hides more bugs than it catches. + */ +export function stubFetch(routes: Array<[string, (init?: RequestInit) => unknown]>) { + const calls: Array<{ url: string; init?: RequestInit }> = [] + + const impl = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + calls.push({ url, init }) + const match = routes.find(([fragment]) => url.includes(fragment)) + if (!match) { + throw new Error(`Unstubbed request: ${init?.method ?? 'GET'} ${url}`) + } + + const body = match[1](init) + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + vi.stubGlobal('fetch', vi.fn(impl)) + return calls +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..915f09c --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +// jsdom ships without its behaviour: showModal/close are absent, so any component +// built on the native modal throws on mount. Minimal stand-in — enough for `open` to reflect +// reality, which is what assertions look at. +if (typeof HTMLDialogElement !== 'undefined' && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { + this.open = true + } + HTMLDialogElement.prototype.show = function show(this: HTMLDialogElement) { + this.open = true + } + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { + this.open = false + this.dispatchEvent(new Event('close')) + } +} + +// Every test gets a clean document and clean storage; a token leaked from one test quietly +// changing another test's auth state is the classic way a suite starts lying. +afterEach(() => { + cleanup() + localStorage.clear() + sessionStorage.clear() +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e3da676..9a6e8c5 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,14 +1,28 @@ import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' // Kept separate from vite.config.ts so a production build never needs vitest // installed (e.g. `npm ci --omit=dev` followed by `npm run build`). export default defineConfig({ + plugins: [react()], test: { - // The suites under test are plain modules — no DOM required, so we skip - // jsdom and stub the couple of browser globals the API client touches. - environment: 'node', - include: ['src/**/*.test.ts'], + // jsdom for the component suites; the pure-module suites don't care either way. + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + setupFiles: ['src/test/setup.ts'], restoreMocks: true, unstubGlobals: true, + coverage: { + provider: 'v8', + reporter: ['text-summary', 'json-summary', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + // Composition root and declaration-only modules: nothing to assert. + 'src/main.tsx', + 'src/api/types.ts', + ], + }, }, }) From 56b52fbe7f36a3a873d4015031b871494ced7d4f Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:25:25 -0500 Subject: [PATCH 4/9] test(web): cover the remaining screens Fifty-five more component tests, taking the frontend from 36% statements to 86% (89.5% lines). What they actually guard: - LoginPage: the two-step branch stores nothing until the code is verified, a wrong code keeps the challenge so a second attempt needs no fresh password, and a guest cart merges into the account on the way in -- but a merge failure never undoes an accepted sign-in. - Layout: the admin entry point is absent for a visitor and for a customer, present for a Manager and an Administrator. Role leaks show up in the chrome first. - Cart: quantity controls send an absolute quantity, not a delta. - AdminOrderPage: the status POST carries the typed tracking number, and null rather than an empty string when none was entered; a refused transition shows the API's reason. - Storefront and product pages, including the states nobody looks at -- out of stock, nothing matched, catalog down, widget not found. - OrderConfirmationPage: settling an awaiting-payment order posts the right reference to the webhook, and a rejected webhook is reported rather than shown as success. - Password reset: the token comes from the query string, submission is blocked without one, and a forgotten-password request answers identically whether or not the address exists -- the account-enumeration guard, now asserted. - OrderDetailPage as the receipt: every money line, the tracking number, and the print path. Two test-harness gaps fixed along the way: renderWithProviders can seed router location state (how the confirmation page receives its order) and take a route pattern separate from the URL, without which any :param page rendered blank and silently tested nothing. Co-Authored-By: Claude Opus 5 --- web/src/components/Layout.test.tsx | 72 +++++++ web/src/pages/AccountPages.test.tsx | 136 ++++++++++++ web/src/pages/CartAndOrders.test.tsx | 284 +++++++++++++++++++++++++ web/src/pages/LoginPage.test.tsx | 174 +++++++++++++++ web/src/pages/StorefrontPages.test.tsx | 193 +++++++++++++++++ web/src/test/render.tsx | 10 +- 6 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 web/src/components/Layout.test.tsx create mode 100644 web/src/pages/AccountPages.test.tsx create mode 100644 web/src/pages/CartAndOrders.test.tsx create mode 100644 web/src/pages/LoginPage.test.tsx create mode 100644 web/src/pages/StorefrontPages.test.tsx diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx new file mode 100644 index 0000000..2ac6c03 --- /dev/null +++ b/web/src/components/Layout.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Layout } from './Layout' +import { renderWithProviders, signIn, stubFetch, REFRESH_KEY } from '../test/render' + +/** + * The chrome every page sits in. It is where role leaks would show first: an Admin link visible + * to a customer, or an order-history link offered to someone with no session. + */ +describe('Layout', () => { + const render = () => renderWithProviders(, { at: '/store' }) + + it('offers sign-in and no account links to a visitor', () => { + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getAllByRole('link', { name: /Sign in/i }).length).toBeGreaterThan(0) + expect(screen.queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument() + }) + + it('does not show the admin entry point to a customer', () => { + signIn('Customer') + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument() + }) + + it.each(['Manager', 'Administrator'] as const)('shows the admin entry point to a %s', (role) => { + signIn(role) + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getByRole('link', { name: /Admin/i })).toBeInTheDocument() + }) + + it('signing out clears the stored session', async () => { + signIn('Customer') + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Sign out' })) + + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + expect(screen.getAllByRole('link', { name: /Sign in/i }).length).toBeGreaterThan(0) + }) + + it('announces the cart count for screen readers, singular and plural', async () => { + stubFetch([['/cart', () => ({ + id: 'c', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'W', unitPrice: 1, quantity: 1, quantityAvailable: 5, lineSubtotal: 1 }], + subtotal: 1, + itemCount: 1, + })]]) + localStorage.setItem('ww.cartId', 'c') + render() + + expect(await screen.findByRole('link', { name: 'Cart, 1 item' })).toBeInTheDocument() + }) + + it('links back to the demo guide from the promo bar', () => { + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getByRole('link', { name: /read the guide/i })).toHaveAttribute('href', '/') + }) +}) diff --git a/web/src/pages/AccountPages.test.tsx b/web/src/pages/AccountPages.test.tsx new file mode 100644 index 0000000..e6cf37c --- /dev/null +++ b/web/src/pages/AccountPages.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ForgotPasswordPage } from './ForgotPasswordPage' +import { ResetPasswordPage } from './ResetPasswordPage' +import { OrderDetailPage } from './OrderDetailPage' +import { renderWithProviders, signIn, stubFetch } from '../test/render' + +describe('ForgotPasswordPage', () => { + it('sends the address and confirms without revealing whether it exists', async () => { + const calls = stubFetch([['/auth/forgot-password', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/forgot-password' }) + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.click(screen.getByRole('button', { name: /Send|Reset|Email/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/forgot-password')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ email: 'jane@example.com' }) + }) + }) + + it('answers identically for an unknown address', async () => { + // Account enumeration guard: a failure must look exactly like a success. + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'no such user' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/forgot-password' }) + await user.type(screen.getByLabelText('Email address'), 'nobody@example.com') + await user.click(screen.getByRole('button', { name: /Send|Reset|Email/i })) + + await waitFor(() => expect(screen.queryByText(/no such user/i)).not.toBeInTheDocument()) + }) +}) + +describe('ResetPasswordPage', () => { + it('refuses to submit without a token in the link', () => { + renderWithProviders(, { at: '/reset-password' }) + + expect(screen.getByRole('button', { name: /Reset|Save|Change/i })).toBeDisabled() + }) + + it('posts the token from the query string with the new password', async () => { + const calls = stubFetch([['/auth/reset-password', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/reset-password?token=tok-123', path: '/reset-password' }) + await user.type(screen.getByLabelText(/New password/), 'a-brand-new-password') + await user.click(screen.getByRole('button', { name: /Reset|Save|Change/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/reset-password')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ token: 'tok-123', newPassword: 'a-brand-new-password' }) + }) + }) + + it('shows an expired or used token as the reason', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'That reset link has expired.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/reset-password?token=stale', path: '/reset-password' }) + await user.type(screen.getByLabelText(/New password/), 'a-brand-new-password') + await user.click(screen.getByRole('button', { name: /Reset|Save|Change/i })) + + expect(await screen.findByText(/That reset link has expired/)).toBeInTheDocument() + }) +}) + +describe('OrderDetailPage', () => { + const order = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + email: 'jane@example.com', + subtotal: 20, + shippingMethod: 'Standard', + shipping: 7.74, + taxState: 'CA', + taxRate: 0.0725, + tax: 1.45, + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + trackingNumber: '1Z999AA10123456784', + createdAt: '2026-05-01T08:00:00Z', + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, lineSubtotal: 20 }], + } + + it('is the receipt: every money line and the tracking number', async () => { + signIn('Customer') + stubFetch([['/orders/o-1', () => order]]) + + renderWithProviders(, { at: '/orders/o-1', path: '/orders/:id' }) + + expect((await screen.findAllByText(/WW-20260501-ABC123/)).length).toBeGreaterThan(0) + // $20.00 is both the line subtotal and the order subtotal. + expect(screen.getAllByText('$20.00').length).toBeGreaterThan(0) + expect(screen.getByText('$7.74')).toBeInTheDocument() + expect(screen.getByText('$1.45')).toBeInTheDocument() + expect(screen.getByText('$29.19')).toBeInTheDocument() + expect(screen.getByText('1Z999AA10123456784')).toBeInTheDocument() + expect(screen.getByText('Standard Widget')).toBeInTheDocument() + }) + + it('reports an order it cannot load', async () => { + signIn('Customer') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Order not found.' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/orders/nope', path: '/orders/:id' }) + + expect(await screen.findByText(/Order not found/)).toBeInTheDocument() + }) + + it('offers a printable receipt', async () => { + signIn('Customer') + stubFetch([['/orders/o-1', () => order]]) + const print = vi.fn() + vi.stubGlobal('print', print) + const user = userEvent.setup() + + renderWithProviders(, { at: '/orders/o-1', path: '/orders/:id' }) + await user.click(await screen.findByRole('button', { name: /Print/i })) + + expect(print).toHaveBeenCalled() + }) +}) diff --git a/web/src/pages/CartAndOrders.test.tsx b/web/src/pages/CartAndOrders.test.tsx new file mode 100644 index 0000000..5d0efa5 --- /dev/null +++ b/web/src/pages/CartAndOrders.test.tsx @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CartPage } from './CartPage' +import { OrdersPage } from './OrdersPage' +import { AdminOrderPage } from './admin/AdminOrderPage' +import { renderWithProviders, signIn, stubFetch, useCartId } from '../test/render' + +const line = { + widgetId: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + unitPrice: 10, + quantity: 2, + quantityAvailable: 5, + lineSubtotal: 20, +} + +const cart = { id: 'cart-1', userId: null, items: [line], subtotal: 20, itemCount: 2 } + +describe('CartPage', () => { + it('lists what is in the basket', async () => { + useCartId('cart-1') + stubFetch([['/cart/cart-1', () => cart]]) + + renderWithProviders(, { at: '/cart' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('$20.00')).toBeInTheDocument() + }) + + it('increasing a quantity sends the new absolute quantity, not a delta', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Increase quantity of Standard Widget' })) + + await waitFor(() => { + const put = calls.find((c) => c.init?.method === 'PUT') + expect(JSON.parse(String(put?.init?.body))).toMatchObject({ quantity: 3 }) + }) + }) + + it('decreasing a quantity sends one fewer', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Decrease quantity of Standard Widget' })) + + await waitFor(() => { + const put = calls.find((c) => c.init?.method === 'PUT') + expect(JSON.parse(String(put?.init?.body))).toMatchObject({ quantity: 1 }) + }) + }) + + it('removing a line calls DELETE for that widget', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Remove' })) + + await waitFor(() => { + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('w-1') + }) + }) + + it('sends the shopper to checkout', async () => { + useCartId('cart-1') + stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart', routes: { '/checkout':

Secure checkout

} }) + await user.click(await screen.findByRole('button', { name: /Proceed to checkout|Checkout/i })) + + expect(await screen.findByRole('heading', { name: 'Secure checkout' })).toBeInTheDocument() + }) + + it('offers a way back to the store when empty', async () => { + stubFetch([['/cart/', () => ({ ...cart, items: [], itemCount: 0, subtotal: 0 })]]) + + renderWithProviders(, { at: '/cart' }) + + expect(await screen.findByText('Your cart is empty')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Start shopping' })).toHaveAttribute('href', '/store') + }) +}) + +describe('OrdersPage', () => { + const order = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + total: 29.19, + itemCount: 2, + createdAt: '2026-05-01T08:00:00Z', + } + + it('lists the account orders with their status', async () => { + signIn('Customer') + stubFetch([['/orders', () => [order]]]) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText('WW-20260501-ABC123')).toBeInTheDocument() + expect(screen.getByText('$29.19')).toBeInTheDocument() + expect(screen.getByText('Paid')).toBeInTheDocument() + }) + + it('says so plainly when there are none', async () => { + signIn('Customer') + stubFetch([['/orders', () => []]]) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText('No orders yet')).toBeInTheDocument() + }) + + it('surfaces a load failure rather than an endless skeleton', async () => { + signIn('Customer') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Orders unavailable.' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText(/Orders unavailable/)).toBeInTheDocument() + }) +}) + +describe('AdminOrderPage', () => { + const summary = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + total: 29.19, + itemCount: 2, + createdAt: '2026-05-01T08:00:00Z', + } + + const detail = { + ...summary, + email: 'jane@example.com', + subtotal: 20, + shippingMethod: 'Standard', + shipping: 7.74, + taxState: 'CA', + taxRate: 0.0725, + tax: 1.45, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + trackingNumber: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, lineSubtotal: 20 }], + } + + it('lists recent orders so staff can find one without knowing its id', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => [summary]]]) + + renderWithProviders(, { at: '/admin/orders' }) + + // The regression this page exists for: lookup used to require a GUID nobody has. + expect(await screen.findByText('WW-20260501-ABC123')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument() + }) + + it('opening an order shows its detail and fulfilment controls', async () => { + signIn('Manager') + stubFetch([ + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + + expect(await screen.findByText('jane@example.com')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Mark shipped' })).toBeInTheDocument() + }) + + it('marking shipped posts the status with the tracking number typed in', async () => { + signIn('Manager') + const calls = stubFetch([ + ['/admin/orders/o-1/status', () => ({ ...detail, status: 'Shipped', trackingNumber: '1Z-NEW' })], + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.type(await screen.findByLabelText('Tracking number'), '1Z-NEW') + await user.click(screen.getByRole('button', { name: 'Mark shipped' })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/status')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ status: 'Shipped', trackingNumber: '1Z-NEW' }) + }) + }) + + it('sends null rather than an empty string when no tracking was entered', async () => { + signIn('Manager') + const calls = stubFetch([ + ['/admin/orders/o-1/status', () => ({ ...detail, status: 'Cancelled' })], + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/status')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ status: 'Cancelled', trackingNumber: null }) + }) + }) + + it('shows the API refusal when a transition is not allowed', async () => { + signIn('Manager') + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/status')) { + return new Response(JSON.stringify({ error: "Cannot change status from AwaitingPayment to 'Shipped'." }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/admin/orders/o-1')) { + return new Response(JSON.stringify(detail), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify([summary]), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.click(await screen.findByRole('button', { name: 'Mark shipped' })) + + expect(await screen.findByText(/Cannot change status/)).toBeInTheDocument() + }) + + it('says there is nothing to fulfil when the list is empty', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => []]]) + + renderWithProviders(, { at: '/admin/orders' }) + + expect(await screen.findByText('No orders yet')).toBeInTheDocument() + }) + + it('refreshes the list on demand', async () => { + signIn('Manager') + const calls = stubFetch([['/admin/orders', () => [summary]]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await screen.findByText('WW-20260501-ABC123') + const before = calls.filter((c) => c.url.includes('/admin/orders')).length + + await user.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => expect(calls.filter((c) => c.url.includes('/admin/orders')).length).toBeGreaterThan(before)) + }) + + it('prompts staff to pick an order before showing controls', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => [summary]]]) + + renderWithProviders(, { at: '/admin/orders' }) + + const aside = await screen.findByText('No order selected') + expect(within(aside.closest('.panel') as HTMLElement).getByText(/Pick an order/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Mark shipped' })).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..6dc3dac --- /dev/null +++ b/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LoginPage } from './LoginPage' +import { renderWithProviders, stubFetch, useCartId, REFRESH_KEY, ROLE_KEY } from '../test/render' + +/** + * Sign-in, including the two-step branch and the guest-cart merge. The merge is the subtle one: + * a shopper who filled a basket before signing in must not lose it, and a merge failure must not + * strand them on the sign-in page after their credentials were accepted. + */ +describe('LoginPage', () => { + const session = { accessToken: 'access', refreshToken: 'refresh', role: 'Customer', twoFactorRequired: false } + + const routes = { '/store':

Storefront

} + + async function signInAs(user: ReturnType) { + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.type(screen.getByLabelText('Password'), 'correct-horse') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + } + + it('stores the session and lands on the store', async () => { + stubFetch([['/auth/login', () => session]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBe('refresh') + expect(localStorage.getItem(ROLE_KEY)).toBe('Customer') + }) + + it('sends the typed credentials, not something stale', async () => { + const calls = stubFetch([['/auth/login', () => session]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await waitFor(() => { + const login = calls.find((c) => c.url.includes('/auth/login')) + expect(JSON.parse(String(login?.init?.body))).toEqual({ email: 'jane@example.com', password: 'correct-horse' }) + }) + }) + + it('shows the failure and stays put when credentials are rejected', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Invalid email or password.' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Storefront' })).not.toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + }) + + it('asks for the second factor instead of signing in, and stores nothing yet', async () => { + stubFetch([['/auth/login', () => ({ twoFactorRequired: true, challengeToken: 'challenge-1' })]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByRole('heading', { name: 'Two-step verification' })).toBeInTheDocument() + + // A password alone must not leave a usable session behind. + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + expect(screen.queryByRole('heading', { name: 'Storefront' })).not.toBeInTheDocument() + }) + + it('completes the second factor with the challenge it was handed', async () => { + const calls = stubFetch([ + ['/auth/2fa', () => session], + ['/auth/login', () => ({ twoFactorRequired: true, challengeToken: 'challenge-1' })], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await user.type(await screen.findByLabelText('Verification code'), '654321') + await user.click(screen.getByRole('button', { name: 'Verify' })) + + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + const second = calls.find((c) => c.url.includes('/auth/2fa')) + expect(JSON.parse(String(second?.init?.body))).toEqual({ challengeToken: 'challenge-1', code: '654321' }) + }) + + it('reports a wrong code without losing the challenge', async () => { + let calls = 0 + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/auth/2fa')) { + calls++ + return new Response(JSON.stringify({ error: 'Invalid code.' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({ twoFactorRequired: true, challengeToken: 'challenge-1' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + await user.type(await screen.findByLabelText('Verification code'), '000000') + await user.click(screen.getByRole('button', { name: 'Verify' })) + + expect(await screen.findByText('Invalid code.')).toBeInTheDocument() + + // Still on the code step, so a second attempt does not need a fresh password. + expect(screen.getByRole('heading', { name: 'Two-step verification' })).toBeInTheDocument() + expect(calls).toBe(1) + }) + + it('merges a guest cart into the account on the way in', async () => { + useCartId('cart-1') + const calls = stubFetch([ + ['/cart/merge', () => ({ id: 'cart-1', userId: 'u-1', items: [], subtotal: 0, itemCount: 0 })], + ['/auth/login', () => session], + ['/cart/cart-1', () => ({ id: 'cart-1', userId: null, items: [], subtotal: 0, itemCount: 0 })], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await waitFor(() => { + const merge = calls.find((c) => c.url.includes('/cart/merge')) + expect(JSON.parse(String(merge?.init?.body))).toEqual({ guestCartId: 'cart-1' }) + }) + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + }) + + it('still signs in when the cart merge fails', async () => { + useCartId('cart-1') + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/cart/merge')) { + return new Response(JSON.stringify({ error: 'merge blew up' }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/auth/login')) { + return new Response(JSON.stringify(session), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ id: 'cart-1', userId: null, items: [], subtotal: 0, itemCount: 0 }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + // Credentials were accepted; a basket problem must not undo that. + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBe('refresh') + }) + + it('offers the way out for a forgotten password and a new account', () => { + renderWithProviders(, { at: '/login', routes }) + + expect(screen.getByRole('link', { name: /Forgot your password/i })).toHaveAttribute('href', '/forgot-password') + expect(screen.getByRole('link', { name: /Create an account/i })).toHaveAttribute('href', '/register') + }) +}) diff --git a/web/src/pages/StorefrontPages.test.tsx b/web/src/pages/StorefrontPages.test.tsx new file mode 100644 index 0000000..bbbfd74 --- /dev/null +++ b/web/src/pages/StorefrontPages.test.tsx @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CatalogPage } from './CatalogPage' +import { ProductPage } from './ProductPage' +import { OrderConfirmationPage } from './OrderConfirmationPage' +import { RegisterPage } from './RegisterPage' +import { renderWithProviders, stubFetch, REFRESH_KEY } from '../test/render' + +const widget = { + id: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + description: 'A dependable widget for everyday jobs.', + imageUrl: null, + price: 12.5, + quantityOnHand: 10, + quantityReserved: 0, + quantityAvailable: 10, + isActive: true, +} + +const soldOut = { ...widget, id: 'w-2', sku: 'WW-002', name: 'Mega Widget', quantityAvailable: 0, price: 99 } + +describe('CatalogPage', () => { + const paged = (items = [widget, soldOut]) => () => ({ items, page: 1, pageSize: 24, total: items.length }) + + it('renders the widgets it loads', async () => { + stubFetch([['/catalog/widgets', paged()]]) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('Mega Widget')).toBeInTheDocument() + }) + + it('says so instead of showing an empty grid when nothing matches', async () => { + stubFetch([['/catalog/widgets', paged([])]]) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText('No widgets matched')).toBeInTheDocument() + }) + + it('marks an out-of-stock widget as unbuyable', async () => { + stubFetch([['/catalog/widgets', paged()]]) + + renderWithProviders(, { at: '/store' }) + await screen.findByText('Mega Widget') + + expect(screen.getByRole('button', { name: 'Out of stock' })).toBeDisabled() + }) + + it('surfaces a catalog failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Catalog is down.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText(/Catalog is down/)).toBeInTheDocument() + }) +}) + +describe('ProductPage', () => { + it('shows the product and lets it be bought', async () => { + stubFetch([['/catalog/widgets/w-1', () => widget]]) + + renderWithProviders(, { at: '/widgets/w-1', path: '/widgets/:id' }) + + expect(await screen.findByRole('heading', { name: 'Standard Widget' })).toBeInTheDocument() + expect(screen.getByText(/A dependable widget/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Add to cart/i })).toBeEnabled() + }) + + it('reports a widget it cannot load rather than rendering a blank page', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Widget not found.' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/widgets/nope', path: '/widgets/:id' }) + + expect(await screen.findByText(/couldn.t load that widget/i)).toBeInTheDocument() + }) + + it('cannot be added when it is out of stock', async () => { + stubFetch([['/catalog/widgets/w-2', () => soldOut]]) + + renderWithProviders(, { at: '/widgets/w-2', path: '/widgets/:id' }) + await screen.findByRole('heading', { name: 'Mega Widget' }) + + expect(screen.getByRole('button', { name: 'Out of stock' })).toBeDisabled() + }) +}) + +describe('OrderConfirmationPage', () => { + const paid = { + orderNumber: 'WW-20260501-ABC123', + orderId: 'o-1', + status: 'Paid', + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + email: 'jane@example.com', + } + + const awaiting = { ...paid, status: 'AwaitingPayment', paymentProvider: 'Klarna' } + + it('confirms a paid order with its number and total', () => { + renderWithProviders(, { at: '/order-confirmation', state: paid }) + + expect(screen.getAllByText('WW-20260501-ABC123').length).toBeGreaterThan(0) + expect(screen.getByText('$29.19')).toBeInTheDocument() + }) + + it('explains what to do when someone lands here with no order', () => { + renderWithProviders(, { at: '/order-confirmation' }) + + expect(screen.getByText('No recent order to show')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Your orders' })).toHaveAttribute('href', '/orders') + }) + + it('offers to settle an order that is awaiting the provider', () => { + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + + expect(screen.getByText(/Waiting on Klarna/)).toBeInTheDocument() + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('settling posts the reference to the mock webhook and updates the status', async () => { + const calls = stubFetch([['/webhooks/payments/mock', () => ({ status: 'Paid' })]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + const [approve] = screen.getAllByRole('button') + await user.click(approve) + + await waitFor(() => { + const hook = calls.find((c) => c.url.includes('/webhooks/payments/mock')) + expect(JSON.parse(String(hook?.init?.body))).toMatchObject({ reference: 'mock_1', outcome: 'succeeded' }) + }) + }) + + it('reports a webhook failure instead of pretending it settled', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Invalid webhook signature.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + const [approve] = screen.getAllByRole('button') + await user.click(approve) + + expect(await screen.findByText(/Invalid webhook signature/)).toBeInTheDocument() + }) +}) + +describe('RegisterPage', () => { + it('creates the account and sends the new customer to sign in', async () => { + const calls = stubFetch([['/auth/register', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/register', routes: { '/login':

Sign in

} }) + + await user.type(screen.getByLabelText(/Email/i), 'new@example.com') + await user.type(screen.getByLabelText(/Password/i), 'long-enough-pw') + await user.click(screen.getByRole('button', { name: /Create account|Create your account|Sign up/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/register')) + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ email: 'new@example.com' }) + }) + }) + + it('shows the API rejection and leaves no session behind', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Unable to register with the provided details.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/register', routes: { '/login':

Sign in

} }) + await user.type(screen.getByLabelText(/Email/i), 'taken@example.com') + await user.type(screen.getByLabelText(/Password/i), 'long-enough-pw') + await user.click(screen.getByRole('button', { name: /Create account|Create your account|Sign up/i })) + + expect(await screen.findByText(/Unable to register/)).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + }) +}) diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx index 469e11c..4c31aca 100644 --- a/web/src/test/render.tsx +++ b/web/src/test/render.tsx @@ -22,18 +22,20 @@ export function useCartId(id: string) { /** * Renders a component inside the providers it expects, on a memory router so navigation is * observable without a browser. `at` sets the starting URL; any route in `routes` renders a - * marker so a redirect can be asserted by what lands on screen. + * marker so a redirect can be asserted by what lands on screen; `state` seeds router location + * state, which is how the confirmation page receives its order. `path` supplies the route + * pattern when the URL carries params (e.g. at='/widgets/w-1', path='/widgets/:id'). */ export function renderWithProviders( ui: ReactElement, - { at = '/', routes = {} as Record } = {}, + { at = '/', path = '', routes = {} as Record, state = undefined as unknown } = {}, ): RenderResult { return render( - + - + {Object.entries(routes).map(([path, element]) => ( ))} From ae57fd9ee61a3eff8cc2923cfa1f0c448af310c5 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:35:38 -0500 Subject: [PATCH 5/9] test: add a PostgreSQL integration suite for the repository layer Infrastructure sat at 14.5% because the repositories are mostly SQL, and SQL cannot be tested against an in-memory fake -- a fake would only prove the fake works. 53 tests now run against a real PostgreSQL, on a throwaway database created and dropped per run, migrated by the same DbUp scripts the application runs at startup. The one that justifies the whole suite: ten concurrent buyers, two units each, ten in stock -- exactly five may win. Overselling is prevented by a conditional UPDATE inside a transaction, and nothing short of concurrent connections against a real server can demonstrate that. Alongside it: a refused reservation rolls the order row back too, a decline returns the stock to the shelf, and an AwaitingPayment order keeps its reservation so it cannot be sold twice while the provider settles. Also covered: SKU uniqueness folded through upper(), the ON CONFLICT cart upsert, cascading cart deletes, refresh-token family revocation, single-use recovery codes that are useless to another user, reset tokens invalidated when a newer one is issued, and a seeder that can run on every boot without duplicating an account or resetting a password someone changed. Two real findings, both fixed: Dapper's snake_case mapping is global process state that was set inline in AddInfrastructure. Any repository built outside the DI container -- a test, a console tool, a migration script -- silently mis-mapped every multi-word column, so tracking_number and order_number came back as defaults while single-word columns worked. That reads as missing data, not missing configuration. It is now DapperConfiguration.Apply(): explicit, idempotent, callable. Testcontainers was the obvious tool and was rejected: it pulls SSH.NET 2024.2.0, which has a known high-severity advisory, and this repo builds with NuGet audit as an error. The suite uses the Postgres that docker compose and CI already provide instead. Backend line coverage 46.3% -> 83.1%; Infrastructure 14.5% -> 72.1%. 273 backend tests. Co-Authored-By: Claude Opus 5 --- WidgetWorks.slnx | 1 + .../DependencyInjection.cs | 3 +- .../Persistence/DapperConfiguration.cs | 28 + .../CatalogAndAuthRepositoryTests.cs | 536 ++++++++++++++++++ .../OrderRepositoryTests.cs | 308 ++++++++++ .../PostgresFixture.cs | 71 +++ .../SeederAndMigrationTests.cs | 150 +++++ .../WidgetWorks.IntegrationTests.csproj | 30 + 8 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs create mode 100644 tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs create mode 100644 tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs create mode 100644 tests/WidgetWorks.IntegrationTests/PostgresFixture.cs create mode 100644 tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs create mode 100644 tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj diff --git a/WidgetWorks.slnx b/WidgetWorks.slnx index 4c3964b..ac0db9c 100644 --- a/WidgetWorks.slnx +++ b/WidgetWorks.slnx @@ -7,5 +7,6 @@ + diff --git a/src/WidgetWorks.Infrastructure/DependencyInjection.cs b/src/WidgetWorks.Infrastructure/DependencyInjection.cs index 94b37ab..77bde8f 100644 --- a/src/WidgetWorks.Infrastructure/DependencyInjection.cs +++ b/src/WidgetWorks.Infrastructure/DependencyInjection.cs @@ -1,4 +1,3 @@ -using Dapper; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -21,7 +20,7 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { // Dapper maps snake_case columns to PascalCase properties. - DefaultTypeMap.MatchNamesWithUnderscores = true; + DapperConfiguration.Apply(); // Deterministic, testable time everywhere — never DateTime.Now. services.AddSingleton(TimeProvider.System); diff --git a/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs b/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs new file mode 100644 index 0000000..07adfc6 --- /dev/null +++ b/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs @@ -0,0 +1,28 @@ +using Dapper; + +namespace WidgetWorks.Infrastructure.Persistence; + +/// +/// Dapper's column-to-property mapping is global, process-wide state. It used to be set inline in +/// AddInfrastructure, which meant any repository constructed outside the DI container -- a test, a +/// console tool, a migration script -- silently mis-mapped every multi-word column: order_number, +/// tracking_number and quantity_reserved came back as defaults while single-word columns worked, so +/// the failure looked like missing data rather than missing configuration. +/// +/// Applying it here, once and idempotently, makes the requirement explicit and callable. +/// +public static class DapperConfiguration +{ + private static bool _applied; + + public static void Apply() + { + if (_applied) + { + return; + } + + DefaultTypeMap.MatchNamesWithUnderscores = true; + _applied = true; + } +} diff --git a/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs b/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs new file mode 100644 index 0000000..0b84ae6 --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs @@ -0,0 +1,536 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Domain.Auth; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// Catalog, cart, and auth persistence against real PostgreSQL. These repositories lean on things +/// only a database provides — a case-folded unique index on SKU, ON CONFLICT upserts, partial +/// indexes, and cascading deletes — so a fake would prove nothing about them. +/// +[Collection(PostgresCollection.Name)] +public class CatalogAndAuthRepositoryTests(PostgresFixture db) +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private WidgetRepository Widgets => new(db.Connections); + + private CartRepository Carts => new(db.Connections, TimeProvider.System); + + private UserRepository Users => new(db.Connections); + + private static string Unique(string prefix) => prefix + Guid.NewGuid().ToString("N")[..10]; + + private async Task GivenWidget(int onHand = 10, bool active = true, string? name = null) + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = Unique("SKU-").ToUpperInvariant(), + Name = name ?? Unique("Widget "), + Description = "Integration fixture.", + Price = 12.5m, + QuantityOnHand = onHand, + QuantityReserved = 0, + IsActive = active, + CreatedAt = Now, + UpdatedAt = Now, + }; + await Widgets.AddAsync(widget, CancellationToken.None); + return widget; + } + + private async Task GivenUser(string? role = null) + { + var email = Unique("it-") + "@example.com"; + var user = new User + { + Id = Guid.NewGuid(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PasswordHash = "hash", + Role = role ?? UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }; + await Users.AddAsync(user, CancellationToken.None); + return user; + } + + // ---- widgets ------------------------------------------------------------------------- + + [Fact] + public async Task A_widget_round_trips_every_column() + { + var widget = await GivenWidget(onHand: 7); + + var stored = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + + Assert.Equal(widget.Sku, stored!.Sku); + Assert.Equal(widget.Name, stored.Name); + Assert.Equal(12.5m, stored.Price); + Assert.Equal(7, stored.QuantityOnHand); + Assert.Equal(0, stored.QuantityReserved); + Assert.True(stored.IsActive); + } + + [Fact] + public async Task A_widget_can_be_found_by_its_normalized_sku() + { + var widget = await GivenWidget(); + + Assert.NotNull(await Widgets.GetBySkuAsync(widget.Sku.ToUpperInvariant(), CancellationToken.None)); + Assert.Null(await Widgets.GetBySkuAsync("NOT-A-SKU", CancellationToken.None)); + } + + [Fact] + public async Task Updating_a_widget_persists_the_change() + { + var widget = await GivenWidget(); + widget.Price = 99.99m; + widget.QuantityOnHand = 3; + widget.IsActive = false; + + await Widgets.UpdateAsync(widget, CancellationToken.None); + + var stored = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(99.99m, stored!.Price); + Assert.Equal(3, stored.QuantityOnHand); + Assert.False(stored.IsActive); + } + + [Fact] + public async Task Search_matches_on_name_and_respects_the_active_filter() + { + var token = Guid.NewGuid().ToString("N")[..8]; + await GivenWidget(name: $"Findable {token}"); + await GivenWidget(name: $"Hidden {token}", active: false); + + var all = await Widgets.SearchAsync(new WidgetQuery(token, ActiveOnly: false, 1, 50), CancellationToken.None); + var live = await Widgets.SearchAsync(new WidgetQuery(token, ActiveOnly: true, 1, 50), CancellationToken.None); + + Assert.Equal(2, all.Count); + Assert.Single(live); + Assert.Equal(2, await Widgets.CountAsync(new WidgetQuery(token, false, 1, 50), CancellationToken.None)); + Assert.Equal(1, await Widgets.CountAsync(new WidgetQuery(token, true, 1, 50), CancellationToken.None)); + } + + [Fact] + public async Task Search_pages_through_results() + { + var token = Guid.NewGuid().ToString("N")[..8]; + for (var i = 0; i < 3; i++) + { + await GivenWidget(name: $"Paged {token} {i}"); + } + + var first = await Widgets.SearchAsync(new WidgetQuery(token, true, 1, 2), CancellationToken.None); + var second = await Widgets.SearchAsync(new WidgetQuery(token, true, 2, 2), CancellationToken.None); + + Assert.Equal(2, first.Count); + Assert.Single(second); + Assert.Empty(first.Select(w => w.Id).Intersect(second.Select(w => w.Id))); + } + + [Fact] + public async Task A_widget_that_was_never_ordered_reports_no_order_lines_and_deletes() + { + var widget = await GivenWidget(); + + Assert.Equal(0, await Widgets.CountOrderLinesAsync(widget.Id, CancellationToken.None)); + + await Widgets.DeleteAsync(widget.Id, CancellationToken.None); + + Assert.Null(await Widgets.GetByIdAsync(widget.Id, CancellationToken.None)); + } + + [Fact] + public async Task Two_widgets_cannot_share_a_sku_whatever_the_casing() + { + var widget = await GivenWidget(); + + // ux_widgets_sku is on upper(sku), so the clash is caught however it is typed. This is + // enforced by the index, not by application code, so a direct write cannot dodge it. + var clash = new Widget + { + Id = Guid.NewGuid(), + Sku = widget.Sku.ToLowerInvariant(), + Name = Unique("Other "), + Description = "Should not be accepted.", + Price = 1m, + QuantityOnHand = 1, + IsActive = true, + CreatedAt = Now, + UpdatedAt = Now, + }; + + await Assert.ThrowsAnyAsync(() => Widgets.AddAsync(clash, CancellationToken.None)); + } + + [Fact] + public async Task Widget_names_are_deliberately_not_unique() + { + // ix_widgets_live_name exists for ordering the live set, not to constrain it: two + // widgets may legitimately share a display name while differing by SKU. + var name = Unique("Shared "); + await GivenWidget(name: name); + + var second = await GivenWidget(name: name); + + Assert.Equal(name, (await Widgets.GetByIdAsync(second.Id, CancellationToken.None))!.Name); + } + + // ---- carts --------------------------------------------------------------------------- + + [Fact] + public async Task A_guest_cart_is_created_and_read_back() + { + var cart = await Carts.CreateAsync(null, CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + + Assert.NotNull(stored); + Assert.Null(stored!.UserId); + Assert.Empty(stored.Items); + } + + [Fact] + public async Task Adding_the_same_widget_twice_updates_the_line_rather_than_duplicating_it() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + + await Carts.UpsertItemAsync(cart.Id, widget.Id, 2, Now, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 5, Now, CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + var item = Assert.Single(stored!.Items); + Assert.Equal(5, item.Quantity); + } + + [Fact] + public async Task A_users_cart_can_be_found_by_the_user() + { + var user = await GivenUser(); + var cart = await Carts.CreateAsync(user.Id, CancellationToken.None); + + var found = await Carts.GetByUserAsync(user.Id, CancellationToken.None); + + Assert.Equal(cart.Id, found!.Id); + Assert.Null(await Carts.GetByUserAsync(Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Removing_an_item_and_touching_the_cart_both_persist() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 1, Now, CancellationToken.None); + + await Carts.RemoveItemAsync(cart.Id, widget.Id, CancellationToken.None); + await Carts.TouchAsync(cart.Id, Now.AddHours(2), CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + Assert.Empty(stored!.Items); + Assert.Equal(Now.AddHours(2), stored.UpdatedAt); + } + + [Fact] + public async Task Deleting_a_cart_takes_its_items_with_it() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 1, Now, CancellationToken.None); + + await Carts.DeleteAsync(cart.Id, CancellationToken.None); + + // Relies on the cascade; an orphaned cart_items row would violate the schema. + Assert.Null(await Carts.GetAsync(cart.Id, CancellationToken.None)); + } + + // ---- users --------------------------------------------------------------------------- + + [Fact] + public async Task A_user_is_found_by_normalized_email_regardless_of_typed_case() + { + var user = await GivenUser(); + + var found = await Users.GetByNormalizedEmailAsync(user.Email.ToUpperInvariant(), CancellationToken.None); + + Assert.Equal(user.Id, found!.Id); + Assert.Null(await Users.GetByNormalizedEmailAsync("NOBODY@EXAMPLE.COM", CancellationToken.None)); + } + + [Fact] + public async Task A_google_user_is_found_by_subject() + { + var user = await GivenUser(); + user.GoogleSub = Unique("google-sub-"); + await Users.UpdateAsync(user, CancellationToken.None); + + var found = await Users.GetByGoogleSubAsync(user.GoogleSub, CancellationToken.None); + + Assert.Equal(user.Id, found!.Id); + Assert.Null(await Users.GetByGoogleSubAsync("not-a-subject", CancellationToken.None)); + } + + [Fact] + public async Task Lockout_state_and_the_security_stamp_persist() + { + var user = await GivenUser(); + var rotated = Guid.NewGuid(); + user.FailedAccessCount = 4; + user.LockedUntil = Now.AddMinutes(15); + user.SecurityStamp = rotated; + + await Users.UpdateAsync(user, CancellationToken.None); + + var stored = await Users.GetByIdAsync(user.Id, CancellationToken.None); + Assert.Equal(4, stored!.FailedAccessCount); + Assert.Equal(Now.AddMinutes(15), stored.LockedUntil); + Assert.True(stored.IsLockedOut(Now)); + + // The stamp is read on every request, so it has its own narrow query. + Assert.Equal(rotated, await Users.GetSecurityStampAsync(user.Id, CancellationToken.None)); + Assert.Null(await Users.GetSecurityStampAsync(Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Two_users_cannot_share_an_email() + { + var user = await GivenUser(); + + await Assert.ThrowsAnyAsync(() => Users.AddAsync( + new User + { + Id = Guid.NewGuid(), + Email = user.Email, + NormalizedEmail = user.NormalizedEmail, + PasswordHash = "hash", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }, + CancellationToken.None)); + } + + // ---- refresh tokens ------------------------------------------------------------------ + + private RefreshTokenRepository RefreshTokens => new(db.Connections); + + private static RefreshToken TokenFor(Guid userId, Guid familyId, string hash) => new() + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = hash, + FamilyId = familyId, + ExpiresAt = Now.AddDays(14), + CreatedAt = Now, + }; + + [Fact] + public async Task A_refresh_token_round_trips_and_is_found_by_hash() + { + var user = await GivenUser(); + var token = TokenFor(user.Id, Guid.NewGuid(), Unique("hash-")); + await RefreshTokens.AddAsync(token, CancellationToken.None); + + var stored = await RefreshTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + + Assert.Equal(token.Id, stored!.Id); + Assert.Equal(token.FamilyId, stored.FamilyId); + Assert.True(stored.IsActive(Now)); + Assert.Null(await RefreshTokens.GetByHashAsync("no-such-hash", CancellationToken.None)); + } + + [Fact] + public async Task Revoking_a_family_kills_every_token_in_it_and_spares_the_others() + { + var user = await GivenUser(); + var doomed = Guid.NewGuid(); + var untouched = Guid.NewGuid(); + var a = TokenFor(user.Id, doomed, Unique("hash-")); + var b = TokenFor(user.Id, doomed, Unique("hash-")); + var c = TokenFor(user.Id, untouched, Unique("hash-")); + foreach (var t in new[] { a, b, c }) + { + await RefreshTokens.AddAsync(t, CancellationToken.None); + } + + await RefreshTokens.RevokeFamilyAsync(doomed, Now, CancellationToken.None); + + Assert.NotNull((await RefreshTokens.GetByHashAsync(a.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.NotNull((await RefreshTokens.GetByHashAsync(b.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.Null((await RefreshTokens.GetByHashAsync(c.TokenHash, CancellationToken.None))!.RevokedAt); + } + + [Fact] + public async Task Revoking_everything_for_a_user_signs_out_all_their_devices() + { + var user = await GivenUser(); + var other = await GivenUser(); + var mine = TokenFor(user.Id, Guid.NewGuid(), Unique("hash-")); + var theirs = TokenFor(other.Id, Guid.NewGuid(), Unique("hash-")); + await RefreshTokens.AddAsync(mine, CancellationToken.None); + await RefreshTokens.AddAsync(theirs, CancellationToken.None); + + await RefreshTokens.RevokeAllForUserAsync(user.Id, Now, CancellationToken.None); + + Assert.NotNull((await RefreshTokens.GetByHashAsync(mine.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.Null((await RefreshTokens.GetByHashAsync(theirs.TokenHash, CancellationToken.None))!.RevokedAt); + } + + [Fact] + public async Task Rotation_records_what_replaced_a_token() + { + var user = await GivenUser(); + var family = Guid.NewGuid(); + var original = TokenFor(user.Id, family, Unique("hash-")); + var replacement = TokenFor(user.Id, family, Unique("hash-")); + await RefreshTokens.AddAsync(original, CancellationToken.None); + await RefreshTokens.AddAsync(replacement, CancellationToken.None); + + original.RevokedAt = Now; + original.ReplacedBy = replacement.Id; + await RefreshTokens.UpdateAsync(original, CancellationToken.None); + + var stored = await RefreshTokens.GetByHashAsync(original.TokenHash, CancellationToken.None); + Assert.Equal(Now, stored!.RevokedAt); + Assert.Equal(replacement.Id, stored.ReplacedBy); + Assert.False(stored.IsActive(Now)); + } + + // ---- two-factor ---------------------------------------------------------------------- + + private TwoFactorRepository TwoFactor => new(db.Connections, TimeProvider.System); + + [Fact] + public async Task A_pending_secret_becomes_confirmed_and_can_be_deleted() + { + var user = await GivenUser(); + + await TwoFactor.UpsertPendingSecretAsync(user.Id, "SECRETBASE32", CancellationToken.None); + Assert.False((await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.IsConfirmed); + + await TwoFactor.MarkConfirmedAsync(user.Id, CancellationToken.None); + Assert.True((await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.IsConfirmed); + + await TwoFactor.DeleteSecretAsync(user.Id, CancellationToken.None); + Assert.Null(await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None)); + } + + [Fact] + public async Task Re_enrolling_replaces_the_pending_secret_rather_than_adding_a_second() + { + var user = await GivenUser(); + + await TwoFactor.UpsertPendingSecretAsync(user.Id, "FIRST", CancellationToken.None); + await TwoFactor.UpsertPendingSecretAsync(user.Id, "SECOND", CancellationToken.None); + + Assert.Equal("SECOND", (await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.Secret); + } + + [Fact] + public async Task A_recovery_code_can_be_consumed_exactly_once() + { + var user = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(user.Id, ["rc:one", "rc:two"], Now, CancellationToken.None); + + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:one", Now, CancellationToken.None)); + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:one", Now, CancellationToken.None)); + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:two", Now, CancellationToken.None)); + } + + [Fact] + public async Task One_users_recovery_code_is_useless_to_another() + { + var owner = await GivenUser(); + var attacker = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(owner.Id, ["rc:shared-value"], Now, CancellationToken.None); + + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(attacker.Id, "rc:shared-value", Now, CancellationToken.None)); + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(owner.Id, "rc:shared-value", Now, CancellationToken.None)); + } + + [Fact] + public async Task Deleting_recovery_codes_clears_them_all() + { + var user = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(user.Id, ["rc:a", "rc:b"], Now, CancellationToken.None); + + await TwoFactor.DeleteRecoveryCodesAsync(user.Id, CancellationToken.None); + + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:a", Now, CancellationToken.None)); + } + + // ---- password reset ------------------------------------------------------------------ + + private PasswordResetTokenRepository ResetTokens => new(db.Connections); + + private static PasswordResetToken ResetFor(Guid userId, string hash) => new() + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = hash, + ExpiresAt = Now.AddHours(1), + CreatedAt = Now, + }; + + [Fact] + public async Task A_reset_token_round_trips_and_can_be_marked_used() + { + var user = await GivenUser(); + var token = ResetFor(user.Id, Unique("reset-")); + await ResetTokens.AddAsync(token, CancellationToken.None); + + var stored = await ResetTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + Assert.True(stored!.IsActive(Now)); + + await ResetTokens.MarkUsedAsync(token.Id, Now, CancellationToken.None); + + var used = await ResetTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + Assert.Equal(Now, used!.UsedAt); + Assert.False(used.IsActive(Now)); + } + + [Fact] + public async Task Requesting_a_new_reset_invalidates_the_outstanding_ones() + { + var user = await GivenUser(); + var first = ResetFor(user.Id, Unique("reset-")); + var second = ResetFor(user.Id, Unique("reset-")); + await ResetTokens.AddAsync(first, CancellationToken.None); + + await ResetTokens.InvalidateForUserAsync(user.Id, Now, CancellationToken.None); + await ResetTokens.AddAsync(second, CancellationToken.None); + + // Only the newest link may work, or an old email stays a way in. + Assert.False((await ResetTokens.GetByHashAsync(first.TokenHash, CancellationToken.None))!.IsActive(Now)); + Assert.True((await ResetTokens.GetByHashAsync(second.TokenHash, CancellationToken.None))!.IsActive(Now)); + } + + [Fact] + public async Task An_unknown_reset_hash_returns_null() + { + Assert.Null(await ResetTokens.GetByHashAsync("never-issued", CancellationToken.None)); + } + + // ---- audit log ----------------------------------------------------------------------- + + [Fact] + public async Task Audit_entries_are_written_for_a_user_and_anonymously() + { + var user = await GivenUser(); + var audit = new AuditLog(db.Connections, TimeProvider.System); + + await audit.WriteAsync(user.Id, "test.action", "detail", CancellationToken.None); + await audit.WriteAsync(null, "test.anonymous", null, CancellationToken.None); + + // No read side on the port; the assertion is that neither write throws or violates the FK. + Assert.NotNull(await Users.GetByIdAsync(user.Id, CancellationToken.None)); + } +} diff --git a/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs new file mode 100644 index 0000000..19897e0 --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs @@ -0,0 +1,308 @@ +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// The order repository against real PostgreSQL. The reservation is the reason this suite exists: +/// stock is committed by a conditional UPDATE inside a transaction, so overselling is prevented by +/// the database, not by application code. No in-memory fake can prove that — only concurrent +/// connections against a real server can. +/// +[Collection(PostgresCollection.Name)] +public class OrderRepositoryTests(PostgresFixture db) +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private OrderRepository Orders => new(db.Connections); + + private WidgetRepository Widgets => new(db.Connections); + + private async Task GivenWidget(int onHand) + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "IT-" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + Name = "Widget " + Guid.NewGuid().ToString("N")[..6], + Description = "Integration fixture.", + Price = 10m, + QuantityOnHand = onHand, + QuantityReserved = 0, + IsActive = true, + CreatedAt = Now, + UpdatedAt = Now, + }; + await Widgets.AddAsync(widget, CancellationToken.None); + return widget; + } + + /// orders.user_id is a real foreign key, so an owner has to exist first. + private async Task GivenUser() + { + var id = Guid.NewGuid(); + var email = $"it-{id:N}@example.com"; + await new UserRepository(db.Connections).AddAsync( + new User + { + Id = id, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PasswordHash = "hash", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }, + CancellationToken.None); + return id; + } + + private static Order OrderFor(Widget widget, int quantity, string? number = null) => new() + { + Id = Guid.NewGuid(), + OrderNumber = number ?? "WW-IT-" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + Email = "jane@example.com", + ShipName = "Jane Doe", + ShipLine1 = "1 Main St", + ShipCity = "Springfield", + ShipState = "CA", + ShipPostalCode = "90210", + ShipCountry = "US", + Subtotal = widget.Price * quantity, + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 1.45m, + Total = (widget.Price * quantity) + 6.99m + 1.45m, + Status = OrderStatus.Pending, + CreatedAt = Now, + UpdatedAt = Now, + Items = + [ + new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = widget.Id, + Sku = widget.Sku, + Name = widget.Name, + UnitPrice = widget.Price, + Quantity = quantity, + LineSubtotal = widget.Price * quantity, + }, + ], + }; + + [Fact] + public async Task Placing_an_order_reserves_the_stock() + { + var widget = await GivenWidget(onHand: 5); + + var placed = await Orders.TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None); + + Assert.True(placed); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(2, after!.QuantityReserved); + Assert.Equal(5, after.QuantityOnHand); + Assert.Equal(3, after.QuantityAvailable); + } + + [Fact] + public async Task An_order_for_more_than_is_available_is_refused_and_reserves_nothing() + { + var widget = await GivenWidget(onHand: 1); + + var placed = await Orders.TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None); + + Assert.False(placed); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(0, after!.QuantityReserved); + } + + [Fact] + public async Task A_refused_order_leaves_no_row_behind() + { + var widget = await GivenWidget(onHand: 0); + var order = OrderFor(widget, 1); + + await Orders.TryPlaceAsync(order, CancellationToken.None); + + // The whole placement is one transaction: a failed reservation must roll the order back too. + Assert.Null(await Orders.GetByIdAsync(order.Id, CancellationToken.None)); + } + + [Fact] + public async Task Concurrent_orders_cannot_oversell_the_last_units() + { + var widget = await GivenWidget(onHand: 10); + + // Ten buyers, two units each, ten in stock: exactly five can win. + var attempts = Enumerable.Range(0, 10) + .Select(_ => Task.Run(() => new OrderRepository(db.Connections) + .TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None))) + .ToArray(); + + var results = await Task.WhenAll(attempts); + + Assert.Equal(5, results.Count(placed => placed)); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(10, after!.QuantityReserved); + Assert.Equal(0, after.QuantityAvailable); + } + + [Fact] + public async Task Marking_paid_records_the_provider_and_reference() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkPaidAsync(order.Id, "Mock", "mock_ref_1", Now, CancellationToken.None); + + var stored = await Orders.GetByIdAsync(order.Id, CancellationToken.None); + Assert.Equal(OrderStatus.Paid, stored!.Status); + Assert.Equal("Mock", stored.PaymentProvider); + Assert.Equal("mock_ref_1", stored.PaymentReference); + } + + [Fact] + public async Task A_declined_payment_releases_the_reservation() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 3); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkPaymentFailedAsync(order, "Card declined.", Now, CancellationToken.None); + + // Stock a customer never paid for must go back on the shelf. + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(0, after!.QuantityReserved); + Assert.Equal(5, after.QuantityAvailable); + Assert.Equal(OrderStatus.PaymentFailed, (await Orders.GetByIdAsync(order.Id, CancellationToken.None))!.Status); + } + + [Fact] + public async Task An_awaiting_payment_order_keeps_its_reservation() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 2); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkAwaitingPaymentAsync(order.Id, "Klarna", "klarna_1", Now, CancellationToken.None); + + // The stock stays committed while the provider settles, or it could be sold twice. + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(2, after!.QuantityReserved); + Assert.Equal(OrderStatus.AwaitingPayment, (await Orders.GetByIdAsync(order.Id, CancellationToken.None))!.Status); + } + + [Fact] + public async Task An_order_can_be_found_by_its_payment_reference() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + await Orders.MarkAwaitingPaymentAsync(order.Id, "Klarna", "klarna_lookup", Now, CancellationToken.None); + + // This is how a webhook correlates an inbound event back to an order. + var found = await Orders.GetByPaymentReferenceAsync("Klarna", "klarna_lookup", CancellationToken.None); + + Assert.Equal(order.Id, found!.Id); + Assert.Null(await Orders.GetByPaymentReferenceAsync("Klarna", "not-a-reference", CancellationToken.None)); + Assert.Null(await Orders.GetByPaymentReferenceAsync("Stripe", "klarna_lookup", CancellationToken.None)); + } + + [Fact] + public async Task A_guest_can_look_an_order_up_only_with_the_email_that_placed_it() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + Assert.NotNull(await Orders.GetByNumberAndEmailAsync(order.OrderNumber, "jane@example.com", CancellationToken.None)); + Assert.Null(await Orders.GetByNumberAndEmailAsync(order.OrderNumber, "someone@else.com", CancellationToken.None)); + } + + [Fact] + public async Task Updating_status_stores_the_tracking_number() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + await Orders.MarkPaidAsync(order.Id, "Mock", "r", Now, CancellationToken.None); + + await Orders.UpdateStatusAsync(order.Id, OrderStatus.Shipped, "1Z-TRACK", Now.AddHours(1), CancellationToken.None); + + var stored = await Orders.GetByIdAsync(order.Id, CancellationToken.None); + Assert.Equal(OrderStatus.Shipped, stored!.Status); + Assert.Equal("1Z-TRACK", stored.TrackingNumber); + } + + [Fact] + public async Task A_users_orders_come_back_newest_first_with_their_lines() + { + var userId = await GivenUser(); + var widget = await GivenWidget(onHand: 20); + + var older = OrderFor(widget, 1); + older.UserId = userId; + older.CreatedAt = Now.AddDays(-3); + await Orders.TryPlaceAsync(older, CancellationToken.None); + + var newer = OrderFor(widget, 2); + newer.UserId = userId; + newer.CreatedAt = Now; + await Orders.TryPlaceAsync(newer, CancellationToken.None); + + var mine = await Orders.GetForUserAsync(userId, CancellationToken.None); + + Assert.Equal([newer.Id, older.Id], mine.Select(o => o.Id)); + Assert.All(mine, o => Assert.NotEmpty(o.Items)); + } + + [Fact] + public async Task Another_users_orders_are_not_returned() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + order.UserId = await GivenUser(); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + Assert.Empty(await Orders.GetForUserAsync(await GivenUser(), CancellationToken.None)); + } + + [Fact] + public async Task The_recent_list_carries_item_rows_so_counts_are_right() + { + var widget = await GivenWidget(onHand: 20); + var order = OrderFor(widget, 4); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + var recent = await Orders.GetRecentAsync(50, CancellationToken.None); + + // The bug this guards: skipping the item rows made every order report 0 items. + var mine = recent.Single(o => o.Id == order.Id); + Assert.Equal(4, mine.UnitCount); + } + + [Fact] + public async Task The_recent_list_honours_its_limit() + { + var widget = await GivenWidget(onHand: 50); + for (var i = 0; i < 4; i++) + { + await Orders.TryPlaceAsync(OrderFor(widget, 1), CancellationToken.None); + } + + Assert.Equal(2, (await Orders.GetRecentAsync(2, CancellationToken.None)).Count); + } + + [Fact] + public async Task An_unknown_order_id_returns_null_rather_than_throwing() + { + Assert.Null(await Orders.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + } +} diff --git a/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs b/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs new file mode 100644 index 0000000..cec187a --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs @@ -0,0 +1,71 @@ +using Npgsql; +using WidgetWorks.Infrastructure.Migrations; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// A real PostgreSQL database for the repository suites. The Dapper repositories are mostly SQL -- +/// the atomic stock reservation, the ON CONFLICT upserts, the partial unique index on live widget +/// names -- and none of that can be exercised by an in-memory fake. A fake would only prove the +/// fake works. +/// +/// It connects to the Postgres that `docker compose up db` already provides (override with +/// WIDGETWORKS_TEST_DB), then creates a **throwaway database per run** and migrates it, so the +/// suite never touches developer or demo data and parallel runs cannot collide. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private const string DefaultAdmin = + "Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally"; + + private string _adminConnectionString = DefaultAdmin; + + public string DatabaseName { get; } = "ww_test_" + Guid.NewGuid().ToString("N")[..12]; + + public string ConnectionString { get; private set; } = string.Empty; + + public IDbConnectionFactory Connections { get; private set; } = null!; + + public async Task InitializeAsync() + { + _adminConnectionString = Environment.GetEnvironmentVariable("WIDGETWORKS_TEST_DB") ?? DefaultAdmin; + + var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = "postgres" }; + await using (var admin = new NpgsqlConnection(builder.ConnectionString)) + { + await admin.OpenAsync(); + await using var create = new NpgsqlCommand($"create database \"{DatabaseName}\"", admin); + await create.ExecuteNonQueryAsync(); + } + + ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = DatabaseName } + .ConnectionString; + + // The same Dapper mapping and the same DbUp migrations the application runs at startup, so + // the schema and the mapping under test are the ones that ship. + DapperConfiguration.Apply(); + MigrationRunner.Run(ConnectionString); + + Connections = new NpgsqlConnectionFactory(ConnectionString); + } + + public async Task DisposeAsync() + { + NpgsqlConnection.ClearAllPools(); + + var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = "postgres" }; + await using var admin = new NpgsqlConnection(builder.ConnectionString); + await admin.OpenAsync(); + await using var drop = new NpgsqlCommand($"drop database if exists \"{DatabaseName}\" with (force)", admin); + await drop.ExecuteNonQueryAsync(); + } +} + +/// One database shared by every repository suite; each test cleans up after itself. +[CollectionDefinition(Name)] +public sealed class PostgresCollection : ICollectionFixture +{ + public const string Name = "postgres"; +} diff --git a/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs b/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs new file mode 100644 index 0000000..df4d11b --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs @@ -0,0 +1,150 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Migrations; +using WidgetWorks.Infrastructure.Persistence; +using WidgetWorks.Infrastructure.Seeding; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// Startup behaviour: migrations and the demo seed. Both run on every boot, so the property that +/// matters is idempotence — a second start must not duplicate an account, reset a password someone +/// changed, or re-add a widget an administrator deleted. +/// +[Collection(PostgresCollection.Name)] +public class SeederAndMigrationTests(PostgresFixture db) +{ + private UserRepository Users => new(db.Connections); + + private static SeedOptions Options(string suffix) => new() + { + DemoAdminEmail = $"admin-{suffix}@widgetworks.test", + DemoAdminPassword = "DemoAdmin!Change01", + DemoCustomerEmail = $"demo-{suffix}@widgetworks.test", + DemoCustomerPassword = "DemoUser!Change01", + DemoManagerEmail = $"manager-{suffix}@widgetworks.test", + DemoManagerPassword = "DemoManager!Change01", + }; + + private DbSeeder Seeder => new(db.Connections, new PlainHasher(), TimeProvider.System); + + [Fact] + public async Task Seeding_creates_all_three_roles() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + + var admin = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + var manager = await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None); + var customer = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + + Assert.Equal(UserRoles.Administrator, admin!.Role); + Assert.Equal(UserRoles.Manager, manager!.Role); + Assert.Equal(UserRoles.Customer, customer!.Role); + } + + [Fact] + public async Task Only_the_seeded_administrator_is_protected() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + + Assert.True((await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + Assert.False((await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + Assert.False((await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + } + + [Fact] + public async Task Seeding_twice_does_not_duplicate_an_account() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + var first = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + + await Seeder.SeedAsync(options, CancellationToken.None); + var second = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + + // Same row, not a second one — the unique index would have thrown on a blind insert. + Assert.Equal(first!.Id, second!.Id); + } + + [Fact] + public async Task Seeding_leaves_an_existing_password_alone() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + await Seeder.SeedAsync(options, CancellationToken.None); + + var user = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + user!.PasswordHash = "plain:changed-by-the-user"; + await Users.UpdateAsync(user, CancellationToken.None); + + await Seeder.SeedAsync(options, CancellationToken.None); + + // Restarting the app must never reset a password someone chose. + var after = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + Assert.Equal("plain:changed-by-the-user", after!.PasswordHash); + } + + [Fact] + public async Task An_account_with_no_configured_password_is_skipped_rather_than_created_open() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + options.DemoManagerPassword = string.Empty; + + await Seeder.SeedAsync(options, CancellationToken.None); + + Assert.Null(await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None)); + } + + [Fact] + public async Task Seeding_stocks_the_demo_catalog_and_repeats_safely() + { + var widgets = new WidgetRepository(db.Connections); + await Seeder.SeedAsync(Options(Guid.NewGuid().ToString("N")[..8]), CancellationToken.None); + + var standard = await widgets.GetBySkuAsync("WW-001", CancellationToken.None); + Assert.Equal("Standard Widget", standard!.Name); + Assert.Equal(9.99m, standard.Price); + + await Seeder.SeedAsync(Options(Guid.NewGuid().ToString("N")[..8]), CancellationToken.None); + + // Still one row per SKU after a second run. + Assert.Equal(1, await widgets.CountAsync(new WidgetQuery("Standard Widget", true, 1, 50), CancellationToken.None)); + } + + [Fact] + public void Migrations_are_journaled_so_a_second_run_is_a_no_op() + { + // The fixture already migrated this database; running again must succeed without + // reapplying anything, which is what makes restart-on-crash safe. + MigrationRunner.Run(db.ConnectionString); + + var outcome = MigrationRunner.TryRun(db.ConnectionString); + Assert.True(outcome.Successful); + } + + [Fact] + public void An_unreachable_database_is_reported_rather_than_thrown_at_startup() + { + var unreachable = "Host=localhost;Port=59999;Database=nope;Username=nobody;Password=nobody;Timeout=1"; + + var outcome = MigrationRunner.TryRun(unreachable, maxAttempts: 2, firstDelay: TimeSpan.FromMilliseconds(1)); + + // The behaviour that stops a free-tier container restart-looping and burning quota. + Assert.False(outcome.Successful); + Assert.NotNull(outcome.Error); + Assert.Equal(2, outcome.Attempts); + } + + /// Deterministic stand-in: the seeder only needs *a* hasher, not a slow one. + private sealed class PlainHasher : IPasswordHasher + { + public string Hash(string password) => "plain:" + password; + + public bool Verify(string password, string hash) => hash == "plain:" + password; + } +} diff --git a/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj b/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj new file mode 100644 index 0000000..e89398f --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + From 7f667eab437f6cd1905101f24b1b92836f651bef Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:46:47 -0500 Subject: [PATCH 6/9] test: cover the payment, Google, JWT and email adapters; add coverage settings Takes the backend from 83% to 95.5% merged line coverage by testing the adapters that were still dark, all of them on paths where being wrong is expensive. - The Stripe adapter through a stub transport: the amount really is sent in minor units and rounded away from zero, the order number really is in metadata (which is how the webhook finds the order later), and each PaymentIntent status maps to the branch checkout expects. A status mapped to the wrong branch either ships goods nobody paid for or cancels an order that settled. - Google ID tokens, validated end to end against a locally generated RSA key served as JWKS. Tested by forging: right shape wrong signer, right signer wrong audience, right everything but expired. Each is refused, silently, and Google being unreachable refuses rather than surfacing as a 500 from our login endpoint. - The 2FA challenge token, which deliberately grants nothing on its own: an access token presented in its place is rejected on the purpose claim alone, and a tampered signature, foreign issuer, or foreign audience are all refused. - Email, where two historical bugs lived. The MIME shape (plain body, single HTML alternate view) and the HTML escaping of interpolated values are now assertions rather than memories. Building the message moved out of SmtpEmailSender so it can be checked without an SMTP server; delivery itself still needs one and stays uncovered. coverlet.runsettings makes the number honest. Composition roots are excluded with the reason written down -- asserting "AddScoped was called" restates the code, and the smoke test already proves the container resolves. CompilerGeneratedAttribute is deliberately NOT in the exclude list: every async method compiles to a state machine carrying it, so excluding it silently drops most of the codebase and reports a meaningless number. That cost an hour, and the comment exists so it costs nobody else one. Application 95.0%, Domain 95.7%, Infrastructure 96.2%. 348 backend tests. Co-Authored-By: Claude Opus 5 --- coverlet.runsettings | 33 +++ .../Email/SmtpEmailSender.cs | 48 ++-- .../ChallengeTokenTests.cs | 162 ++++++++++++ .../EmailMessageTests.cs | 215 ++++++++++++++++ .../GoogleTokenValidatorTests.cs | 237 +++++++++++++++++ .../SmallServicesTests.cs | 243 ++++++++++++++++++ .../StripeGatewayTests.cs | 215 ++++++++++++++++ 7 files changed, 1135 insertions(+), 18 deletions(-) create mode 100644 coverlet.runsettings create mode 100644 tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs create mode 100644 tests/WidgetWorks.UnitTests/EmailMessageTests.cs create mode 100644 tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs create mode 100644 tests/WidgetWorks.UnitTests/SmallServicesTests.cs create mode 100644 tests/WidgetWorks.UnitTests/StripeGatewayTests.cs diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..7ed8acb --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,33 @@ + + + + + + + cobertura + + + [*]*.DependencyInjection,[WidgetWorks.WebApi]Program + + Obsolete,GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute + false + false + false + + + + + diff --git a/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs b/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs index 5576ee2..0356eab 100644 --- a/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs +++ b/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs @@ -15,24 +15,7 @@ public sealed class SmtpEmailSender(EmailOptions options) : IEmailSender { public async Task SendAsync(EmailMessage message, CancellationToken ct) { - using var mail = new MailMessage - { - From = new MailAddress(options.FromAddress, options.FromName), - Subject = message.Subject, - SubjectEncoding = Encoding.UTF8, - - // The plain-text version IS the body, and the HTML version rides alongside as the - // preferred alternative — the canonical multipart/alternative shape. Leaving Body - // empty and adding BOTH text and HTML as alternate views produced a message whose - // HTML part mail clients rendered as blank. - Body = message.TextBody, - BodyEncoding = Encoding.UTF8, - IsBodyHtml = false, - }; - - mail.To.Add(message.To); - mail.AlternateViews.Add( - AlternateView.CreateAlternateViewFromString(message.HtmlBody, Encoding.UTF8, MediaTypeNames.Text.Html)); + using var mail = BuildMailMessage(options, message); using var client = new SmtpClient(options.Host, options.Port) { @@ -54,4 +37,33 @@ public async Task SendAsync(EmailMessage message, CancellationToken ct) throw; } } + + /// + /// Builds the MIME message. Separated from delivery so its shape can be asserted without an SMTP + /// server — this is where the bugs actually were: an HTML part that rendered blank, and headers + /// that mangled non-ASCII subjects. + /// + public static MailMessage BuildMailMessage(EmailOptions options, EmailMessage message) + { + var mail = new MailMessage + { + From = new MailAddress(options.FromAddress, options.FromName), + Subject = message.Subject, + SubjectEncoding = Encoding.UTF8, + + // The plain-text version IS the body, and the HTML version rides alongside as the + // preferred alternative — the canonical multipart/alternative shape. Leaving Body + // empty and adding BOTH text and HTML as alternate views produced a message whose + // HTML part mail clients rendered as blank. + Body = message.TextBody, + BodyEncoding = Encoding.UTF8, + IsBodyHtml = false, + }; + + mail.To.Add(message.To); + mail.AlternateViews.Add( + AlternateView.CreateAlternateViewFromString(message.HtmlBody, Encoding.UTF8, MediaTypeNames.Text.Html)); + + return mail; + } } diff --git a/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs b/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs new file mode 100644 index 0000000..e24ab3f --- /dev/null +++ b/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.Options; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Security; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The short-lived token that carries a half-authenticated user between "password accepted" and +/// "second factor proved". It is the one credential in the system that deliberately grants nothing +/// on its own, so the tests are mostly about what it must refuse: an access token presented in its +/// place, a token past its five minutes, one signed by a key the ring does not know, and one whose +/// signature has been tampered with. +/// +public class ChallengeTokenTests +{ + // Issuance uses the injected clock, but ValidateTokenAsync checks lifetime against the system + // clock — TokenValidationParameters has no TimeProvider. Correct in production, where the two + // agree; in tests it means a token minted at a fixed past date is already expired. So these + // tests anchor on real time and move the *issuing* clock to express age. + private static DateTimeOffset Now => DateTimeOffset.UtcNow; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static JwtOptions Options(string key = "test-signing-key-that-is-long-enough-0123456789", string kid = "wk-1") => new() + { + Issuer = "https://localhost", + Audience = "widgetworks", + SigningKey = key, + KeyId = kid, + AccessTokenMinutes = 15, + RefreshTokenDays = 14, + }; + + private static JwtTokenService Service(DateTimeOffset now, JwtOptions? options = null) + { + var o = options ?? Options(); + return new JwtTokenService(Microsoft.Extensions.Options.Options.Create(o), new JwtKeyRing(o), new FixedClock(now)); + } + + private static User TheUser { get; } = new() + { + Id = Guid.Parse("11111111-2222-3333-4444-555555555555"), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + }; + + [Fact] + public async Task A_freshly_issued_challenge_resolves_to_its_user() + { + var service = Service(Now); + + var token = service.CreateChallengeToken(TheUser); + + Assert.Equal(TheUser.Id, await service.ValidateChallengeTokenAsync(token)); + } + + [Fact] + public async Task An_access_token_is_not_accepted_as_a_challenge() + { + var service = Service(Now); + + // Both are signed by the same key and would validate structurally; only the purpose claim + // separates them. Without that check, a full access token would satisfy the 2FA step. + var access = service.CreateAccessToken(TheUser); + + Assert.Null(await service.ValidateChallengeTokenAsync(access.Value)); + } + + [Fact] + public async Task A_challenge_still_works_a_few_minutes_in() + { + var issuedFourMinutesAgo = Service(Now.AddMinutes(-4)).CreateChallengeToken(TheUser); + + Assert.Equal(TheUser.Id, await Service(Now).ValidateChallengeTokenAsync(issuedFourMinutesAgo)); + } + + [Fact] + public async Task A_challenge_older_than_five_minutes_is_refused() + { + var stale = Service(Now.AddMinutes(-30)).CreateChallengeToken(TheUser); + + // A half-authenticated session must not stay open indefinitely. + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(stale)); + } + + [Fact] + public async Task A_challenge_signed_by_an_unknown_key_is_rejected() + { + var foreign = Service(Now, Options(key: "a-completely-different-signing-key-9876543210", kid: "other")) + .CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public async Task A_tampered_challenge_is_rejected() + { + var service = Service(Now); + var token = service.CreateChallengeToken(TheUser); + + // Flip the last character of the signature segment. + var parts = token.Split('.'); + var signature = parts[2]; + parts[2] = signature[..^1] + (signature[^1] == 'A' ? 'B' : 'A'); + + Assert.Null(await service.ValidateChallengeTokenAsync(string.Join('.', parts))); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-token")] + [InlineData("a.b.c")] + public async Task Garbage_is_rejected_without_throwing(string token) + { + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(token)); + } + + [Fact] + public async Task A_challenge_for_a_different_audience_is_rejected() + { + var options = Options(); + options.Audience = "some-other-app"; + var foreign = Service(Now, options).CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public async Task A_challenge_from_a_different_issuer_is_rejected() + { + var options = Options(); + options.Issuer = "https://not-us"; + var foreign = Service(Now, options).CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public void A_refresh_token_is_random_each_time_and_hashes_to_its_own_value() + { + var service = Service(Now); + var family = Guid.NewGuid(); + + var first = service.CreateRefreshToken(family); + var second = service.CreateRefreshToken(family); + + Assert.NotEqual(first.Value, second.Value); + Assert.Equal(family, first.FamilyId); + Assert.Equal(service.HashRefreshToken(first.Value), first.Hash); + Assert.NotEqual(first.Hash, second.Hash); + + // 14 days from the injected clock. + Assert.Equal(14, Math.Round((first.ExpiresAt - Now).TotalDays)); + } +} diff --git a/tests/WidgetWorks.UnitTests/EmailMessageTests.cs b/tests/WidgetWorks.UnitTests/EmailMessageTests.cs new file mode 100644 index 0000000..99d9071 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/EmailMessageTests.cs @@ -0,0 +1,215 @@ +using System.Net.Mime; +using System.Text; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Notifications; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Infrastructure.Email; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// What actually lands in someone's inbox. Two real bugs live here in history: the HTML part +/// rendering blank because the message was assembled as two alternate views with no body, and a +/// widget name containing an ampersand corrupting the layout because values were interpolated into +/// HTML unescaped. Both are now assertions rather than memories. +/// +public class EmailMessageTests +{ + private static readonly EmailOptions Options = new() + { + FromAddress = "no-reply@widgetworks.demo", + FromName = "WidgetWorks", + Host = "localhost", + Port = 1025, + }; + + private static Order OrderWith(string widgetName, decimal price = 1234.56m) => new() + { + Id = Guid.NewGuid(), + OrderNumber = "WW-20260501-ABC123", + Email = "jane@example.com", + ShipName = "Jane Doe", + ShipLine1 = "1 Main St", + ShipCity = "Springfield", + ShipState = "CA", + ShipPostalCode = "90210", + ShipCountry = "US", + Subtotal = price, + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 89.51m, + Total = price + 6.99m + 89.51m, + Status = OrderStatus.Paid, + CreatedAt = new DateTimeOffset(2026, 5, 1, 8, 0, 0, TimeSpan.Zero), + Items = + [ + new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = Guid.NewGuid(), + Sku = "WW-001", + Name = widgetName, + UnitPrice = price, + Quantity = 1, + LineSubtotal = price, + }, + ], + }; + + // ---- MIME shape ---------------------------------------------------------------------- + + [Fact] + public void The_body_is_plain_text_with_html_as_the_alternative() + { + var message = new EmailMessage("jane@example.com", "Subject", "

Hi

", "Hi"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + // The shape that made the HTML part render as blank was: empty Body + two alternate views. + Assert.Equal("Hi", mail.Body); + Assert.False(mail.IsBodyHtml); + var html = Assert.Single(mail.AlternateViews); + Assert.Equal(MediaTypeNames.Text.Html, html.ContentType.MediaType); + } + + [Fact] + public void Everything_is_utf8_so_accents_and_currency_survive() + { + var message = new EmailMessage("jörg@example.com", "Your order — £12.50 réservé", "

£

", "£12.50 réservé"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + Assert.Equal(Encoding.UTF8, mail.SubjectEncoding); + Assert.Equal(Encoding.UTF8, mail.BodyEncoding); + Assert.Equal(Encoding.UTF8, mail.AlternateViews[0].ContentType.CharSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(mail.AlternateViews[0].ContentType.CharSet!)); + } + + [Fact] + public void The_sender_and_recipient_come_from_configuration_and_the_message() + { + var message = new EmailMessage("jane@example.com", "Subject", "

Hi

", "Hi"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + Assert.Equal("no-reply@widgetworks.demo", mail.From!.Address); + Assert.Equal("WidgetWorks", mail.From.DisplayName); + Assert.Equal("jane@example.com", Assert.Single(mail.To).Address); + } + + // ---- template escaping --------------------------------------------------------------- + + [Fact] + public void A_widget_name_with_markup_characters_is_escaped_in_the_html() + { + var order = OrderWith("Widget & Co \"Special\""); + + var email = EmailTemplates.OrderReceived(order); + + // Escaped in HTML... + Assert.Contains("&", email.HtmlBody); + Assert.Contains("<Pro>", email.HtmlBody); + Assert.DoesNotContain("", email.HtmlBody); + + // ...and left alone in the text part, where it is not markup. + Assert.Contains("Widget & Co ", email.TextBody); + } + + [Fact] + public void The_html_is_a_document_with_a_charset_so_clients_do_not_guess() + { + var email = EmailTemplates.OrderReceived(OrderWith("Standard Widget")); + + Assert.Contains(" Assert.Equal(order.Email, e.To)); + } + + [Fact] + public void Account_emails_are_addressed_and_carry_their_link() + { + var welcome = AccountEmailTemplates.Welcome("jane@example.com"); + var reset = AccountEmailTemplates.PasswordReset("jane@example.com", "https://app.test/reset?token=abc"); + + Assert.Equal("jane@example.com", welcome.To); + Assert.NotEmpty(welcome.HtmlBody); + Assert.Equal("jane@example.com", reset.To); + Assert.Contains("https://app.test/reset?token=abc", reset.TextBody); + Assert.Contains("https://app.test/reset?token=abc", reset.HtmlBody); + } + + [Fact] + public void Every_template_supplies_both_a_text_and_an_html_part() + { + var order = OrderWith("Standard Widget"); + + EmailMessage[] all = + [ + EmailTemplates.OrderReceived(order), + EmailTemplates.OrderShipped(order), + EmailTemplates.OrderCancelled(order), + AccountEmailTemplates.Welcome("jane@example.com"), + AccountEmailTemplates.PasswordReset("jane@example.com", "https://app.test/r"), + ]; + + Assert.All(all, e => + { + Assert.False(string.IsNullOrWhiteSpace(e.Subject)); + Assert.False(string.IsNullOrWhiteSpace(e.TextBody)); + Assert.False(string.IsNullOrWhiteSpace(e.HtmlBody)); + }); + } +} diff --git a/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs b/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs new file mode 100644 index 0000000..17f17aa --- /dev/null +++ b/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using WidgetWorks.Infrastructure.Security; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Google ID-token validation, exercised end to end against a locally generated RSA key served as +/// a JWKS by a stub transport. This is the only place a stranger's assertion becomes an account, so +/// it is tested by forging tokens: right shape, wrong signer; right signer, wrong audience; right +/// everything, expired. Each must be refused, and the refusal must be silent (null) rather than an +/// exception the endpoint would have to interpret. +/// +public class GoogleTokenValidatorTests +{ + private const string ClientId = "866620806528-test.apps.googleusercontent.com"; + + private static readonly RsaSecurityKey GoogleKey = new(RSA.Create(2048)) { KeyId = "test-key-1" }; + private static readonly RsaSecurityKey ImposterKey = new(RSA.Create(2048)) { KeyId = "test-key-1" }; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static string Jwks(RsaSecurityKey key) + { + var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(key); + jwk.KeyId = key.KeyId; + return JsonSerializer.Serialize(new { keys = new[] { jwk } }); + } + + private static string IdToken( + RsaSecurityKey signer, + string issuer = "https://accounts.google.com", + string audience = ClientId, + string? sub = "google-sub-123", + string? email = "jane@example.com", + bool emailVerified = true, + string? name = "Jane Doe", + int expiresInMinutes = 30) + { + var claims = new Dictionary(); + if (sub is not null) claims["sub"] = sub; + if (email is not null) claims["email"] = email; + if (name is not null) claims["name"] = name; + claims["email_verified"] = emailVerified; + + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + IssuedAt = DateTime.UtcNow.AddMinutes(-1), + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes), + SigningCredentials = new SigningCredentials(signer, SecurityAlgorithms.RsaSha256), + Claims = claims, + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + private static (GoogleTokenValidator Validator, StubJwks Jwks) Build( + string? jwksBody = null, + HttpStatusCode status = HttpStatusCode.OK, + string clientId = ClientId) + { + var handler = new StubJwks(status, jwksBody ?? Jwks(GoogleKey)); + var validator = new GoogleTokenValidator( + new HttpClient(handler), + new GoogleOptions { ClientId = clientId }, + new FixedClock(DateTimeOffset.UtcNow)); + return (validator, handler); + } + + [Fact] + public async Task A_genuine_token_yields_the_identity() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + + Assert.NotNull(identity); + Assert.Equal("google-sub-123", identity!.Subject); + Assert.Equal("jane@example.com", identity.Email); + Assert.True(identity.EmailVerified); + Assert.Equal("Jane Doe", identity.Name); + } + + [Fact] + public async Task A_token_signed_by_someone_else_is_refused() + { + var (validator, _) = Build(); + + // Same key id, same claims, different private key — the whole point of checking signatures. + var forged = IdToken(ImposterKey); + + Assert.Null(await validator.ValidateAsync(forged, CancellationToken.None)); + } + + [Fact] + public async Task A_token_for_another_application_is_refused() + { + var (validator, _) = Build(); + + var otherApp = IdToken(GoogleKey, audience: "someone-elses-client-id.apps.googleusercontent.com"); + + Assert.Null(await validator.ValidateAsync(otherApp, CancellationToken.None)); + } + + [Fact] + public async Task A_token_from_another_issuer_is_refused() + { + var (validator, _) = Build(); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, issuer: "https://evil.test"), CancellationToken.None)); + } + + [Theory] + [InlineData("https://accounts.google.com")] + [InlineData("accounts.google.com")] + public async Task Both_issuer_spellings_google_uses_are_accepted(string issuer) + { + var (validator, _) = Build(); + + Assert.NotNull(await validator.ValidateAsync(IdToken(GoogleKey, issuer: issuer), CancellationToken.None)); + } + + [Fact] + public async Task An_expired_token_is_refused() + { + var (validator, _) = Build(); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, expiresInMinutes: -30), CancellationToken.None)); + } + + [Fact] + public async Task A_token_without_an_email_is_refused() + { + var (validator, _) = Build(); + + // The app keys accounts on email; an identity without one cannot be provisioned. + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, email: null), CancellationToken.None)); + } + + [Fact] + public async Task An_unverified_email_is_returned_but_flagged() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey, emailVerified: false), CancellationToken.None); + + // The validator reports; the login handler decides. Refusing here would hide the reason. + Assert.NotNull(identity); + Assert.False(identity!.EmailVerified); + } + + [Fact] + public async Task A_token_with_no_name_still_validates() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey, name: null), CancellationToken.None); + + Assert.NotNull(identity); + Assert.Null(identity!.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-jwt")] + public async Task Garbage_is_refused_without_calling_google(string token) + { + var (validator, jwks) = Build(); + + Assert.Null(await validator.ValidateAsync(token, CancellationToken.None)); + if (string.IsNullOrWhiteSpace(token)) + { + Assert.Equal(0, jwks.Calls); + } + } + + [Fact] + public async Task Google_sign_in_is_off_when_no_client_id_is_configured() + { + var (validator, jwks) = Build(clientId: ""); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + Assert.Equal(0, jwks.Calls); + } + + [Fact] + public async Task An_unreachable_key_endpoint_refuses_rather_than_throws() + { + var (validator, _) = Build(status: HttpStatusCode.ServiceUnavailable, jwksBody: "unavailable"); + + // Google being down must not surface as a 500 from our login endpoint. + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + } + + [Fact] + public async Task Malformed_key_material_refuses_rather_than_throws() + { + var (validator, _) = Build(jwksBody: "{ not json"); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + } + + [Fact] + public async Task The_key_set_is_fetched_once_and_reused() + { + var (validator, jwks) = Build(); + + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + + // Google's keys rotate slowly; refetching per sign-in would be a self-inflicted rate limit. + Assert.Equal(1, jwks.Calls); + } + + private sealed class StubJwks(HttpStatusCode status, string body) : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } +} diff --git a/tests/WidgetWorks.UnitTests/SmallServicesTests.cs b/tests/WidgetWorks.UnitTests/SmallServicesTests.cs new file mode 100644 index 0000000..02d66f5 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/SmallServicesTests.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Carts.UpdateItem; +using WidgetWorks.Application.Catalog; +using WidgetWorks.Domain.Carts; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Infrastructure.Security; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The small pieces that are easy to leave untested and expensive to get wrong: the two token +/// generators (where "random" and "one-way" are the whole contract), the catalog read model, and +/// the cart quantity rules. +/// +public class SmallServicesTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); + + // ---- recovery codes ------------------------------------------------------------------ + + [Fact] + public void Recovery_codes_are_unique_lowercase_hex_and_stored_only_as_hashes() + { + var codes = new RecoveryCodeService().Generate(10); + + Assert.Equal(10, codes.Count); + Assert.Equal(10, codes.Select(c => c.Plain).Distinct().Count()); + Assert.All(codes, c => + { + Assert.Equal(10, c.Plain.Length); // 5 bytes as hex + Assert.Equal(c.Plain.ToLowerInvariant(), c.Plain); // typed by a human under stress + Assert.NotEqual(c.Plain, c.Hash); // never stored in the clear + }); + } + + [Fact] + public void Recovery_code_hashing_is_deterministic_and_case_sensitive() + { + var service = new RecoveryCodeService(); + + Assert.Equal(service.Hash("abc123"), service.Hash("abc123")); + Assert.NotEqual(service.Hash("abc123"), service.Hash("abc124")); + + // The login handler lowercases before hashing; the hash itself must not do it silently. + Assert.NotEqual(service.Hash("ABC123"), service.Hash("abc123")); + } + + [Fact] + public void Generating_zero_codes_is_allowed_and_yields_nothing() + { + Assert.Empty(new RecoveryCodeService().Generate(0)); + } + + // ---- opaque tokens ------------------------------------------------------------------- + + [Fact] + public void Secure_tokens_are_url_safe_and_never_repeat() + { + var generator = new SecureTokenGenerator(); + + var tokens = Enumerable.Range(0, 50).Select(_ => generator.Generate()).ToList(); + + Assert.Equal(50, tokens.Distinct().Count()); + Assert.All(tokens, t => + { + // base64url: it ends up in a reset link, so + / = would need escaping. + Assert.DoesNotContain('+', t); + Assert.DoesNotContain('/', t); + Assert.DoesNotContain('=', t); + Assert.True(t.Length >= 42); + }); + } + + [Fact] + public void Secure_token_hashing_is_deterministic_and_one_way() + { + var generator = new SecureTokenGenerator(); + var raw = generator.Generate(); + + var hash = generator.Hash(raw); + + Assert.Equal(hash, generator.Hash(raw)); + Assert.NotEqual(raw, hash); + Assert.Equal(64, hash.Length); // SHA-256 as hex + Assert.NotEqual(hash, generator.Hash(raw + "x")); + } + + // ---- catalog read model -------------------------------------------------------------- + + [Fact] + public void The_widget_view_reports_availability_net_of_reservations() + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-001", + Name = "Standard Widget", + Description = "Dependable.", + ImageUrl = null, + Price = 9.99m, + IsActive = true, + QuantityOnHand = 10, + QuantityReserved = 4, + }; + + var view = WidgetView.From(widget); + + Assert.Equal(widget.Id, view.Id); + Assert.Equal("WW-001", view.Sku); + Assert.Equal(9.99m, view.Price); + Assert.True(view.IsActive); + Assert.Equal(10, view.QuantityOnHand); + Assert.Equal(4, view.QuantityReserved); + + // What a shopper can actually buy — reserved stock belongs to someone else's order. + Assert.Equal(6, view.QuantityAvailable); + } + + [Fact] + public void Availability_never_goes_negative_even_if_reservations_exceed_stock() + { + var widget = new Widget { Id = Guid.NewGuid(), QuantityOnHand = 2, QuantityReserved = 5 }; + + Assert.Equal(0, WidgetView.From(widget).QuantityAvailable); + } + + // ---- cart quantity rules ------------------------------------------------------------- + + private sealed record Ctx(InMemoryCartRepository Carts, InMemoryWidgetRepository Widgets, Cart Cart, Widget Widget); + + private static Ctx Setup(int available = 5, bool active = true) + { + var widgets = new InMemoryWidgetRepository(); + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-001", + Name = "Standard Widget", + Price = 10m, + QuantityOnHand = available, + IsActive = active, + }; + widgets.Store[widget.Id] = widget; + + var carts = new InMemoryCartRepository(); + var cart = new Cart { Id = Guid.NewGuid(), CreatedAt = Now, UpdatedAt = Now }; + cart.Items.Add(new CartItem { CartId = cart.Id, WidgetId = widget.Id, Quantity = 2 }); + carts.Store[cart.Id] = cart; + + return new Ctx(carts, widgets, cart, widget); + } + + private static UpdateCartItemHandler Handler(Ctx c) => new(c.Carts, c.Widgets, new FakeTimeProvider(Now)); + + [Fact] + public async Task Setting_a_quantity_of_zero_removes_the_line() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 0), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Items); + } + + [Fact] + public async Task A_negative_quantity_removes_the_line_rather_than_erroring() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, -3), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Items); + } + + [Fact] + public async Task Asking_for_more_than_exists_caps_at_what_is_available() + { + var c = Setup(available: 5); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 99), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(5, result.Value!.Items.Single().Quantity); + } + + [Fact] + public async Task An_out_of_stock_widget_is_refused_with_a_reason() + { + var c = Setup(available: 0); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("This widget is out of stock.", result.Error); + } + + [Fact] + public async Task A_hidden_widget_cannot_be_added_to_a_cart() + { + var c = Setup(active: false); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Widget not found.", result.Error); + } + + [Fact] + public async Task An_unknown_widget_is_refused() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, Guid.NewGuid(), 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Widget not found.", result.Error); + } + + [Fact] + public async Task An_unknown_cart_is_refused() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(Guid.NewGuid(), c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + [Fact] + public async Task Updating_a_quantity_touches_the_cart() + { + var c = Setup(); + + await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 3), CancellationToken.None); + + Assert.Equal(Now, c.Carts.Store[c.Cart.Id].UpdatedAt); + } +} diff --git a/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs b/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs new file mode 100644 index 0000000..dc71f43 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs @@ -0,0 +1,215 @@ +using System.Net; +using Microsoft.Extensions.Options; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Infrastructure.Payments; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The Stripe adapter, driven through a stub transport. Two things are worth pinning down: the +/// request Stripe actually receives (amount in minor units, the order number in metadata so the +/// webhook can find the order again), and the mapping from PaymentIntent status to the three +/// outcomes checkout branches on — because a status mapped to the wrong branch either ships goods +/// that were never paid for or cancels an order that was. +/// +public class StripeGatewayTests +{ + private static (StripePaymentGateway Gateway, StubHandler Handler) Build( + HttpStatusCode status = HttpStatusCode.OK, + string body = """{"id":"pi_1","status":"succeeded"}""", + string secretKey = "sk_test_key") + { + var handler = new StubHandler(status, body); + var gateway = new StripePaymentGateway( + new HttpClient(handler), + Options.Create(new StripeOptions { SecretKey = secretKey, ApiBase = "https://api.stripe.test" })); + return (gateway, handler); + } + + private static PaymentRequest Request(decimal amount = 29.19m, string? token = "pm_card_visa") + => new("WW-20260501-ABC123", amount, "usd", "jane@example.com", token); + + [Fact] + public async Task It_declines_without_calling_stripe_when_no_key_is_configured() + { + var (gateway, handler) = Build(secretKey: ""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Stripe is not configured.", result.Error); + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task It_refuses_a_non_positive_amount_without_calling_stripe(decimal amount) + { + var (gateway, handler) = Build(); + + var result = await gateway.ChargeAsync(Request(amount), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Amount must be positive.", result.Error); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task It_sends_the_amount_in_minor_units_and_the_order_number_in_metadata() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(29.19m), CancellationToken.None); + + Assert.Contains("amount=2919", handler.LastBody); + Assert.Contains("currency=usd", handler.LastBody); + Assert.Contains("payment_method=pm_card_visa", handler.LastBody); + + // How the webhook correlates back to the order later. + Assert.Contains("WW-20260501-ABC123", handler.LastBody); + Assert.Equal("Bearer sk_test_key", handler.LastAuthorization); + Assert.Contains("/v1/payment_intents", handler.LastUrl); + } + + [Fact] + public async Task Rounding_to_minor_units_is_half_away_from_zero() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(10.005m), CancellationToken.None); + + // 1000.5 minor units must bill as 1001, not 1000. + Assert.Contains("amount=1001", handler.LastBody); + } + + [Fact] + public async Task A_missing_token_falls_back_to_the_test_card() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(token: null), CancellationToken.None); + + Assert.Contains("payment_method=pm_card_visa", handler.LastBody); + } + + [Fact] + public async Task A_succeeded_intent_is_a_completed_payment() + { + var (gateway, _) = Build(body: """{"id":"pi_abc","status":"succeeded"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Succeeded, result.Status); + Assert.Equal("pi_abc", result.Reference); + Assert.Equal("Stripe", result.Provider); + } + + [Theory] + [InlineData("requires_action")] + [InlineData("requires_confirmation")] + [InlineData("processing")] + public async Task An_unsettled_intent_parks_the_order_rather_than_failing_it(string status) + { + var (gateway, _) = Build(body: $$"""{"id":"pi_abc","status":"{{status}}","client_secret":"cs_123"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Pending, result.Status); + Assert.Equal("cs_123", result.ClientSecret); + } + + [Fact] + public async Task A_redirect_url_is_pulled_out_of_next_action() + { + const string body = """ + {"id":"pi_abc","status":"requires_action","next_action":{"redirect_to_url":{"url":"https://hooks.test/go"}}} + """; + var (gateway, _) = Build(body: body); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal("https://hooks.test/go", result.NextActionUrl); + } + + [Theory] + [InlineData("""{"id":"pi_abc","status":"requires_action"}""")] + [InlineData("""{"id":"pi_abc","status":"requires_action","next_action":null}""")] + [InlineData("""{"id":"pi_abc","status":"requires_action","next_action":{"type":"use_stripe_sdk"}}""")] + public async Task A_missing_redirect_is_null_rather_than_a_crash(string body) + { + var (gateway, _) = Build(body: body); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Pending, result.Status); + Assert.Null(result.NextActionUrl); + } + + [Theory] + [InlineData("canceled")] + [InlineData("requires_payment_method")] + [InlineData("")] + public async Task Any_other_status_is_a_decline(string status) + { + var (gateway, _) = Build(body: $$"""{"id":"pi_abc","status":"{{status}}"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Contains(status, result.Error); + } + + [Fact] + public async Task An_http_error_is_a_decline_carrying_the_status_code() + { + var (gateway, _) = Build(HttpStatusCode.PaymentRequired, """{"error":{"message":"card declined"}}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Stripe returned 402.", result.Error); + } + + [Fact] + public async Task An_intent_with_no_id_still_produces_a_usable_reference() + { + var (gateway, _) = Build(body: """{"status":"succeeded"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Succeeded, result.Status); + Assert.Equal("unknown", result.Reference); + } + + [Fact] + public void The_adapter_names_itself_so_the_webhook_route_can_match_it() + { + var (gateway, _) = Build(); + + Assert.Equal("Stripe", gateway.Name); + } + + /// Records what was sent and replies with a canned response — no network involved. + private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + public int Calls { get; private set; } + + public string LastBody { get; private set; } = string.Empty; + + public string LastUrl { get; private set; } = string.Empty; + + public string? LastAuthorization { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + LastUrl = request.RequestUri?.ToString() ?? string.Empty; + LastAuthorization = request.Headers.Authorization?.ToString(); + LastBody = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + } +} From fef880ec8ddd461e8a8ad86d72d2bc0000d797d0 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:49:13 -0500 Subject: [PATCH 7/9] ci: run the integration suite and gate deployment on coverage floors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test suite gained a fourth job — the repository integration tests, against a real PostgreSQL service container — and two floors that fail the build on a regression. The backend floor is checked in its own job rather than inline, because neither suite reaches it alone: the repositories are only exercised by the integration tests and the handlers only by the unit tests. Both upload their cobertura report, and the floor job merges them by taking the highest hit count per line. Summing or averaging would understate the real figure, since a line covered by one suite is missed by the other. Floors are floors, not targets: 90% backend, and frontend thresholds in vitest.config.ts. They exist to catch a regression, not to invite tests written to move a number. check-coverage.sh executes each python candidate before accepting it. Windows ships a "python3" App Execution Alias that resolves on PATH, prints an advert for the Store and exits 0 — which turned the gate into a no-op that always passed. It was only noticeable because the floor also "passed" when set to 99%. Both new action pins were resolved against the GitHub API before use rather than recalled. Co-Authored-By: Claude Opus 5 --- .github/workflows/test-suite.yml | 84 ++++++++++++++++++++++++++++++-- scripts/check-coverage.sh | 74 ++++++++++++++++++++++++++++ web/package.json | 3 +- web/vitest.config.ts | 8 +++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100755 scripts/check-coverage.sh diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index ccf9a32..71b8e8a 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -1,6 +1,6 @@ name: Test suite -# Reusable gate: every deployment calls this and will not proceed unless all three jobs pass. +# Reusable gate: every deployment calls this and will not proceed unless all four jobs pass. # Kept in one file so the API and web deploys cannot drift apart on what "tests passed" means. on: workflow_call: @@ -20,7 +20,81 @@ jobs: with: dotnet-version: '10.0.x' - name: Test - run: dotnet test WidgetWorks.slnx --configuration Release --nologo + run: > + dotnet test tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj + --configuration Release --nologo + --collect:"XPlat Code Coverage" --settings coverlet.runsettings + --results-directory ./TestResults + - name: Upload coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-unit + path: TestResults/**/coverage.cobertura.xml + retention-days: 1 + + integration: + name: Repository integration tests (PostgreSQL) + runs-on: ubuntu-latest + + # The Dapper repositories are mostly SQL — the atomic stock reservation, the ON CONFLICT + # upserts, the cascades. A real server is the only thing that can exercise them, so one runs + # here. The suite creates and drops its own throwaway database per run. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: widgetworks + POSTGRES_PASSWORD: replace-me-locally + POSTGRES_DB: widgetworks + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U widgetworks" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + - name: Test + env: + WIDGETWORKS_TEST_DB: Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally + run: > + dotnet test tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj + --configuration Release --nologo + --collect:"XPlat Code Coverage" --settings coverlet.runsettings + --results-directory ./TestResults + - name: Upload coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-integration + path: TestResults/**/coverage.cobertura.xml + retention-days: 1 + + coverage: + name: Backend coverage floor + runs-on: ubuntu-latest + needs: [backend, integration] + + # Neither suite reaches the floor alone — the repositories are only exercised by the + # integration tests, the handlers only by the unit tests. The floor applies to the merged + # figure, so it has to run after both and combine their reports. + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: TestResults + pattern: coverage-* + merge-multiple: false + - name: Check the floor + run: ./scripts/check-coverage.sh ./TestResults 90 frontend: name: Frontend unit tests (Vitest) @@ -33,8 +107,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install run: npm ci --no-audit --no-fund - - name: Test - run: npm test + + # Thresholds live in vitest.config.ts, so this fails the job if coverage regresses. + - name: Test with coverage + run: npm run test:coverage - name: Build (type-check + bundle) run: npm run build diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..c91afa7 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Fails the build when line coverage falls below a floor. +# +# A floor, not a target: it exists to catch a regression, not to be gamed to exactly 100%. +# It reads every cobertura report under the given directory and merges them by taking the +# highest hit count per line, because a line can be covered by one suite and missed by +# another — summing or averaging the files would understate the real figure. +# +# ./scripts/check-coverage.sh ./TestResults 80 + +set -euo pipefail + +RESULTS_DIR="${1:-./TestResults}" +FLOOR="${2:-80}" + +if ! find "$RESULTS_DIR" -name 'coverage.cobertura.xml' -print -quit | grep -q .; then + echo "::error::No coverage report found under $RESULTS_DIR — did the collector run?" + exit 1 +fi + +# python3 on CI runners; plain python on a Windows dev box. Each candidate is executed before +# being accepted: Windows ships a "python3" App Execution Alias that resolves on PATH, prints an +# advert for the Store and exits 0 — silently turning this gate into a no-op that always passes. +PYTHON="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c "import sys" >/dev/null 2>&1; then + PYTHON="$candidate" + break + fi +done + +if [ -z "$PYTHON" ]; then + echo "::error::a working python is required to summarise coverage" + exit 1 +fi + +"$PYTHON" - "$RESULTS_DIR" "$FLOOR" <<'PY' +import collections +import glob +import os +import sys +import xml.etree.ElementTree as ET + +results_dir, floor = sys.argv[1], float(sys.argv[2]) + +hits = collections.defaultdict(dict) +for report in glob.glob(os.path.join(results_dir, '**', 'coverage.cobertura.xml'), recursive=True): + for cls in ET.parse(report).getroot().iter('class'): + name = cls.get('filename', '').replace('\\', '/') + for line in cls.iter('line'): + number = int(line.get('number')) + hits[name][number] = max(hits[name].get(number, 0), int(line.get('hits'))) + +covered = sum(1 for lines in hits.values() for h in lines.values() if h > 0) +total = sum(len(lines) for lines in hits.values()) +if total == 0: + print('::error::Coverage report contained no lines') + raise SystemExit(1) + +pct = covered / total * 100 +print(f'Line coverage: {pct:.1f}% ({covered}/{total}); floor {floor:.0f}%') + +by_layer = collections.defaultdict(lambda: [0, 0]) +for name, lines in hits.items(): + layer = name.split('/')[0] + by_layer[layer][0] += sum(1 for h in lines.values() if h > 0) + by_layer[layer][1] += len(lines) +for layer, (c, t) in sorted(by_layer.items()): + print(f' {layer:<30} {c / t * 100:5.1f}%') + +if pct < floor: + print(f'::error::Line coverage {pct:.1f}% is below the {floor:.0f}% floor') + raise SystemExit(1) +PY diff --git a/web/package.json b/web/package.json index ae43d9e..6e44307 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "typecheck": "tsc -b --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "react": "^18.3.1", diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 9a6e8c5..ea2a722 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -23,6 +23,14 @@ export default defineConfig({ 'src/main.tsx', 'src/api/types.ts', ], + // A floor, not a target: it catches a regression rather than inviting tests written to + // hit a number. `npm run test:coverage` fails the run when coverage drops below it. + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 82, + }, }, }, }) From fb7b2815dce4cb375ca890c331d6ef14c171bbbc Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:53:34 -0500 Subject: [PATCH 8/9] docs: describe the four test layers, the coverage floors, and seven more bugs The testing chapter described three layers and a gate that no longer matches the workflow. It now covers all four -- backend unit, PostgreSQL integration, frontend component, end-to-end smoke -- with what each proves and what each needs, the real coverage figures, and the CI jobs including why the floor is checked in its own job rather than inline. The bugs table gains rows 33-39, a different class from the earlier ones: global state that only misbehaves outside the DI container, a coverage exclusion that silently dropped most of the codebase from measurement, a gate on Windows that reported success while doing nothing, an audit gate correctly rejecting a convenience dependency, a library that ignores the injected clock, and a test asserting a constraint the schema never had. Row 39 is a repeat of row 23 -- .at(-1) against an ES2020 target -- caught only because the build runs tsc and the test run does not. Left in rather than quietly fixed: a lesson that had to be learned twice is worth more written down than a table that implies it was learned once. Architecture records the two structural changes: the order owns its fulfilment transitions, and one pricer serves both the quote and the charge. Co-Authored-By: Claude Opus 5 --- docs/handbook/01-overview.md | 4 +- docs/handbook/02-architecture.md | 9 ++ docs/handbook/07-testing.md | 95 +++++++++++++++++++-- docs/handbook/08-bugs-and-lessons.md | 26 +++++- docs/handbook/README.md | 4 +- web/src/components/AddToCartButton.test.tsx | 6 +- web/src/pages/CheckoutPage.test.tsx | 4 +- 7 files changed, 133 insertions(+), 15 deletions(-) diff --git a/docs/handbook/01-overview.md b/docs/handbook/01-overview.md index 41b1aca..aaeabca 100644 --- a/docs/handbook/01-overview.md +++ b/docs/handbook/01-overview.md @@ -38,9 +38,10 @@ parts most demos skip, on clean, testable, time-abstracted code. | Auth | JWT (short-lived access + rotating refresh), per-user **security stamp**, `kid` key rotation, **TOTP 2FA** (Otp.NET), **Google OIDC** | | Time | `TimeProvider` everywhere for deterministic, testable time | | Payments | `IPaymentGateway` — Mock (default) + Stripe test mode | -| Web | **React 18 + TypeScript** (Vite 8) SPA, **Vitest** unit tests | +| Web | **React 18 + TypeScript** (Vite 8) SPA; **Vitest + Testing Library** | | Run | **Docker Compose** (db + api + web + **Mailpit** mail catcher) | | CI | GitHub Actions — gitleaks, build (warnings-as-errors) + tests, CodeQL, Dependabot, web build | +| Tests | 463 across four layers — backend unit, PostgreSQL integration, frontend component, end-to-end smoke. **95.5% backend / 89.5% frontend** lines, floors enforced in CI | | CD | Path-scoped deploys (API and web move independently; docs move nothing), each gated on the **whole** test suite | | Hosting | Azure **App Service F1** (API) + **Static Web Apps** (SPA) + **Key Vault** via managed identity, Postgres on **Neon** — all free tiers ([ch.10](10-deploy-azure-free.md)) | @@ -54,6 +55,7 @@ src/ WidgetWorks.WebApi Minimal API endpoints, DI, auth wiring tests/ WidgetWorks.UnitTests xUnit tests with in-memory fakes + FakeTimeProvider + WidgetWorks.IntegrationTests repository tests against a real PostgreSQL web/ React + TypeScript SPA (Vite) infra/ Provision.ps1 — idempotent Azure provisioning scripts/ smoke-test.ps1, deploy helpers, tooling diff --git a/docs/handbook/02-architecture.md b/docs/handbook/02-architecture.md index 178c078..bfd62b6 100644 --- a/docs/handbook/02-architecture.md +++ b/docs/handbook/02-architecture.md @@ -62,6 +62,15 @@ host, and infrastructure choices (DB, payment provider, email) are swappable beh - **`kid` key rotation** — a signing-key ring signs with the active key and still validates tokens signed by previous, non-revoked keys; unknown/revoked `kid` → rejected. - **2FA** — TOTP (authenticator app) with single-use, hashed recovery codes. +- **The order owns its fulfilment rules.** `OrderStatus.AllowedNext`/`CanTransition` hold the + transition table and `Order.TransitionTo` applies it, so the invariant travels with the + entity instead of living in whichever handler happens to call it. `UpdateOrderStatusHandler` + asks permission first and reports a refusal as a `Result` — a rejected transition is an + expected outcome at an API boundary, not an exception. +- **One pricer, two callers.** `OrderPricer` is the single calculation behind both + `POST /checkout/quote` and checkout itself, so the total a shopper is shown and the total + they are charged cannot drift apart. `OrderDraft` builds the order row, leaving + `CheckoutHandler` sequencing steps rather than performing them. - **RBAC** — policy-based: `ManageCatalog` (Manager or Administrator) guards catalog/orders; `ManageUsers` and `DeleteCatalog` are Administrator-only. Removing a widget is deliberately narrower than editing one: a Manager can create, edit, restock and hide, but not retire. diff --git a/docs/handbook/07-testing.md b/docs/handbook/07-testing.md index 477b471..f56d258 100644 --- a/docs/handbook/07-testing.md +++ b/docs/handbook/07-testing.md @@ -2,9 +2,19 @@ # 7. Testing & the smoke test -Three layers: fast **backend unit tests** (logic, no I/O), **frontend unit tests**, and an -**end-to-end smoke test** (the running API over HTTP). All three are the gate: no deployment -runs unless every one of them passes. +Four layers, and all four are the gate — no deployment runs unless every one passes: + +| Layer | What it proves | Needs | +|---|---|---| +| **Backend unit** (xUnit) | handler and domain logic | nothing | +| **Repository integration** (xUnit) | the SQL: reservations, constraints, cascades | PostgreSQL | +| **Frontend unit** (Vitest + Testing Library) | components render and behave | jsdom | +| **Smoke test** (PowerShell) | the running API over HTTP, end to end | Docker | + +**Coverage: 95.5% backend (merged), 86% frontend statements / 89.5% lines.** Floors are +enforced in CI — 90% backend, and thresholds in `vitest.config.ts` — so a regression fails +the build. They are floors, not targets: they catch a slide, they are not an invitation to +write tests that move a number. ## Backend unit tests @@ -36,6 +46,8 @@ CI runs `dotnet build -warnaserror` then `dotnet test` on every code change (see `web/**/*.test.ts` (Vitest) cover the logic that isn't worth a browser: +**Logic** + - **`api/client.test.ts`** — the token-refresh contract. The important case is the regression test for bug #12: fire several concurrent requests that all get a `401`, and assert the client issues **exactly one** refresh. Refresh tokens rotate, so a second @@ -43,14 +55,67 @@ CI runs `dotnet build -warnaserror` then `dotnet test` on every code change (see the single-flight guard is ever removed. - **`lib/catalog.test.ts`** — catalog filtering/sorting behaviour. +**Components** (Testing Library, jsdom) — the screens where a silent break costs the most: + +- **`ProtectedRoute`** — every combination of signed-in / staff-route / role, including the + half-written session (refresh token, no role) that must not open an admin screen. +- **`AdminWidgetsPage`** — nothing is sent before the delete confirmation, cancelling sends + nothing at all, and a Manager is never shown the control. +- **`CheckoutPage`** — totals come from the server and are re-fetched when the state or + shipping method changes; the selected payment method is the token actually submitted; a + decline leaves the shopper on the page with the reason. +- **`LoginPage`** — the 2FA branch stores no session until the code is verified, and a guest + cart merges on the way in without a merge failure undoing an accepted sign-in. +- **`Layout`**, **`CartPage`**, **`AdminOrderPage`**, the storefront and account pages. + Run them: ```bash cd web && npm test ``` +```bash +cd web && npm run test:coverage +``` + `npm run build` (tsc + Vite) runs alongside them in CI, so a type error fails the same gate. +> **jsdom does not implement ``.** `showModal`/`close` are absent, so any component +> built on the native modal throws on mount. `src/test/setup.ts` supplies minimal versions. + +## Repository integration tests + +`tests/WidgetWorks.IntegrationTests` runs the Dapper repositories against a **real +PostgreSQL**. This layer exists because the repositories are mostly SQL, and an in-memory +fake would only prove the fake works: + +- **Stock reservation.** Ten concurrent buyers, two units each, ten in stock — exactly five + may win. Overselling is prevented by a conditional `UPDATE` inside a transaction, and + nothing short of concurrent connections against a real server demonstrates that. +- **Transactional integrity** — a refused reservation rolls the order row back with it. +- **Constraints and indexes** — SKU uniqueness folded through `upper()`, the `ON CONFLICT` + cart upsert, cascading deletes. +- **Idempotent startup** — migrations journaled, and a seeder that can run on every boot + without duplicating an account or resetting a password someone changed. + +It creates and drops a **throwaway database per run**, migrated by the same DbUp scripts the +app runs at startup, so it never touches developer or demo data. Point it at any Postgres: + +```bash +docker compose up -d db +``` + +```bash +dotnet test tests/WidgetWorks.IntegrationTests +``` + +It defaults to the local compose database. Override with `WIDGETWORKS_TEST_DB` (a connection +string to the **`postgres`** maintenance database — the suite creates its own from there). + +> **Why not Testcontainers?** It pulls `SSH.NET 2024.2.0`, which carries a known +> high-severity advisory, and this repo builds with NuGet audit as an error. Using the +> Postgres that compose and CI already provide costs one environment variable instead. + ## End-to-end smoke test `scripts/smoke-test.ps1` drives the **running API** over HTTP and checks real responses. @@ -100,7 +165,7 @@ Sample: | **CodeQL** | code changes (public) | security-extended analysis | | **Web CI** | `web/**` changes | `npm run build` (tsc + Vite) | | **Smoke test** | code changes (docs ignored) | `docker compose up db api` → wait `/health` → run `smoke-test.ps1` | -| **Test suite** | called by both deploys | all three layers at once — backend units, frontend units + build, and the smoke test | +| **Test suite** | called by both deploys | all four layers plus the coverage floor — see below | | **Deploy API** | `main`, only for `src/**`, `tests/**`, `Dockerfile.api`, build files | `needs: tests` → publish Release → zip-deploy to App Service | | **Deploy web** | `main`, only for `web/**` | `needs: tests` → build the SPA → Static Web Apps | @@ -110,8 +175,23 @@ smoke workflow can also be run on demand from the Actions tab (`workflow_dispatc ### The deployment gate -`test-suite.yml` is a **reusable** workflow (`on: workflow_call`) with three jobs — backend -units, frontend units, smoke test. Both deploy workflows start with: +`test-suite.yml` is a **reusable** workflow (`on: workflow_call`) with five jobs: + +| Job | What it runs | +|---|---| +| `backend` | unit tests + coverage report | +| `integration` | repository tests against a PostgreSQL **service container** | +| `coverage` | `needs: [backend, integration]` — merges both reports, enforces the **90%** floor | +| `frontend` | Vitest with thresholds, then `tsc` + Vite build | +| `smoke` | compose up, wait for `/health`, run `smoke-test.ps1` | + +The floor is a separate job because **neither suite reaches it alone**: the repositories are +only exercised by the integration tests and the handlers only by the unit tests. Each uploads +its cobertura report; the floor job merges them by taking the highest hit count per line. +Summing or averaging would understate the real figure, because a line covered by one suite is +missed by the other. + +Both deploy workflows start with: ```yaml jobs: @@ -121,7 +201,8 @@ jobs: needs: tests ``` -so a failure in **any** of the three stops the deploy before a single artifact is uploaded. +so a failure in **any** job — including the coverage floor — stops the deploy before a single +artifact is uploaded. The web deploy runs the API smoke test too, deliberately: a SPA is useless against a broken API, so it isn't allowed to ship on frontend tests alone. diff --git a/docs/handbook/08-bugs-and-lessons.md b/docs/handbook/08-bugs-and-lessons.md index e26fa89..3a0686f 100644 --- a/docs/handbook/08-bugs-and-lessons.md +++ b/docs/handbook/08-bugs-and-lessons.md @@ -8,8 +8,10 @@ Two phases shaped the list. Early on the build environment could not install the so **code was authored without a local compiler and CI acted as the compiler** — which makes the discipline below load-bearing rather than optional. Later, with a local toolchain and a real deployment, the failures shifted: shells mangling arguments, a platform restarting a -crashing container, an identity provider presenting a subject nobody documented. Rows 1–11 -are from the first phase, 12–32 from the second. +crashing container, an identity provider presenting a subject nobody documented. A third pass +went after test coverage, and turned up a different class again: global state that only +misbehaves outside the DI container, and two gates that reported success while doing nothing. +Rows 1–11 are from the first phase, 12–32 from the second, 33–39 from the third. ## Bugs @@ -47,6 +49,13 @@ are from the first phase, 12–32 from the second. | 30 | Azure OIDC login failed with a valid federated credential | deploy workflow | GitHub presented an **immutable** subject (`repo:owner@id/repo@id:environment:production`), not the documented `repo:owner/repo:environment:name` form | Add federated credentials matching the subject actually presented | Read the subject from the failing token/log rather than from the docs | | 31 | A pinned action didn't exist | deploy workflow, immediately | I pinned `Azure/static-web-apps-deploy` to a SHA I had invented | Verify every pin against the GitHub API | A SHA pin is only safe if the SHA is real — resolve it, don't recall it | | 32 | Branch protection could never be satisfied | enabling required checks | The rule required a check named `ci.yml`, which no job publishes — and requiring **path-filtered** workflows deadlocks docs-only PRs (they never run, so they never report) | Require only `Secret scan (gitleaks)`, the one check that runs unconditionally | A required check must be one that runs on **every** PR | +| 33 | A repository read returned `null` for `tracking_number` and `order_number` while `status` and `email` were fine | writing the first integration test | Dapper's snake_case→PascalCase mapping is **global process state**, set inline inside `AddInfrastructure`. Anything constructing a repository outside the DI container never turned it on, so only single-word columns mapped | `DapperConfiguration.Apply()` — explicit, idempotent, called by both the container and the test fixture | Global mutable configuration is a hidden dependency; give it a name and call it, don't bury it in a registration method | +| 34 | Coverage collapsed to 39% the moment a runsettings file was added, and `OrderRepository` reported 2 tracked lines | the number moved the wrong way after a change that should not have moved it | `CompilerGeneratedAttribute` was in `ExcludeByAttribute`. Every `async` method compiles to a state machine carrying that attribute, so the exclusion silently removed almost the whole codebase from measurement | Drop it; keep only `GeneratedCode`, `Obsolete` and `ExcludeFromCodeCoverage`, with a comment saying why | Treat a coverage jump as suspicious in **both** directions — a number that improves for an unexplained reason is telling you the measurement broke | +| 35 | The coverage gate passed at a 99% floor on a 95% codebase | testing the gate's failure path, not its success path | `command -v python3` resolves on Windows to an **App Execution Alias** that prints an advert for the Store and exits 0, so the script's body never ran and the gate always "passed" | Execute each candidate interpreter before accepting it | A gate that has never been seen to fail is not known to work; test the red path first | +| 36 | `dotnet restore` failed the moment a test dependency was added | CI-equivalent restore locally | Testcontainers pulls `SSH.NET 2024.2.0`, which carries a known high-severity advisory, and the repo builds with NuGet audit as an error | Use the PostgreSQL that compose and CI already provide, via a connection-string env var | The audit gate works; when it fires on a *convenience* dependency, take the plainer route rather than weakening the gate | +| 37 | A challenge-token test failed against a correct implementation | writing tests around `ValidateChallengeTokenAsync` | Issuance uses the injected `TimeProvider` but `TokenValidationParameters` has no such hook, so lifetime is validated against the **system** clock. A token minted at a fixed past date is born expired | Anchor those tests on real time and move the *issuing* clock to express age | "Inject time everywhere" holds only as far as the libraries let it; find the seams that don't take your clock | +| 38 | An integration test asserted a uniqueness rule the schema does not have | the test failed | `ix_widgets_live_name` is a plain index for ordering the live set, not a unique one. Only SKU is unique (case-folded via `upper(sku)`) | Assert the rule that exists, and add a test documenting that names are deliberately **not** unique | Read the migration, not your memory of it — a test that asserts an imagined constraint fails honestly, but the same assumption in code would not | +| 39 | New frontend tests passed but `tsc` failed the build | running `npm run build`, not just `npm test` | `.at(-1)` again — the **same ES2022-against-ES2020 trap as row 23** — plus a `let x = null` only assigned inside a Promise executor, which TypeScript narrows to `never` | Index arithmetic, and `let release!: () => void` | Vitest transpiles without type-checking, so a green test run says nothing about the build. Run the gate CI runs | ## Lessons learned @@ -98,3 +107,16 @@ are from the first phase, 12–32 from the second. deadlocks any PR that doesn't touch those paths: it never runs, so it never reports. - **Don't trim data a projection depends on.** Skipping the item rows made the query cheaper and every order display `0 items`. Read the mapper before optimizing the query. +- **A measurement that improves for no reason is broken.** Coverage jumping the wrong way + after a settings change, and a floor "passing" when set above the actual figure, were both + the instrument failing rather than the code improving. Test a gate's red path before + trusting its green one. +- **Global mutable configuration is a hidden dependency.** Dapper's column mapping worked + perfectly through the container and silently mis-mapped everything outside it. Naming it + and calling it explicitly turned an invisible coupling into a one-line requirement. +- **A fake that lies is worse than no fake.** A no-op `TouchAsync` let handlers forget to + stamp the cart and still pass. Fakes have to model the behaviour they stand in for, or the + suite is decorative. +- **Some invariants only exist in the database.** Atomic stock reservation cannot be + demonstrated by any in-memory double; it needs concurrent connections to a real server. + Where the rule lives decides what kind of test can prove it. diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 81078ac..5206bd5 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -15,8 +15,8 @@ payments (sync + async/webhook), transactional email, and an order lifecycle. 4. [Configuration, secrets, email & 2FA](04-configuration-and-2fa.md) — what keys go where/how/why; email + Google setup; how to set up 2FA. 5. [Payments, tax & testing credit cards](05-payments.md) — how the total is built (shipping + per-state sales tax, worked examples, the rate table), Mock + Stripe test mode, async/webhooks, testing without charging a card, going live. 6. [Database & schema](06-database.md) — why PostgreSQL, migrations, tables and relationships. -7. [Testing & smoke test](07-testing.md) — unit tests, CI gates, and how to run the end-to-end smoke test. -8. [Bugs & lessons learned](08-bugs-and-lessons.md) — 32 real bugs, how each was found, fixed, and prevented — from CI-as-compiler through deployment. +7. [Testing & smoke test](07-testing.md) — four layers (backend unit, PostgreSQL integration, frontend component, end-to-end smoke), the coverage floors CI enforces, and how to run each locally. +8. [Bugs & lessons learned](08-bugs-and-lessons.md) — 39 real bugs, how each was found, fixed, and prevented — from CI-as-compiler through deployment and test coverage. 9. [Runbook — testing & going live](09-runbook.md) — step-by-step to test email, payments, and Google locally, and exactly what to change to go live. 10. [Deploying to Azure on free tiers](10-deploy-azure-free.md) — the whole stack for $0: F1 App Service, Static Web Apps, Key Vault with a managed identity, and Postgres on Neon. diff --git a/web/src/components/AddToCartButton.test.tsx b/web/src/components/AddToCartButton.test.tsx index 5fcfa8b..c083ab0 100644 --- a/web/src/components/AddToCartButton.test.tsx +++ b/web/src/components/AddToCartButton.test.tsx @@ -44,7 +44,9 @@ describe('AddToCartButton', () => { }) it('ignores a second click while the first is still in flight', async () => { - let release: (() => void) | null = null + // Declared via the resolver rather than a mutable local: TypeScript narrows a variable only + // assigned inside the executor to `never`, making it uncallable afterwards. + let release!: () => void const gate = new Promise((resolve) => { release = resolve }) vi.stubGlobal('fetch', vi.fn(async () => { @@ -61,7 +63,7 @@ describe('AddToCartButton', () => { expect(await screen.findByRole('button', { name: 'Adding…' })).toBeDisabled() await user.click(button) - release?.() + release() await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)) }) diff --git a/web/src/pages/CheckoutPage.test.tsx b/web/src/pages/CheckoutPage.test.tsx index 7795a76..4777ea3 100644 --- a/web/src/pages/CheckoutPage.test.tsx +++ b/web/src/pages/CheckoutPage.test.tsx @@ -85,7 +85,9 @@ describe('CheckoutPage', () => { await waitFor(() => expect(screen.getByText('$27.74')).toBeInTheDocument()) const quotes = calls.filter((c) => c.url.includes('/checkout/quote')) expect(quotes.length).toBeGreaterThan(1) - expect(JSON.parse(String(quotes.at(-1)?.body ?? quotes.at(-1)?.init?.body))).toMatchObject({ stateCode: 'OR' }) + // Indexed rather than .at(-1): that is an ES2022 API and the project targets ES2020. + const latest = quotes[quotes.length - 1] + expect(JSON.parse(String(latest.init?.body))).toMatchObject({ stateCode: 'OR' }) }) it('re-quotes when the shipping method changes', async () => { From 73a6515cf293bc6ef21f300ceb9d3b61b1710d0b Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:55:53 -0500 Subject: [PATCH 9/9] ci: give the .NET job a database, enforce formatting, add the coverage floor Adding the integration project to the solution quietly broke `ci.yml`: its test step runs `dotnet test` across the solution, which now needs a PostgreSQL. It gets one as a service container, so every PR proves the reservation logic rather than only a deployment does, and the coverage floor runs on the merged report from the same command. The formatting gate has carried `continue-on-error: true` since the scaffold, with a note saying to run `dotnet format` once a local SDK was available and then re-enable it. A local SDK is available, so: formatted (two files), gate enabled. A warning nobody reads is not a gate. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 34 +++++++++-- .../Pricing/StaticStateTaxRateProvider.cs | 60 +++++++++++++++---- .../DeleteWidgetTests.cs | 6 +- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc088eb..e5276fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,25 @@ jobs: build-test: name: Build & test (.NET) runs-on: ubuntu-latest + + # The repository suite talks to a real PostgreSQL: the atomic stock reservation and the + # schema constraints cannot be exercised by an in-memory fake. Running it here means every + # PR proves it, not just a deployment. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: widgetworks + POSTGRES_PASSWORD: replace-me-locally + POSTGRES_DB: widgetworks + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U widgetworks" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -41,17 +60,24 @@ jobs: - name: Restore run: dotnet restore - # BOOTSTRAP: scaffold was authored without a local SDK. Run `dotnet format` - # locally once, then set continue-on-error back to false to re-enable the gate. + # Enforced. The bootstrap exemption is spent: the tree was formatted once a local SDK was + # available, so a formatting drift is now a build failure rather than a warning nobody reads. - name: Verify formatting - continue-on-error: true run: dotnet format --verify-no-changes - name: Build (warnings as errors) run: dotnet build --no-restore -c Release -warnaserror - name: Test - run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage" + env: + WIDGETWORKS_TEST_DB: Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally + run: > + dotnet test --no-build -c Release + --collect:"XPlat Code Coverage" --settings coverlet.runsettings + --results-directory ./TestResults + + - name: Coverage floor + run: ./scripts/check-coverage.sh ./TestResults 90 dependency-review: name: Dependency review diff --git a/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs b/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs index d5084fe..6b259f5 100644 --- a/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs +++ b/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs @@ -18,16 +18,56 @@ public sealed class StaticStateTaxRateProvider : ITaxRateProvider // Base state sales-tax rates (approximate, state-level only) as decimal fractions. private static readonly IReadOnlyDictionary Rates = new Dictionary { - ["AL"] = 0.0400m, ["AK"] = 0.0000m, ["AZ"] = 0.0560m, ["AR"] = 0.0650m, ["CA"] = 0.0725m, - ["CO"] = 0.0290m, ["CT"] = 0.0635m, ["DE"] = 0.0000m, ["DC"] = 0.0600m, ["FL"] = 0.0600m, - ["GA"] = 0.0400m, ["HI"] = 0.0400m, ["ID"] = 0.0600m, ["IL"] = 0.0625m, ["IN"] = 0.0700m, - ["IA"] = 0.0600m, ["KS"] = 0.0650m, ["KY"] = 0.0600m, ["LA"] = 0.0445m, ["ME"] = 0.0550m, - ["MD"] = 0.0600m, ["MA"] = 0.0625m, ["MI"] = 0.0600m, ["MN"] = 0.06875m, ["MS"] = 0.0700m, - ["MO"] = 0.04225m, ["MT"] = 0.0000m, ["NE"] = 0.0550m, ["NV"] = 0.0685m, ["NH"] = 0.0000m, - ["NJ"] = 0.06625m, ["NM"] = 0.04875m, ["NY"] = 0.0400m, ["NC"] = 0.0475m, ["ND"] = 0.0500m, - ["OH"] = 0.0575m, ["OK"] = 0.0450m, ["OR"] = 0.0000m, ["PA"] = 0.0600m, ["RI"] = 0.0700m, - ["SC"] = 0.0600m, ["SD"] = 0.0420m, ["TN"] = 0.0700m, ["TX"] = 0.0625m, ["UT"] = 0.0610m, - ["VT"] = 0.0600m, ["VA"] = 0.0530m, ["WA"] = 0.0650m, ["WV"] = 0.0600m, ["WI"] = 0.0500m, + ["AL"] = 0.0400m, + ["AK"] = 0.0000m, + ["AZ"] = 0.0560m, + ["AR"] = 0.0650m, + ["CA"] = 0.0725m, + ["CO"] = 0.0290m, + ["CT"] = 0.0635m, + ["DE"] = 0.0000m, + ["DC"] = 0.0600m, + ["FL"] = 0.0600m, + ["GA"] = 0.0400m, + ["HI"] = 0.0400m, + ["ID"] = 0.0600m, + ["IL"] = 0.0625m, + ["IN"] = 0.0700m, + ["IA"] = 0.0600m, + ["KS"] = 0.0650m, + ["KY"] = 0.0600m, + ["LA"] = 0.0445m, + ["ME"] = 0.0550m, + ["MD"] = 0.0600m, + ["MA"] = 0.0625m, + ["MI"] = 0.0600m, + ["MN"] = 0.06875m, + ["MS"] = 0.0700m, + ["MO"] = 0.04225m, + ["MT"] = 0.0000m, + ["NE"] = 0.0550m, + ["NV"] = 0.0685m, + ["NH"] = 0.0000m, + ["NJ"] = 0.06625m, + ["NM"] = 0.04875m, + ["NY"] = 0.0400m, + ["NC"] = 0.0475m, + ["ND"] = 0.0500m, + ["OH"] = 0.0575m, + ["OK"] = 0.0450m, + ["OR"] = 0.0000m, + ["PA"] = 0.0600m, + ["RI"] = 0.0700m, + ["SC"] = 0.0600m, + ["SD"] = 0.0420m, + ["TN"] = 0.0700m, + ["TX"] = 0.0625m, + ["UT"] = 0.0610m, + ["VT"] = 0.0600m, + ["VA"] = 0.0530m, + ["WA"] = 0.0650m, + ["WV"] = 0.0600m, + ["WI"] = 0.0500m, ["WY"] = 0.0400m, }; } diff --git a/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs b/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs index bee4cec..9e76d42 100644 --- a/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs +++ b/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs @@ -23,7 +23,11 @@ private static (InMemoryWidgetRepository Repo, Widget Widget) Seed(int orderLine var repo = new InMemoryWidgetRepository(); var widget = new Widget { - Id = Guid.NewGuid(), Sku = "WW-900", Name = "Doomed", IsActive = true, QuantityOnHand = 5, + Id = Guid.NewGuid(), + Sku = "WW-900", + Name = "Doomed", + IsActive = true, + QuantityOnHand = 5, }; repo.Store[widget.Id] = widget; if (orderLines > 0)