5 chap09
Jason Zhu edited this page 2021-09-22 16:49:53 +10:00

Chapter 09: SportsStore: Completing the Cart

Objective: Continue to build SportsStore app by adding basic support for a shopping cart.

9.1 Using Model Binding

MVC Framework uses model binding to create C# objects from HTTP requests (i.e. pass HTTP requests as parameter values to action methods).

  • Details of Model binding is in Chapter 24

Objective: Stop using session state feature in Cart controller to store and manage Cart objects. Instead, we create a custom model binder that obtains Cart object contained in the session data (by utilizing IModelBinder of Mvc)

9.1.1 Creating a Custom Model Binder

Objective: create a custom model binder to obtain Cart object in session data by implementing System.Web.Mvc.IModelBinder

Steps:

  1. Create new folder /Infrastructure/Binders in SportsStore.WebUI project. And CartModelBinder.cs in the directory
  2. Register CartModelBinder class in /Application_Start/Global.asax.cs file
  3. Update Cart controller to remove GetCart method, rely on model binder to provide controller with Cart object.

Listing 9-1. The Contents of the CartModelBinder.cs File

using System.Web.Mvc;
using SportsStore.Domain.Entities;

namespace SportsStore.WebUI.Infrastructure.Binders
{
    public class CartModelBinder : IModelBinder
    {
        private const string sessionKey = "Cart";

        public object BindModel(ControllerContext controllerContext,
            ModelBindingContext bindingContext)
        {

            // get the Cart from the session
            Cart cart = null;
            if (controllerContext.HttpContext.Session != null)
            {
                cart = (Cart)controllerContext.HttpContext.Session[sessionKey];
            }
            // create the Cart if there wasn't one in the session data
            if (cart == null)
            {
                cart = new Cart();
                if (controllerContext.HttpContext.Session != null)
                {
                    controllerContext.HttpContext.Session[sessionKey] = cart;
                }
            }
            // return the cart
            return cart;
        }
    }
}

Where:

  • IModelBinder interface defines BindModel. It takes 2 parameters (ControllerContext, ModelBindingContext) to create domain model object.
    • ControllerContext provides access to all information that the controller class has, which includes details of the request from the client.
    • ModelBindingContext provides information about model object about model object you are being asked to build
  • ControllerContext is what we interested in.
    • It has HttpContext property, which has Session property to get/set session data.
    • Cart object associated with the user's session can be obtained by reading values from session data.

Listing 9-2. Registering the CartModelBinder Class in the Global.asax.cs File

namespace SportsStore.WebUI
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            ...
            ModelBinders.Binders.Add(typeof(Cart), new CartModelBinder());
        }
    }
}

Objective: Update Cart controller to remove GetCart method and rely on model binder to provide controller with Cart objects.

Listing 9-3. Relying on the Model Binder in the CartController.cs File

...
namespace SportsStore.WebUI.Controllers
{
    public class CartController : Controller
    {
        private IProductRepository repository;

        public CartController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult Index(Cart cart, string returnUrl)
        {
            return View(new CartIndexViewModel
            {
                Cart = cart,
                ReturnUrl = returnUrl
            });
        }

        // GET: Cart
        public RedirectToRouteResult AddToCart(Cart cart, int productId, string returnUrl)
        {
            Product product = repository.Products
                .FirstOrDefault(p => p.ProductID == productId);

            if (product != null)
            {
                cart.AddItem(product, 1);
            }
            return RedirectToAction("Index", new { returnUrl });
        }

        public RedirectToRouteResult RemoveFromCart(Cart cart, int productId, string returnUrl)
        {
            Product product = repository.Products
                .FirstOrDefault(p => p.ProductID == productId);

            if (product != null)
            {
                cart.RemoveLine(product);
            }
            return RedirectToAction("Index", new { returnUrl });
        }
    }
}

Where:

  • GetCart method is removed. A Cart parameter is added to each action methods.
  • How MVC Framework use model binder (instead of using GetCart()):
    • When MVC Framework receives a request that requires (e.g. the AddToCart method), it looks for list of binders and tries to find one that can create instance of each parameter type.
      • When custom binder is asked to create a Cart object, it use session state feature.
      • Between the custom binder and default binder, MVC Framework is able to create the set of parameters required to call the action method, allowing us to refactor the controller as it has no knowledge of how Cart objects are created when requests are recieved.

Benefit of custom model binder:

  1. Seperate the logic used to create a Cart from controller, which allows us to change the way to store Cart objects w/o changing controller. (compared to Listing 8-14)
  2. Any controller class that works with Cart objects can simply declare them as action method parameters and take advantage of custom model binder.
  3. We can now unit test Cart contoller w/o needing to mock a lot of ASP.NET infrastructure.

UNIT TEST: THE CART CONTROLLER

Objective of UT:

  • AttToCart method should add the selected product to customer's cart
  • After adding a product to the cart, the user should be redirected to Index view
  • URL that the user can follow to return to the catalog should be correctly passed to Index method

Steps:

  1. Modify CartTests.cs file
namespace SportsStore.UnitTests {
    [TestClass]
    public class CartTests {
        ...
        [TestMethod]
        public void Can_Add_To_Cart()
        {
            // Arrange - create the mock repository
            Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns(new Product[]
            {
                new Product { ProductID = 1, Name = "P1", Category = "Apples" },
            }.AsQueryable());

            // Arrange - create a Cart
            Cart cart = new Cart();

            // Arrange - create the controller
            CartController target = new CartController(mock.Object);

            // Act - add a product to the cart
            target.AddToCart(cart, 1, null);

            // Assert
            Assert.AreEqual(cart.Lines.Count(), 1);
            Assert.AreEqual(cart.Lines.ToArray()[0].Product.ProductID, 1);
        }

        [TestMethod]
        public void Adding_Product_To_Cart_Goes_To_Cart_Screen()
        {
            // Arrange - create the mock repository
            Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns(new Product[]
            {
                new Product { ProductID = 1, Name = "P1", Category = "Apples" },
            }.AsQueryable());

            // Arrange - create a Cart
            Cart cart = new Cart();

            // Arrange - create the controller
            CartController target = new CartController(mock.Object);

            // Act - add a product to the 
            RedirectToRouteResult result = target.AddToCart(cart, 2, "myUrl");

            // Assert
            Assert.AreEqual(result.RouteValues["action"], "Index");
            Assert.AreEqual(result.RouteValues["returnUrl"], "myUrl");
        }

        [TestMethod]
        public void Can_View_Cart_Contents()
        {
            // Arrange - create a Cart
            Cart cart = new Cart();

            // Arrange - create the controller
            CartController target = new CartController(null);

            // Act - call the Index action methdo
            CartIndexViewModel result = (CartIndexViewModel)target.Index(cart, "myUrl").ViewData.Model;

            // Assert
            Assert.AreSame(result.Cart, cart);
            Assert.AreEqual(result.ReturnUrl, "myUrl");
        }

        ...
    }
}

9.2 Completing the Cart

Objective: add 2 features to cart

  1. Allow customer to remove an item from the cart
  2. Display a summary of the cart at top of the page

9.2.1 Removing Items from the Cart

Objective: Expose RemoveFromCart action method in Cart controller to a view, so customer can remove items.

Steps:

  1. Add a Remove button in each row of the cart summary, by change Views/Cart/Index.cshtml
    1. Changes are added with comment shown below
@model SportsStore.WebUI.Models.CartIndexViewModel

@{
    ViewBag.Title = "Sports Store: Your Cart";
}

@*Add style tag of cart table*@
<style>
    #cartTable td { vertical-align: middle; }
</style>

<h2>Your cart</h2>

@*Add id*@
<table id="cartTable" class="table">
    <thead>
    <tr>
        <th>Quantity</th>
        <th>Item</th>
        <th class="text-right">Price</th>
        <th class="text-right">Subtotal</th>
    </tr>
    </thead>
    <tbody>
    @foreach (var line in Model.Cart.Lines) {
        <tr>
            <td class="text-center">@line.Quantity</td>
            <td class="text-left">@line.Product.Name</td>
            <td class="text-right">@line.Product.Price.ToString("c")</td>
            <td class="text-right">
                @((line.Quantity * line.Product.Price).ToString("c"))
            </td>

            @*Add button*@
            <td>
                @using (Html.BeginForm("RemoveFromCart", "Cart"))
                {
                    @Html.Hidden("ProductId", line.Product.ProductID)
                    @Html.HiddenFor(x => x.ReturnUrl)
                    <input class="btn btn-sm btn-warning"
                           type="submit" value="Remove" />
                }
            </td>
        </tr>
    }
    </tbody>
    <tfoot>
    <tr>
        <td colspan="3" class="text-right">Total:</td>
        <td class="text-right">
            @Model.Cart.ComputeTotalValue().ToString("c")
        </td>
    </tr>
    </tfoot>
</table>

<div class="text-center">
    <a class="btn btn-primary" href="@Model.ReturnUrl">Continue shopping</a>
</div>

Where:

  • @Html.HiddenFor helper method is used (here) to create a hidden field (i.e. field with hidden attribute) for the ReturnUrl model property

TODO: Understand following note:

I used the strongly typed Html.HiddenFor helper method to create a hidden field for the ReturnUrl model property, but I had to use the string-based Html.Hidden helper to do the same for the ProductId field. If I had written Html.HiddenFor(x => line.Product.ProductID), the helper would render a hidden field with the name line.Product.ProductID. The name of the field would not match the names of the parameters for the CartController.RemoveFromCart action method, which would prevent the default model binders from working, so the MVC Framework would not be able to call the method.

9.2.2 Adding the Cart Summary

Objective: Add widget that summarize the content of the cart to display on nav bar

Steps:

  1. Add action method Summary to CartController.cs to supply cart summary (Listing 9-5)
  2. Create a partial view (Views/Cart/Summary.cshtml), which will render the view for summarizing current Cart (Listing 9-6)
    1. The view will display number of items in cart, total cost of those items and a link
  3. Add the partial view into _Layout.cshtml, so it become part of navbar (Listing 9-7)

Listing 9-5 Adding the Summary Method to the CartController.cs File

...
namespace SportsStore.WebUI.Controllers
{
    public class CartController : Controller
    {
        private IProductRepository repository;

        public CartController(IProductRepository repo)
        {
            repository = repo;
        }

        ...
        public PartialViewResult Summary(Cart cart)
        {
            return PartialView(cart);
        }
    }
}

Listing 9-6 The Contents of the Summary.cshtml File

@model SportsStore.Domain.Entities.Cart

<div class="navbar-right">
    @Html.ActionLink("Checkout", "Index", "Cart",
        new { returnUrl = Request.Url.PathAndQuery },
        new { @class = "btn btn-default navbar-btn" } )
</div>

<div class="navbar-text navbar-right">
    <b>Your cart:</b>
    @Model.Lines.Sum(x => x.Quantity) item(s),
    @Model.ComputeTotalValue().ToString("c")

where:

  • The view displays
    • Number of items in the cart
    • Total cost of items
    • A link to the Checkout
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="~/Content/bootstrap.css" rel="stylesheet"/>
    <link href="~/Content/bootstrap-theme.css" rel="stylesheet"/>
    <title>@ViewBag.Title</title>
</head>
<body>
<div class="navbar navbar-inverse" role="navigation">
    <a class="navbar-brand" href="#">SPORTS STORE</a>
    @Html.Action("Summary", "Cart")
</div>
<div class="row panel">
    <div id="categoreis" class="col-xs-3">
        @Html.Action("Menu", "Nav")
    </div>
    <div class="col-xs-8">
        @RenderBody()
    </div>
</div>
</body>
</html>

Where:

  • @Html.Action("Summary", "Cart") helper method is used to incorporate output from an action method into the existing view.

Reference:

9.3 Submitting Orders

Objective: Add final customer feature "the ability to checkout and complete an order"

9.3.1 Extending the Domain Model

Objective: Extend domain model to provide support for capturing the shipping details from a user and add the app support to process those details

Steps:

  1. Add ShippingDetails.cs to /Entities folder of SportsStore.Domain project and edit it (Listing 9-8)
namespace SportsStore.Domain.Entities
{
    public class ShippingDetails
    {
        [Required(ErrorMessage = "Please enter a name")]
        public string Name { get; set; }

        [Required(ErrorMessage = "Please enter the first address line")]
        public string Line1 { get; set; }
        public string Line2 { get; set; }
        public string Line3 { get; set; }

        [Required(ErrorMessage = "Please enter a city name")]
        public string City { get; set; }

        [Required(ErrorMessage = "Please enter a state name")]
        public string State { get; set; }

        public string Zip { get; set; }

        [Required(ErrorMessage = "Please enter a country name")]
        public string Country { get; set; }

        public bool GiftWrap { get; set; }
    }
}

Where:

  • Validation attributes from System.ComponentModel.DataAnnotations namespace is used here.
  • As this entity class has no functionality, there is nothing to be unit tested.

9.3.2 Adding the Checkout Process

Objective: provide view for users to enter shipping details and submit orders

Steps:

  1. Add Checkout now button to cart summary view in /Views/Cart/Index.cshtml (Listing 9-9)
  2. Define Checkout method in CartController.cs for the button to redirect to Checkout page (Listing 9-10)
  3. Add Views/Cart/Checkout.cshtml file (Listing 9-11)
  4. Apply Display attribute ShippingDetails.cs for better DisplayName in Razor view

Listing 9-9. Adding the Checkout Now Button to the Index.cshtml File

...
<div class="text-center">
    <a class="btn btn-primary" href="@Model.ReturnUrl">Continue shopping</a>
    @Html.ActionLink("Checkout now", "Checkout", null, new { @class = "btn btn-primary" })
</div>

Listing 9-10. The Checkout Action Method in the CartController.cs File

namespace SportsStore.WebUI.Controllers
{
    public class CartController : Controller
    {
        private IProductRepository repository;

        public CartController(IProductRepository repo)
        {
            repository = repo;
        }

        ...
        public ViewResult Checkout()
        {
            return View(new ShippingDetails());
        }
    }
}

Listing 9-11&12. The Contents of the Checkout.cshtml File

@model SportsStore.Domain.Entities.ShippingDetails

@{
    ViewBag.Title = "SportStore: Checkout";
}

<h2>Check out now</h2>
<p>Please enter your details, and we'll ship your goods right now</p>

@using (Html.BeginForm())
{

    <h3>Ship to</h3>
    <div class="form-group">
        <label>Name:</label>
        @Html.TextBoxFor(x => x.Name, new { @class = "form-control" })
    </div>

    <h3>Address</h3>
    foreach (var property in ViewData.ModelMetadata.Properties)
    {
        if (property.PropertyName != "Name" && property.PropertyName != "GiftWrap")
        {            
            <div class="form-group">
                <label>@(property.DisplayName ?? property.PropertyName)</label>
                @Html.TextBox(property.PropertyName, null, new { @class = "form-control" })
            </div>
        }
    }

    <h3>Options</h3>
    <div class="checkbox">
        <label>
            @Html.EditorFor(x => x.GiftWrap)
            Gift wrap these items
        </label>
    </div>

    <div class="text-center">
        <input class="btn btn-primary" type="submit" value="Complete order" />
    </div>
}

Where:

  • static ViewData.ModelMetadata property returns a System.Web.Mvc.ModelMetaData object. This object provides information about the model type for the view (i.e. new ShippingDetails() for this case).
    • ViewData.ModelMetadata.Properties hence provide list of all properties of returned object
  • if (property.PropertyName != "Name" && property.PropertyName != "GiftWrap") is used to exclude object properties that named "Name" and "GiftWrap"
  • foreach and if keywords can be used directly without prefixing with @, as they are within scope of Razor expression (i.e. @using)
  • In <label>@(property.DisplayName ?? property.PropertyName)</label>, ?? (null-coalescing operator) is used to check if there is a DisplayName value available when we generate the form element.

Listing 9-13. Applying the Display attribute to the ShippingDetails.cs File

namespace SportsStore.Domain.Entities
{
    public class ShippingDetails
    {
        ...
        [Required(ErrorMessage = "Please enter the first address line")]
        [Display(Name = "Line 1")]
        public string Line1 { get; set; }
        [Display(Name = "Line 2")]
        public string Line2 { get; set; }
        [Display(Name = "Line 3")]
        public string Line3 { get; set; }
        ...
    }
}

Where:

  • Hence the Name value for the Display attribute will be read by DisplayName property in the view.
    • e.g. Show Line 1 in view instead of Line1

9.3.3 Implementing the Order Processor

Objective: Create a component in app to handle details of order for processing

9.3.3.1 Defining the Interface

Steps:

  1. Create interface IOrderProcessor to /Abstract folder of SportsStore.Domain project (Listing 9-14)

Listing 9-14. The Contents of the IOrderProcessor.cs File

using SportsStore.Domain.Entities;
namespace SportsStore.Domain.Abstract
{
    public interface IOrderProcessor
    {
        void ProcessOrder(Cart cart, ShippingDetails shippingDetails);
    }
}

9.3.3.2 Implementing the Interface

Objective: Implement IOrderProcessor to deal with orders by e-mail them to the site administrator.

Steps:

  1. Create a new class EmailOrderProcessor.cs in /Concrete folder of SportsStore.Domain project.
    1. This class use .NET Framework built-in SMTP support to send emails

Listing 9-15. The Contents of the EmailOrderProcessor.cs File

using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System.Net;
using System.Net.Mail;
using System.Text;

namespace SportsStore.Domain.Concrete
{
    public class EmailSettings
    {
        public string MailToAddress = "orders@example.com";
        public string MailFromAddress = "sportsstore@example.com";
        public bool UseSsl = true;
        public string Username = "MySmtpUsername";
        public string Password = "MySmtpPassword";
        public string Servername = "smtp.example.com";
        public int ServerPort = 587;
        public bool WriteAsFile = false;
        public string FileLocation = @"c:\sports_store_emailss";
    }

    public class EmailOrderProcessor : IOrderProcessor
    {
        private EmailSettings emailSettings;
        public EmailOrderProcessor(EmailSettings settings)
        {
            emailSettings = settings;
        }

        public void ProcessOrder(Cart cart, ShippingDetails shippingInfo)
        {
            using (var smtpClient = new SmtpClient())
            {
                smtpClient.EnableSsl = emailSettings.UseSsl;
                smtpClient.Host = emailSettings.Servername;
                smtpClient.Port = emailSettings.ServerPort;
                smtpClient.UseDefaultCredentials = false;
                smtpClient.Credentials = new NetworkCredential(
                    emailSettings.Username,
                    emailSettings.Password);

                if (emailSettings.WriteAsFile)
                {
                    smtpClient.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
                    smtpClient.PickupDirectoryLocation = emailSettings.FileLocation;
                    smtpClient.EnableSsl = false;
                }
                StringBuilder body = new StringBuilder()
                    .AppendLine("A new order has been submitted")
                    .AppendLine("---")
                    .AppendLine("Items:");

                foreach (var line in cart.Lines)
                {
                    var subtotal = line.Product.Price * line.Quantity;
                    body.AppendFormat("{0} x {1} (subtotal: {2:c}", line.Quantity, line.Product.Name, subtotal);
                }

                body.AppendFormat("Total order value: {0:c}", cart.ComputeTotalValue())
                    .AppendLine("---")
                    .AppendLine("Ship to:")
                    .AppendLine(shippingInfo.Name)
                    .AppendLine(shippingInfo.Line1)
                    .AppendLine(shippingInfo.Line2 ?? "")
                    .AppendLine(shippingInfo.Line3 ?? "")
                    .AppendLine(shippingInfo.City)
                    .AppendLine(shippingInfo.State ?? "")
                    .AppendLine(shippingInfo.Country)
                    .AppendLine(shippingInfo.Zip)
                    .AppendLine("---")
                    .AppendFormat("Gift wrap: {0}", shippingInfo.GiftWrap ? "Yes" : "No");

                MailMessage mailMessage = new MailMessage(
                    emailSettings.MailFromAddress,   // From
                                                       emailSettings.MailToAddress,     // To
                                                      "New order submitted!",          // Subject
                                                       body.ToString());                // Body

                if (emailSettings.WriteAsFile)
                {
                    mailMessage.BodyEncoding = Encoding.ASCII;
                }

                smtpClient.Send(mailMessage);
            }

        }
    }
}

9.3.4 Registering the Implementation

Objective: Register the implementation of IOrderProcessor

Steps:

  1. Modify NinjectDependencyResolver.cs (Listing 9-16)
    1. Add EmailSettings object, which is used with Ninject WithConstructorArgument method. So the EmailSettings object can be injected into EmailOrderProcessor constructor when new instances are created to service requests for IOrderProcessor interface
    2. Bind IOrderProcessor with its implementation.
  2. Specify value for EmailSettings properties: WriteAsFile in Web.config (Listing 9-17)

Listing 9-16 Adding Ninject Bindings for IOrderProcessor to the NinjectDependencyResolver.cs File

namespace SportsStore.WebUI.Infrastructure
{
    public class NinjectDependencyResolver : IDependencyResolver
    {
        private IKernel kernel;

        public NinjectDependencyResolver(IKernel kernelParam)
        {
            kernel = kernelParam;
            AddBindings();
        }
        ...
        private void AddBindings()
        {
            kernel.Bind<IProductRepository>().To<EFProductRepository>();

            EmailSettings emailSettings = new EmailSettings
            {
                WriteAsFile = bool.Parse(ConfigurationManager.AppSettings["Email.WriteAsFile"] ?? "false")
            };

            kernel.Bind<IOrderProcessor>().To<EmailOrderProcessor>()
                .WithConstructorArgument("settings", emailSettings);
        }
    }
}

Listing 9-17 Application Settings ini the Web.config File

...
<appSettings>
  ...
  <add key="UnobtrusiveJavaScriptEnabled" value="true" />
  <add key="Email.WriteAsFile" value="true"/>
</appSettings>
...

9.3.5 Completing the Cart Controller

Objective: complete CartController so it can handle HTTP form POST request when user clicks Complete order button

Steps:

  1. Add implementation of IOrderProcessor into CartController.cs (Listing 9-18)
  2. Create action method Checkout that handle POST request from user. (Listing 9-18)
  3. Fix unit tests

Listing 9-18 Completing the Controller in the CartController.cs File

namespace SportsStore.WebUI.Controllers
{
    public class CartController : Controller
    {
        private IProductRepository repository;
        private IOrderProcessor orderProcessor;

        public CartController(IProductRepository repo, IOrderProcessor proc)
        {
            repository = repo;
            orderProcessor = proc;
        }

        ...

        // GET
        public ViewResult Checkout()
        {
            return View(new ShippingDetails());
        }

        // POST
        [HttpPost]
        public ViewResult Checkout(Cart cart, ShippingDetails shippingDetails)
        {
            if (cart.Lines.Count() == 0)
            {
                ModelState.AddModelError("", "Sorry, your cart is empty");
            }
            if (ModelState.IsValid)
            {
                orderProcessor.ProcessOrder(cart, shippingDetails);
                cart.Clear();
                return View("Completed");
            }
            else
            {
                return View(shippingDetails);
            }
        }
    }
}

Where:

  • New Checkout method is decorated with HttpPost attribute. Hence it will be invoked for a POST request.
  • Both cart and shippingDetails are parsed by model binider (by MVC)
  • ModelState (MVC Framework feature): MVC Framework checks validation constraints on ShippingDetails, any validation problems violations are passed to action method through ModelState property (Details of validation are in chapter 25)
    • If there is any proble, check ModelState.IsValid property
    • We can call ModelState.AddModelError method to register error messages if there is no items in cart. (We can use this feature in test to create error as well)

Q: How to fix previous UTs that fail due to change in constructor?

  • Passing null for unused injected dependency in the constructor e.g.
// Arrange - create a mock order processor
Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();

// Arrange - create a cart with an item
Cart cart = new Cart();
cart.AddItem(new Product(), 1);

// Arrange - create an instance of the controller
CartController target = new CartController(null, mock.Object); 
// use null to inject repo, as we don't need it

UNIT TEST: ORDER PROCESSING

        [TestMethod]
        public void Cannot_Checkout_Empty_Cart()
        {
            // Arrange - create a mock order processor
            Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();
            // Arrange - create an empty cart
            Cart cart = new Cart();
            // Arrange - create shipping details
            ShippingDetails shippingDetails = new ShippingDetails();
            // Arrange - create an instance of the controller
            CartController target = new CartController(null, mock.Object);

            // Act
            ViewResult result = target.Checkout(cart, shippingDetails);

            // Assert - check that the order hasn't been passed on to the processor
            mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()), Times.Never());

            // Assert - check that method is returning the default view
            Assert.AreEqual("", result.ViewName);
            // Assert - check that I am passing an invalid model to the view
            Assert.AreEqual(false, result.ViewData.ModelState.IsValid);
        }

        [TestMethod]
        public void Cannot_Checkout_Invalid_ShippingDetails()
        {

            // Arrange - create a mock order processor
            Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();

            // Arrange - create a cart with an item
            Cart cart = new Cart();
            cart.AddItem(new Product(), 1);

            // Arrange - create an instance of the controller
            CartController target = new CartController(null, mock.Object); 
            // use null to inject repo, as we don't need it

            // Arrange - add an error to the model
            target.ModelState.AddModelError("error", "error");

            // Act - try to checkout
            ViewResult result = target.Checkout(cart, new ShippingDetails());

            // Assert - check that the order hasn't been passed on to the processor
            mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()),
        Times.Never());
            // Assert - check that the method is returning the default view
            Assert.AreEqual("", result.ViewName);
            // Assert - check that I am passing an invalid model to the view
            Assert.AreEqual(false, result.ViewData.ModelState.IsValid);
        }

        [TestMethod]
        public void Can_Checkout_And_Submit_Order()
        {

            // Arrange - create a mock order processor
            Mock<IOrderProcessor> mock = new Mock<IOrderProcessor>();

            // Arrange - create a cart with an item
            Cart cart = new Cart();
            cart.AddItem(new Product(), 1);

            // Arrange - create an instance of the controller
            CartController target = new CartController(null, mock.Object);

            // Act - try to checkout
            ViewResult result = target.Checkout(cart, new ShippingDetails());

            // Assert - check that the order has been passed on to the processor
            mock.Verify(m => m.ProcessOrder(It.IsAny<Cart>(), It.IsAny<ShippingDetails>()),
        Times.Once());
            // Assert - check that the method is returning the Completed view
            Assert.AreEqual("Completed", result.ViewName);
            // Assert - check that I am passing a valid model to the view
            Assert.AreEqual(true, result.ViewData.ModelState.IsValid);
        }

Where:

  • We use .Verify of Moq object to verify behaviour of mocked object, reference in Moq in github
  • For unused dependency, we inject null

9.3.6 Displaying Validation Errors

Objective: MVC Framework can validate use input, but we need to make changes to display validation information correctly.

Steps:

  1. Create a useful summary of validation errors by using @Html.ValidationSummary helper method in Checkout.cshtml view (Listing 9-20)
  2. Create CSS style file /Content/ErrorStyle.css in SportsStore.WebUI for classes used by validation summary (which MVC adds invalid elements) (Listing 9-21)
  3. Update _Layout.cshtml to add link element for ErrorStyles.css file (Listing 9-22)

Listing 9-19. Adding a Validation Summary to the Checkout.cshtml File

@model SportsStore.Domain.Entities.ShippingDetails
...

@using (Html.BeginForm())
{
    @Html.ValidationSummary()
    <h3>Ship to</h3>
    <div class="form-group">
        <label>Name:</label>
        @Html.TextBoxFor(x => x.Name, new { @class = "form-control" })
    </div>

Where:

Listing 9-20. The Contents of the ErrorStyles.css File

.field-validation-error     {
    color: #f00;
}

.field-validation-valid     {
    display: none;
}

.input-validation-error     {
    border: 1px solid #f00;
    background-color: #fee;
}

.validation-summary-errors {
    font-weight: bold;
    color: #f00;
}

.validation-summary-valid   {
    display: none;
}

Listing 9-21. Adding a Link Element in the _Layout.cshtml File

<head>
    <meta charset="utf-8" />
    ...
    <link href="~/Content/ErrorStyles.css" rel="stylesheet"/>
    <title>@ViewBag.Title</title>
</head>

9.3.7 Displaying a Summary Page

Objective: show customer a thank-you page that confirms the order has been processed.

Steps:

  1. Create Completed.cshtml in /Views/Cart
    1. As shown in CartController.cs snippet below, after successfully checkout, the controller will automatically redirect to "Complete" view. (This have been implemented in Listing 9-18)

Listing 9-22. The Contents of the Completed.cshtml File

@{
    ViewBag.Title = "SportsStore: Order Submitted";
}

<h2>Thanks!</h2>
Thanks for placing your order. We'll ship your goods as soon as possible.
        [HttpPost]
        public ViewResult Checkout(Cart cart, ShippingDetails shippingDetails)
        {
            if (cart.Lines.Count() == 0)
            {
                ModelState.AddModelError("", "Sorry, your cart is empty");
            }
            if (ModelState.IsValid)
            {
                orderProcessor.ProcessOrder(cart, shippingDetails);
                cart.Clear();
                return View("Completed");
            }
            else
            {
                return View(shippingDetails);
            }
        }

Summary

The well-separated architecture means I can easily change the behavior of any piece of the application without worrying about causing problems or inconsistencies elsewhere. For example, I could process orders by storing them in a database, and it would not have any impact on the shopping cart, the product catalog, or any other area of the application.