ASP.NET MVC 4

ASP.NET MVC 4

Адам Фриман

Отправка заказов

Мы дошли до заключительной функции для работы пользователей в SportsStore: возможности подтвердить заказ и завершить покупку. В следующих разделах мы расширим нашу доменную модель так, чтобы обеспечить поддержку ввода реквизитов доставки, и добавим функцию для обработки этой информации.

Расширяем доменную модель

Добавьте класс под названием ShippingDetails в папку Entities проекта SportsStore.Domain. Это класс, который мы будем использовать для представления клиенту полей для ввода информации о доставке. Его содержимое показано в листинге 9-9.

Листинг 9-9: Класс ShippingDetails
using System.ComponentModel.DataAnnotations;

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; }
	}
}

Как вы видите из листинга 9-9, мы используем атрибуты валидации из пространства имен System.ComponentModel.DataAnnotations, как мы это уже делали в главе 2. Валидация будет рассмотрена подробно в главе 23.

Примечание

В классе ShippingDetails нет никакой функциональности, так что он не нуждается в тестировании.

Добавляем процесс подтверждения заказа

Мы хотим реализовать функционал до того пункта, где пользователи смогут ввести информацию о доставке и отправить заказ. Чтобы это выполнить, для начала нам нужно добавить кнопку Checkout now в представление для корзины. В листинге 9-10 показано изменение, которое мы должны внести в файл Views/Cart/Index.cshtml.

Листинг 9-10: Добавляем кнопку Checkout now
</table>
<p align="center" class="actionButtons">
	<a href="@Model.ReturnUrl">Continue shopping</a>
	@Html.ActionLink("Checkout now", "Checkout")
</p>

Это единственное изменение генерирует ссылку, нажатие на которую вызывает метод действия Checkout контроллера Cart. Вы можете увидеть эту кнопку на рисунке 9-4.

Рисунок 9-4: Кнопка Checkout Now

Как и следовало ожидать, теперь нам нужно определить метод Checkout в классе CartController, как показано в листинге 9-11.

Листинг 9-11: Метод действия Checkout
public ViewResult Checkout() {
	return View(new ShippingDetails());
}

Метод Checkout возвращает представление по умолчанию и передает в него новый объект ShippingDetails как модель представления. Чтобы создать соответствующее представление, кликните правой кнопкой мыши метод Checkout, выберите Add View и заполните диалоговое окно, как показано на рисунке 9-5. Мы собираемся использовать доменный класс ShippingDetails как основу для строго типизированного представления. Отметьте флажком опцию Use a layout, потому что мы визуализируем целую страницу и хотим, чтобы она выглядела также, как и все приложение.

Рисунок 9-5: Добавляем представление Checkout

Приведите содержимое представления в соответствие с разметкой, показанной в листинге 9-12.

Листинг 9-12: Представление Checkout.cshtml
@model SportsStore.Domain.Entities.ShippingDetails

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

<h2>Check out now</h2>
Please enter your details, and we'll ship your goods right away!
@using (Html.BeginForm())
{
	<h3>Ship to</h3>
	<div>Name: @Html.EditorFor(x => x.Name)</div>
	<h3>Address</h3>
	<div>Line 1: @Html.EditorFor(x => x.Line1)</div>
	<div>Line 2: @Html.EditorFor(x => x.Line2)</div>
	<div>Line 3: @Html.EditorFor(x => x.Line3)</div>
	<div>City: @Html.EditorFor(x => x.City)</div>
	<div>State: @Html.EditorFor(x => x.State)</div>
	<div>Zip: @Html.EditorFor(x => x.Zip)</div>
	<div>Country: @Html.EditorFor(x => x.Country)</div>
	<h3>Options</h3>
	<label>
		@Html.EditorFor(x => x.GiftWrap)
		Gift wrap these items
	</label>
	
	<p align="center">
		<input class="actionButtons" type="submit" value="Complete order" />
	</p>
}

Вы увидите, как визуализируется это представление, запустив приложение, добавив товар в корзину и нажав кнопку Checkout now. На рисунке 9-6 показано, что представление отображается в виде формы для ввода реквизитов доставки.

Рисунок 9-6: Форма для реквизитов доставки

Мы визуализировали элементы input для каждого из полей формы с помощью вспомогательного метода Html.EditorFor. Это пример шаблонного вспомогательного метода. Мы позволяем MVC Framework решать, какой элемент input требуется для свойства модели представления, а не указываем его явно (например, с помощью Html.TextBoxFor).

Мы подробно опишем шаблонные вспомогательные методы в главе 20, но вы уже можете видеть из рисунка, MVC достаточно умная платформа, чтобы визуализировать чекбокс для свойств bool (например, для опции Gift wrap) и текстовые поля для строковых свойств.

Совет

Мы могли бы пойти еще дальше и заменить большую часть разметки представления на вызов вспомогательного метода Html.EditorForModel, который сгенерирует метки и поля ввода для всех свойств в классе модели представления ShippingDetails. Тем не менее, мы хотели разделить элементы так, чтобы поля для имени, адреса и дополнительных опций находились в разных частях формы, поэтому мы просто обращаемся к каждому свойству напрямую.

Создаем IOrderProcessor

В приложении нам нужен компонент, который будет обрабатывать детали заказа. Следуя принципам модели MVC, мы собираемся определить для этой функциональности интерфейс, написать его реализацию, а затем ассоциировать их с помощью нашего DI-контейнера Ninject.

Определяем интерфейс

Добавьте новый интерфейс под названием IOrderProcessor в папку Abstract проекта SportsStore.Domain и отредактируйте его содержимое так, чтобы оно соответствовало листингу 9-13.

Листинг 9-13: Интерфейс IOrderProcessor
using SportsStore.Domain.Entities;

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

Создаем реализацию интерфейса

Наша реализация IOrderProcessor будет обрабатывать заказы, отправляя их по электронной почте администратору сайта. Конечно, мы упрощаем процесс продажи. Большинство сайтов электронной коммерции не ограничиваются подтверждением заказов по e-mail, к тому же мы не обеспечили поддержку для обработки кредитных карт и других форм оплаты. Но мы не хотим отвлекаться от MVC, так что будем работать с электронной почтой.

Создайте новый класс под названием EmailOrderProcessor в папке Concrete проекта SportsStore.Domain и отредактируйте содержимое так, чтобы он соответствовал листингу 9-14. Для отправки e-mail этот класс использует встроенную поддержку SMTP, которая включена в библиотеку .NET Framework.

Листинг 9-14: Класс EmailOrderProcessor
using System.Net.Mail;
using System.Text;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System.Net;

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_emails";
	}

	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-14 класс EmailSettings. Экземпляр этого класса требуется конструктором EmailOrderProcessor и содержит все настройки, которые необходимы для конфигурации классов .NET, работающих с электронной почтой.

Совет

Не беспокойтесь, если у вас нет сервера SMTP. Если вы установите свойству EmailSettings.WriteAsFile значение true, e-mail сообщения будут записываться как файлы в директорию, указанную в свойстве FileLocation. Эта директория должна существовать и быть доступной для записи. Файлы будут записаны с расширением .eml, но их можно прочитать в любом текстовом редакторе.

Регистрируем реализацию

Теперь у нас есть реализация интерфейса IOrderProcessor и средства для ее настройки, мы также можем использовать Ninject для создания ее экземпляров. Отредактируйте класс NinjectControllerFactory проекта SportsStore.WebUI и внесите в метод AddBindings изменения, показанные в листинге 9-15.

Листинг 9-15: Добавление привязок Ninject для IOrderProcessor
using System;
using System.Web.Mvc;
using System.Web.Routing;
using Ninject;
using SportsStore.Domain.Entities;
using SportsStore.Domain.Abstract;
using System.Collections.Generic;
using System.Linq;
using Moq;
using SportsStore.Domain.Concrete;
using System.Configuration;

namespace SportsStore.WebUI.Infrastructure
{
	public class NinjectControllerFactory : DefaultControllerFactory
	{
		private IKernel ninjectKernel;

		public NinjectControllerFactory()
		{
			ninjectKernel = new StandardKernel();
			AddBindings();
		}

		protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType)
		{
			return controllerType == null
				? null
				: (IController)ninjectKernel.Get(controllerType);
		}

		private void AddBindings()
		{
			ninjectKernel.Bind<IProductRepository>().To<EFProductRepository>();
			EmailSettings emailSettings = new EmailSettings
			{
				WriteAsFile = bool.Parse(ConfigurationManager
					.AppSettings["Email.WriteAsFile"] ?? "false")
			};
			ninjectKernel.Bind<IOrderProcessor>()
				.To<EmailOrderProcessor>()
				.WithConstructorArgument("settings", emailSettings);
		}
	}
}

Мы создали объект EmailSettings, который будем использовать с методом Ninject WithConstructorArgument. Он будет внедряться в конструктор EmailOrderProcessor, когда создаются новые экземпляры для обслуживания запросов интерфейса IOrderProcessor. В листинге 9-15 мы установили значение только для одного из свойств EmailSettings - WriteAsFile. Мы читаем значение этого свойства с помощью свойства ConfigurationManager.AppSettings, которое дает нам доступ к настройкам приложения в файле Web.config (в корневой папке проекта), которые показаны в листинге 9-16.

Листинг 9-16: Настройки приложения в файле Web.config
<appSettings>
	<add key="webpages:Version" value="2.0.0.0" />
	<add key="webpages:Enabled" value="false" />
	<add key="PreserveLoginUrl" value="true" />
	<add key="ClientValidationEnabled" value="true" />
	<add key="UnobtrusiveJavaScriptEnabled" value="true" />
	<add key="Email.WriteAsFile" value="true"/>
</appSettings>

Завершаем CartController

Для завершения класса CartController мы должны изменить конструктор так, что он запрашивал реализацию интерфейса IOrderProcessor, и добавить новый метод действия, который будет обрабатывать форму POST после того, когда пользователь нажмет кнопку Complete order. Листинг 9-17 показывает оба изменения.

Листинг 9-17: Завершаем класс CartController
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace SportsStore.WebUI.Controllers
{
	public class CartController : Controller
	{
		private IProductRepository repository;
		private IOrderProcessor orderProcessor;
		public CartController(IProductRepository repo, IOrderProcessor proc)
		{
			repository = repo;
			orderProcessor = proc;
		}

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

		public ViewResult Summary(Cart cart)
		{
			return View(cart);
		}

		[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);
			}
		}

		public ViewResult Checkout()
		{
			return View(new ShippingDetails());
		}
		// ...other action methods omitted for brevity...
	}
}

Как видите, к методу действия Checkout теперь добавляется атрибут HttpPost, что означает, что он будет вызван для обработки запроса POST (в данном случае, когда пользователь отправляет форму). Опять же, мы полагаемся на механизм связывания данных как для параметра ShippingDetails (который создается автоматически на основе данных формы HTTP), так и для параметра Cart (который создается с помощью нашего пользовательского механизма связывания).

Примечание

Из-за изменения конструктора мы должны обновить модульные тесты, которые мы создали для класса CartController. Тесты будут скомпилированы, если вы передадите null в новый параметра конструктора.

MVC Framework проверяет ограничения валидации, которые мы применили к ShippingDetails помощью атрибутов DataAnnotation в листинге 9-17, и передает любые нарушения в наш метод действия через свойство ModelState. Мы можем увидеть, есть ли какие-нибудь проблемы, проверив свойство ModelState.IsValid. Обратите внимание, что мы вызываем метод ModelState.AddModelError для регистрирации сообщения об ошибке, если нет товаров в корзине. Мы вкратце объясним, как отображать такие ошибки, и подробно разберем связывание данных и валидацию в главах 22 и 23.

Модульный тест: Обработка заказов

Чтобы завершить модульное тестирование класса CartController, нам нужно протестировать поведение новой перегруженной версии метода Checkout. Хотя метод кажется коротким и простым, использование связывания данных MVC Framework означает, что происходит множество скрытых процессов, которые нужно проверить. Мы должны обработать заказ, только если в корзине есть товары и пользователь предоставил нам действительные реквизиты доставки. Во всех остальных случаях ему должно быть показано сообщение об ошибке. Вот первый тестовый метод:

[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 the method is returning the default view
	Assert.AreEqual("", result.ViewName);

	// Assert - check that we are passing an invalid model to the view
	Assert.AreEqual(false, result.ViewData.ModelState.IsValid);
}

Этот тест гарантирует, что пользователь не сможет подтвердить покупку с пустой корзиной. Чтобы это проверить, мы утверждаем, что ProcessOrder имитированной реализации IOrderProcessor никогда не вызывается, что метод возвращает представление по умолчанию (которое снова отобразит данные, введенные пользователем, и даст возможность их исправить), и что состояние модели, которое передается в представление, отмечено как недопустимое. Может показаться, что наши утверждения дублируют друг друга, но нам нужны все три, чтобы гарантировать корректное поведение.

Следующий тестовый метод работает во многом таким же образом, но он внедряет сообщение об ошибке в модель представления и поручает механизму связывания данных сообщить об ошибке (что произойдет, когда клиент введет недействительные реквизиты доставки):

[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);

	// 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 we are 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 we are passing a valid model to the view
	Assert.AreEqual(true, result.ViewData.ModelState.IsValid);
}

Обратите внимание, что не нужно проверить, можем ли мы определить действительность реквизитов доставки. Это выполняется автоматически механизмом связывания с помощью атрибутов, которые мы применили к свойствам класса ShippingDetails.

Отображаем ошибки валидации

Если пользователь введет недействительные реквизиты доставки, отдельные поля формы с проблемами будут подсвечены, но никаких сообщений отображаться не будет. Хуже того, если пользователь попытается подтвердить покупку с пустой корзиной, мы не дадим ему завершить заказ, но и не покажем никакого сообщения об ошибке. Чтобы решить эту проблему, мы должны добавить в представление ValidationSummary, как в главе 2. Листинг 9-18 показывает дополнение в представление Checkout.cshtml.

Листинг 9-18: Добавляем ValidationSummary
<h2>Check out now</h2>
Please enter your details, and we'll ship your goods right away!
@using (Html.BeginForm()) {
	@Html.ValidationSummary()
	<h3>Ship to</h3>
	<div>Name: @Html.EditorFor(x => x.Name)</div>

Теперь, когда пользователь введет недействительные реквизиты доставки или попытается подтвердить покупку с пустой корзиной, он увидит сообщение об ошибке, как показано на рисунке 9-7.

Рисунок 9-7: Отображение сообщений валидации

Отображаем страницу подтверждения

Для завершения процесса подтверждения покупки мы покажем пользователю страницу с подтверждением, что заказ был обработан, и благодарностью за покупку. Кликните правой кнопкой мыши по любому методу действия в классе CartController и выберите Add View из контекстного меню. Назовите представление Completed, как показано на рисунке 9-8.

Рисунок 9-8: Создаем представление Completed

Мы не хотим, чтобы это представление было строго типизированным, потому что мы не собираемся передавать какие-либо модели представления между ним и контроллером. Мы хотим использовать макет, чтобы страница подтверждения выглядела соответственно остальным страницам приложения. Нажмите кнопку Add, чтобы создать представление, и отредактируйте его содержимое, чтобы оно соответствовало листингу 9-19.

Листинг 9-19: Представление Completed.cshtml
@{
	ViewBag.Title = "SportsStore: Order Submitted";
}

<h2>Thanks!</h2>
Thanks for placing your order. We'll ship your goods as soon as possible.

Теперь пользователь может пройти весь процесс, начиная с выбора товара и заканчивая подтверждением покупки. Если он предоставит действительные реквизиты доставки (и при наличии товаров в корзине), то, нажимая на кнопку Complete order, он попадет на страницу подтверждения, как показано на рисунке 9-9.

Рисунок 9-9: Страница с благодарностью
или RSS канал: Что новенького на smarly.net