Table of Contents
- Chapter 09: SportsStore: Completing the Cart
- 9.1 Using Model Binding
- 9.2 Completing the Cart
- 9.3 Submitting Orders
- 9.3.1 Extending the Domain Model
- 9.3.2 Adding the Checkout Process
- 9.3.3 Implementing the Order Processor
- 9.3.4 Registering the Implementation
- 9.3.5 Completing the Cart Controller
- 9.3.6 Displaying Validation Errors
- 9.3.7 Displaying a Summary Page
- Summary
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:
- Create new folder
/Infrastructure/Binders
inSportsStore.WebUI
project. AndCartModelBinder.cs
in the directory - Register
CartModelBinder
class in/Application_Start/Global.asax.cs
file - Update
Cart
controller to removeGetCart
method, rely on model binder to provide controller withCart
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 definesBindModel
. 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 hasSession
property to get/set session data. Cart
object associated with the user's session can be obtained by reading values from session data.
- It has
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. ACart
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.
- When custom binder is asked to create a
- When MVC Framework receives a request that requires (e.g. the
Benefit of custom model binder:
- Seperate the logic used to create a
Cart
from controller, which allows us to change the way to storeCart
objects w/o changing controller. (compared to Listing 8-14) - Any controller class that works with
Cart
objects can simply declare them as action method parameters and take advantage of custom model binder. - 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:
- 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
- Allow customer to remove an item from the cart
- 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:
- Add a
Remove
button in each row of the cart summary, by changeViews/Cart/Index.cshtml
- 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 theReturnUrl
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:
- Add action method
Summary
toCartController.cs
to supply cart summary (Listing 9-5) - Create a partial view (
Views/Cart/Summary.cshtml
), which will render the view for summarizing currentCart
(Listing 9-6)- The view will display number of items in cart, total cost of those items and a link
- 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:
- Add
ShippingDetails.cs
to/Entities
folder ofSportsStore.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:
- Add
Checkout now
button to cart summary view in/Views/Cart/Index.cshtml
(Listing 9-9) - Define
Checkout
method inCartController.cs
for the button to redirect to Checkout page (Listing 9-10) - Add
Views/Cart/Checkout.cshtml
file (Listing 9-11) - Apply
Display
attributeShippingDetails.cs
for betterDisplayName
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 aSystem.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
andif
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 aDisplayName
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 theDisplay
attribute will be read byDisplayName
property in the view.- e.g. Show
Line 1
in view instead ofLine1
- e.g. Show
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:
- Create interface
IOrderProcessor
to/Abstract
folder ofSportsStore.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:
- Create a new class
EmailOrderProcessor.cs
in/Concrete
folder ofSportsStore.Domain
project.- 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:
- Modify
NinjectDependencyResolver.cs
(Listing 9-16)- Add
EmailSettings
object, which is used with NinjectWithConstructorArgument
method. So theEmailSettings
object can be injected intoEmailOrderProcessor
constructor when new instances are created to service requests forIOrderProcessor
interface - Bind
IOrderProcessor
with its implementation.
- Add
- Specify value for
EmailSettings
properties:WriteAsFile
inWeb.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:
- Add implementation of
IOrderProcessor
intoCartController.cs
(Listing 9-18) - Create action method
Checkout
that handle POST request from user. (Listing 9-18) - 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 withHttpPost
attribute. Hence it will be invoked for aPOST
request. - Both
cart
andshippingDetails
are parsed by model binider (by MVC) ModelState
(MVC Framework feature): MVC Framework checks validation constraints onShippingDetails
, any validation problems violations are passed to action method throughModelState
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)
- If there is any proble, check
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
ofMoq
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:
- Create a useful summary of validation errors by using
@Html.ValidationSummary
helper method inCheckout.cshtml
view (Listing 9-20) - Create CSS style file
/Content/ErrorStyle.css
inSportsStore.WebUI
for classes used by validation summary (which MVC adds invalid elements) (Listing 9-21) - Update
_Layout.cshtml
to add link element forErrorStyles.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:
@Html.ValidationSummary()
helper method is added in view file, in where validation summary will appear
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:
- Create
Completed.cshtml
in/Views/Cart
- 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)
- As shown in
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.