From 63079dd393a2283f8298cf1f2f9578173f94c1d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:32:18 +0000 Subject: [PATCH 1/2] Initial plan From 4da333282d97ef34d146b3f4c2615e97d9ad290e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:40:43 +0000 Subject: [PATCH 2/2] Add quantity update functionality with comprehensive tests Co-authored-by: KyleMcMaster <11415127+KyleMcMaster@users.noreply.github.com> --- .../Basket/Basket.Component.fs | 19 +- .../Basket/Basket.Domain.fs | 28 +++ .../Basket/Basket.Page.fs | 20 ++ src/Microsoft.eShopWeb.Web/Program.fs | 1 + tests/Basket/UpdateBasketItemQuantity.fs | 222 ++++++++++++++++++ tests/FShopOnWeb.Tests.fsproj | 1 + 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/Basket/UpdateBasketItemQuantity.fs diff --git a/src/Microsoft.eShopWeb.Web/Basket/Basket.Component.fs b/src/Microsoft.eShopWeb.Web/Basket/Basket.Component.fs index 94f3c2b..66dfe01 100644 --- a/src/Microsoft.eShopWeb.Web/Basket/Basket.Component.fs +++ b/src/Microsoft.eShopWeb.Web/Basket/Basket.Component.fs @@ -13,6 +13,23 @@ module BasketComponent = module private Template = let itemTmpl index (item: BasketItem) = + let quantityControls = + div [ class' "d-flex align-items-center" ] + [ if item.Quantity > 1 then + Elem.form + [ method "post"; action "/basket/updatequantity"; class' "me-1" ] + [ input [ type' "hidden"; name "id"; value $"{item.CatalogItemId}" ] + input [ type' "hidden"; name "quantity"; value $"{item.Quantity - 1}" ] + input [ class' "btn btn-sm btn-outline-secondary"; type' "submit"; value "-" ] ] + else + Elem.span [ class' "btn btn-sm btn-outline-secondary me-1 disabled" ] [ raw "-" ] + Elem.span [ class' "mx-2" ] [ raw (item.Quantity.ToString()) ] + Elem.form + [ method "post"; action "/basket/updatequantity"; class' "ms-1" ] + [ input [ type' "hidden"; name "id"; value $"{item.CatalogItemId}" ] + input [ type' "hidden"; name "quantity"; value $"{item.Quantity + 1}" ] + input [ class' "btn btn-sm btn-outline-secondary"; type' "submit"; value "+" ] ] ] + article [ class' "esh-basket-items" ] [ div @@ -28,7 +45,7 @@ module BasketComponent = class' "esh-basket-image" ] ] section [ class' "esh-basket-item esh-basket-item--middle col" ] [ raw item.ProductName ] section [ class' "esh-basket-item esh-basket-item--middle col" ] [ raw (item.UnitPrice.ToString "C") ] - section [ class' "esh-basket-item esh-basket-item--middle col" ] [ raw (item.Quantity.ToString()) ] + section [ class' "esh-basket-item esh-basket-item--middle col" ] [ quantityControls ] section [ class' "esh-basket-item esh-basket-item--middle col" ] [ raw ((decimal(item.Quantity) * item.UnitPrice).ToString "C" ) ] section [ class' "esh-basket-item esh-basket-item--middle col" ] [ Elem.form diff --git a/src/Microsoft.eShopWeb.Web/Basket/Basket.Domain.fs b/src/Microsoft.eShopWeb.Web/Basket/Basket.Domain.fs index 1ba0b52..a0f8535 100644 --- a/src/Microsoft.eShopWeb.Web/Basket/Basket.Domain.fs +++ b/src/Microsoft.eShopWeb.Web/Basket/Basket.Domain.fs @@ -123,3 +123,31 @@ module BasketDomain = printfn $"Error removing item {catalogItemId} from basket"; printfn $"{exp}" return None } + + let updateBasketItemQuantity (db: ShopContext) catalogItemId newQuantity = + async { + // Validate quantity + if newQuantity < 1 then + return Error "Quantity must be at least 1" + else + let! existingBasket = + (db.Baskets.Include(fun b -> b.Items).OrderBy(fun b -> b.Id)) |> tryFirstAsync + + let basket = existingBasket |> defaultValue emptyBasket + + try + let itemToUpdate = + db.BasketItems.Where(fun bi -> bi.CatalogItemId = catalogItemId && bi.BasketId = basket.Id) + |> Seq.tryHead + + match itemToUpdate with + | Some item -> + item.Quantity <- newQuantity + do! saveChangesAsync' db |> Async.Ignore + return Ok newQuantity + | None -> + return Error "Item not found in basket" + with exp -> + printfn $"Error updating quantity for item {catalogItemId}"; printfn $"{exp}" + return Error "Failed to update item quantity" + } diff --git a/src/Microsoft.eShopWeb.Web/Basket/Basket.Page.fs b/src/Microsoft.eShopWeb.Web/Basket/Basket.Page.fs index ab1ef95..a9173ba 100644 --- a/src/Microsoft.eShopWeb.Web/Basket/Basket.Page.fs +++ b/src/Microsoft.eShopWeb.Web/Basket/Basket.Page.fs @@ -64,6 +64,26 @@ module BasketPage = | None -> Response.redirectPermanently "/basket?error=notfound" | Some _ -> Response.redirectPermanently "/basket?removed=success")) + let updateQuantity: HttpHandler = + Services.inject (fun db -> + + let mapAsync = fun (form: FormCollectionReader) -> + async { + let catalogItemId = form.TryGetGuid "id" + let quantity = form.TryGetInt32 "quantity" + + match catalogItemId, quantity with + | Some id, Some qty -> + return! BasketDomain.updateBasketItemQuantity db id qty + | _ -> + return Error "Invalid request parameters" + } |> Async.StartAsTask + + Request.mapFormAsync mapAsync (fun result -> + match result with + | Ok qty -> Response.redirectPermanently $"/basket?updated={qty}" + | Error msg -> Response.redirectPermanently $"/basket?error={msg}")) + // This uses a more low-level approach to reading the form let postAlternate: HttpHandler = Services.inject (fun db -> fun ctx -> diff --git a/src/Microsoft.eShopWeb.Web/Program.fs b/src/Microsoft.eShopWeb.Web/Program.fs index 868e953..7e16884 100644 --- a/src/Microsoft.eShopWeb.Web/Program.fs +++ b/src/Microsoft.eShopWeb.Web/Program.fs @@ -68,6 +68,7 @@ module Program = get "/basket" BasketPage.get post "/basket" BasketPage.post post "/basket/remove" BasketPage.remove + post "/basket/updatequantity" BasketPage.updateQuantity get "/identity/account/login" LoginPage.handler diff --git a/tests/Basket/UpdateBasketItemQuantity.fs b/tests/Basket/UpdateBasketItemQuantity.fs new file mode 100644 index 0000000..723248d --- /dev/null +++ b/tests/Basket/UpdateBasketItemQuantity.fs @@ -0,0 +1,222 @@ +module UpdateBasketItemQuantity + +open System +open System.Linq +open Xunit +open Microsoft.EntityFrameworkCore +open Microsoft.eShopWeb.Web +open Microsoft.eShopWeb.Web.Domain +open Microsoft.eShopWeb.Web.Persistence +open Microsoft.eShopWeb.Web.Basket.BasketDomain +open EntityFrameworkCore.FSharp.DbContextHelpers + +// Helper function to create an in-memory database context +let createInMemoryContext () = + let options = DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName = Guid.NewGuid().ToString()) + .Options + new ShopContext(options) + +// Helper function to seed test data +let seedTestData (context: ShopContext) = + let catalogItemId = Guid.NewGuid() + + let basketItem = { + Id = 0 + CatalogItemId = catalogItemId + ProductName = "Test Product" + UnitPrice = 10.0M + OldUnitPrice = 10.0M + Quantity = 5 + PictureUri = "/test.png" + BasketId = Unchecked.defaultof + } + + context.BasketItems.Add(basketItem) |> ignore + context.SaveChanges() |> ignore + + catalogItemId + +[] +let ``updateBasketItemQuantity should return Ok when updating to valid quantity`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let catalogItemId = seedTestData context + let newQuantity = 10 + + // Act + let! result = updateBasketItemQuantity context catalogItemId newQuantity + + // Assert + match result with + | Ok qty -> + Assert.Equal(newQuantity, qty) + + // Verify the quantity was actually updated in the database + let updatedItem = context.BasketItems.FirstOrDefault(fun bi -> bi.CatalogItemId = catalogItemId) + Assert.NotNull(updatedItem) + Assert.Equal(newQuantity, updatedItem.Quantity) + | Error msg -> + Assert.True(false, $"Expected Ok but got Error: {msg}") + } + +[] +let ``updateBasketItemQuantity should return Error when quantity is less than 1`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let catalogItemId = seedTestData context + let invalidQuantity = 0 + + // Act + let! result = updateBasketItemQuantity context catalogItemId invalidQuantity + + // Assert + match result with + | Ok _ -> Assert.True(false, "Expected Error but got Ok") + | Error msg -> Assert.Equal("Quantity must be at least 1", msg) + } + +[] +let ``updateBasketItemQuantity should return Error when quantity is negative`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let catalogItemId = seedTestData context + let invalidQuantity = -5 + + // Act + let! result = updateBasketItemQuantity context catalogItemId invalidQuantity + + // Assert + match result with + | Ok _ -> Assert.True(false, "Expected Error but got Ok") + | Error msg -> Assert.Equal("Quantity must be at least 1", msg) + } + +[] +let ``updateBasketItemQuantity should return Error when item does not exist`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let _ = seedTestData context + let nonExistentItemId = Guid.NewGuid() + let newQuantity = 5 + + // Act + let! result = updateBasketItemQuantity context nonExistentItemId newQuantity + + // Assert + match result with + | Ok _ -> Assert.True(false, "Expected Error but got Ok") + | Error msg -> Assert.Equal("Item not found in basket", msg) + } + +[] +let ``updateBasketItemQuantity should update quantity to 1`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let catalogItemId = seedTestData context + let newQuantity = 1 + + // Act + let! result = updateBasketItemQuantity context catalogItemId newQuantity + + // Assert + match result with + | Ok qty -> + Assert.Equal(newQuantity, qty) + + // Verify the quantity was actually updated in the database + let updatedItem = context.BasketItems.FirstOrDefault(fun bi -> bi.CatalogItemId = catalogItemId) + Assert.NotNull(updatedItem) + Assert.Equal(1, updatedItem.Quantity) + | Error msg -> + Assert.True(false, $"Expected Ok but got Error: {msg}") + } + +[] +let ``updateBasketItemQuantity should handle large quantities`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + let catalogItemId = seedTestData context + let largeQuantity = 1000 + + // Act + let! result = updateBasketItemQuantity context catalogItemId largeQuantity + + // Assert + match result with + | Ok qty -> + Assert.Equal(largeQuantity, qty) + + // Verify the quantity was actually updated in the database + let updatedItem = context.BasketItems.FirstOrDefault(fun bi -> bi.CatalogItemId = catalogItemId) + Assert.NotNull(updatedItem) + Assert.Equal(largeQuantity, updatedItem.Quantity) + | Error msg -> + Assert.True(false, $"Expected Ok but got Error: {msg}") + } + +[] +let ``updateBasketItemQuantity should not affect other basket items`` () = + async { + // Arrange + use context = createInMemoryContext() + context.Database.EnsureCreated() |> ignore + + let catalogItemId1 = Guid.NewGuid() + let catalogItemId2 = Guid.NewGuid() + + let basketItem1 = { + Id = 0 + CatalogItemId = catalogItemId1 + ProductName = "Test Product 1" + UnitPrice = 10.0M + OldUnitPrice = 10.0M + Quantity = 5 + PictureUri = "/test1.png" + BasketId = Unchecked.defaultof + } + + let basketItem2 = { + Id = 0 + CatalogItemId = catalogItemId2 + ProductName = "Test Product 2" + UnitPrice = 20.0M + OldUnitPrice = 20.0M + Quantity = 3 + PictureUri = "/test2.png" + BasketId = Unchecked.defaultof + } + + context.BasketItems.AddRange([basketItem1; basketItem2]) |> ignore + context.SaveChanges() |> ignore + + let newQuantity = 10 + + // Act - update only first item + let! result = updateBasketItemQuantity context catalogItemId1 newQuantity + + // Assert + match result with + | Ok _ -> + // Verify first item was updated + let updatedItem1 = context.BasketItems.FirstOrDefault(fun bi -> bi.CatalogItemId = catalogItemId1) + Assert.Equal(newQuantity, updatedItem1.Quantity) + + // Verify second item was not affected + let unchangedItem2 = context.BasketItems.FirstOrDefault(fun bi -> bi.CatalogItemId = catalogItemId2) + Assert.Equal(3, unchangedItem2.Quantity) + | Error msg -> + Assert.True(false, $"Expected Ok but got Error: {msg}") + } diff --git a/tests/FShopOnWeb.Tests.fsproj b/tests/FShopOnWeb.Tests.fsproj index 010f798..4a7770d 100644 --- a/tests/FShopOnWeb.Tests.fsproj +++ b/tests/FShopOnWeb.Tests.fsproj @@ -8,6 +8,7 @@ +