构建客户模块

客户模块为我们网店的进一步销售功能提供了基础。在最基本的层面上,它负责注册、登录、管理和显示相关客户信息。它是后面销售模块的要求,它为我们的网店应用增加了实际的销售功能。

在本章中,我们将涉及以下主题:

  • 要求
  • 依赖性
  • 实施
  • 单元测试
  • 功能测试

要求

按照前面的模块化网店应用需求规范中定义的高级应用需求,我们的模块将有一个单一的客户实体定义。

客户实体包括以下属性:

  • id: integer, auto-increment
  • email: string, unique
  • username: string, unique, 登录系统所需
  • password: string
  • first_name: string
  • last_name: string
  • company: string
  • phone_number: string
  • country: string
  • state: string
  • city: string
  • postcode: string
  • street: string

在本章中,除了添加 Custome r实体及其 CRUD 页面外,我们还需要解决登录、注册、忘记密码页面的创建,以及覆盖一个核心模块服务,负责构建客户菜单。

依赖

该模块对其他模块没有明确的依赖。虽然它确实覆盖了核心模块中定义的服务,但模块本身并不依赖于它。此外,一些安全配置需要作为核心应用程序的一部分来提供,我们将在后面看到。

实施

我们首先创建一个新的模块Foggyline\CustomerBundle。我们在控制台的帮助下,通过运行以下命令来实现:

  1. php bin/console generate:bundle --namespace=Foggyline/CustomerBundle

该命令会触发一个交互式过程,沿途向我们询问几个问题,如下截图所示:

构建客户模块 - 图1

做完之后,我们就会生成以下结构:

构建客户模块 - 图2

如果我们现在看一下 app/AppKernel.php 文件,我们会在 registerBundles 方法下看到以下一行:

  1. new Foggyline\CustomerBundle\FoggylineCustomerBundle()

同样,app/config/routing.yml 目录中也添加了以下路由定义:

  1. foggyline_customer:
  2. resource: "@FoggylineCustomerBundle/Resources/config/routing.xml"
  3. prefix: /

这里我们需要把 prefix: / 改成 prefix: /customer/,这样我们就不会和核心模块的路由发生冲突。如果将其保留为 prefix: /会简单地覆盖我们的核心 AppBundle,并从src/Foggyline/CustomerBundle/Resources/views/Default/index.html.twig模板中输出Hello World!这时就会输出给浏览器。我们希望保持良好的分离。这意味着模块不为自己定义根路由。

创建客户实体

让我们继续创建一个 Customer 实体。我们通过使用控制台来创建,如图所示:

  1. php bin/console generate:doctrine:entity

这个命令会触发交互式生成器,我们需要提供实体属性。一旦完成,生成器会在 src/Foggyline/CustomerBundle/ 目录下创建 Entity/Customer.phpRepository/CustomerRepository.php 文件。在这之后,我们需要更新数据库,这样它就会拉入Customer 实体,运行以下命令:

  1. php bin/console doctrine:schema:update --force

这样就会出现如下截图所示的画面:

构建客户模块 - 图3

实体到位后,我们就可以生成它的 CRUD了。我们通过使用以下命令来实现:

  1. php bin/console generate:doctrine:crud

这样就会有一个交互式的输出,如图所示:

构建客户模块 - 图4

这将导致src/Foggyline/CustomerBundle/Controller/CustomerController.php目录被创建。它还在我们的app/config/routing.yml文件中添加了一个条目,如下所示:

  1. foggyline_customer_customer:
  2. resource: "@FoggylineCustomerBundle/Controller/CustomerController.php"
  3. type: annotation

同样,视图文件是在app/Resources/views/customer/目录下创建的,这不是我们所期望的。我们希望它们在我们的模块src/Foggyline/CustomerBundle/Resources/views/Default/customer/目录下,所以我们需要把它们复制过来。此外,我们需要修改CustomerController内的所有$this->render调用,将FoggylineCustomerBundle:default:string 追加到每个模板路径中。

修改安全配置

在我们进一步进行模块内的实际更改之前,让我们想象一下,我们的模块需求强制要求一定的安全配置,以使其工作。这些需求规定我们需要对app/config/security.yml文件进行一些修改。首先,我们编辑 providers 元素,在其中添加以下内容:

  1. foggyline_customer:
  2. entity:
  3. class: FoggylineCustomerBundle:Customer
  4. property: username

这就有效地将我们的 Customer 类定义为安全提供者,而 username 元素则是存储用户身份的属性。

然后我们在 encoders 元素下定义编码器类型,如下所示:

  1. Foggyline\CustomerBundle\Entity\Customer:
  2. algorithm: bcrypt
  3. cost: 12

这告诉 Symfony 在加密密码时使用 bcrypt 算法,算法成本值为12。这样我们的密码在保存到数据库中的时候就不会以明文的形式出现了。

然后我们继续在firewalls元素下定义一个新的防火墙条目,如下所示:

  1. foggyline_customer:
  2. anonymous: ~
  3. provider: foggyline_customer
  4. form_login:
  5. login_path: foggyline_customer_login
  6. check_path: foggyline_customer_login
  7. default_target_path: customer_account
  8. logout:
  9. path: /customer/logout
  10. target: /

这里有不少的事情。我们的防火墙使用 anonymous: ~定义来表示它并不真正需要用户登录才能看到某些页面。默认情况下,所有的 Symfony 用户都是匿名认证的,如下截图所示,在开发者工具栏上:

构建客户模块 - 图5

form_login 的定义有三个属性,其中login_pathcheck_path指向我们的自定义路由foggyline_customer_login。当安全系统启动认证过程时,它会将用户重定向到foggyline_customer_login路由,在那里我们将很快实现所需的控制器逻辑和视图模板,以处理登录表单。登录后,default_target_path决定用户将被重定向到哪里。

最后,我们重用 Symfony 的匿名用户功能,以排除某些页面被禁止。我们希望我们的非认证用户能够访问登录、注册和忘记密码页面。为了实现这一点,我们在 access_control 元素下添加了以下内容:

  1. - { path: customer/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
  2. - { path: customer/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
  3. - { path: customer/forgotten_password, roles: IS_AUTHENTICATED_ANONYMOUSLY }
  4. - { path: customer/account, roles: ROLE_USER }
  5. - { path: customer/logout, roles: ROLE_USER }
  6. - { path: customer/, roles: ROLE_ADMIN }

值得注意的是,这种处理模块和基础应用之间的安全问题的方法是迄今为止最理想的方法。这只是一个可能的例子,说明我们如何实现这个模块所需要的功能。

扩展客户实体

有了前面的 security.yml 的添加,我们现在就可以开始实际执行注册过程了。首先,我们编辑src/Foggyline/CustomerBundle/Entity/目录下的 Customer 实体,让它实现Symfony/Component/Security/Core/User/UserInterface, \Serializable。这意味着要实现以下方法:

  1. public function getSalt()
  2. {
  3. return null;
  4. }
  5. public function getRoles()
  6. {
  7. return array('ROLE_USER');
  8. }
  9. public function eraseCredentials()
  10. {
  11. }
  12. public function serialize()
  13. {
  14. return serialize(array(
  15. $this->id,
  16. $this->username,
  17. $this->password
  18. ));
  19. }
  20. public function unserialize($serialized)
  21. {
  22. list (
  23. $this->id,
  24. $this->username,
  25. $this->password,
  26. ) = unserialize($serialized);
  27. }

尽管所有的密码都需要用盐来进行哈希,但在这种情况下,getSalt 函数是无关紧要的,因为 bcrypt 内部已经完成了这项工作。getRoles函数是重要的一点。我们可以返回单个客户将拥有的一个或多个角色。为了使事情简单化,我们将只为每个客户分配一个 ROLE_USER 角色。但这可以很容易地变得更加强大,这样角色也会存储在数据库中。eraseCredentials函数只是一个清理方法,我们将其留空。

由于用户对象每次请求都要先进行未序列化、序列化,并保存到会话中,所以我们实现了 \Serializable 接口。实际实现序列化和unserialize可以只包括一部分客户属性,因为我们不需要把所有的东西都保存在session中。

在我们开始实现注册、登录、忘记密码等位之前,我们先来定义一下我们后面要使用的需要的服务。

创建订单服务

我们将创建一个订单服务,用于填写 “我的账户 “页面下的可用数据。稍后,其他模块可以覆盖这个服务并注入真实的客户订单。要定义一个订单服务,我们编辑 src/Foggyline/CustomerBundle/Resources/config/services.xml 文件,在 services 元素下添加以下内容:

  1. <service id="foggyline_customer.customer_orders" class="Foggyline\CustomerBundle\Service\CustomerOrders">
  2. </service>

然后,我们继续创建 src/Foggyline/CustomerBundle/Service/CustomerOrders.php目录,内容如下:

  1. namespace Foggyline\CustomerBundle\Service;
  2. class CustomerOrders
  3. {
  4. public function getOrders()
  5. {
  6. return array(
  7. array(
  8. 'id' => '0000000001',
  9. 'date' => '23/06/2016 18:45',
  10. 'ship_to' => 'John Doe',
  11. 'order_total' => 49.99,
  12. 'status' => 'Processing',
  13. 'actions' => array(
  14. array(
  15. 'label' => 'Cancel',
  16. 'path' => '#'
  17. ),
  18. array(
  19. 'label' => 'Print',
  20. 'path' => '#'
  21. )
  22. )
  23. ),
  24. );
  25. }
  26. }

getOrders 方法在这里只是返回一些虚数据。我们可以很容易地让它返回一个空数组。理想情况下,我们希望它返回一个符合某些特定接口的特定类型元素的集合。

创建客户菜单服务

在上一个模块中,我们定义了一个客户服务,在客户菜单中填充一些虚拟数据。现在我们将创建一个覆盖服务,根据客户登录状态,用实际的客户数据填充菜单。要定义一个客户菜单服务,我们编辑src/Foggyline/CustomerBundle/Resources/config/services.xml文件,在services元素下添加以下内容:

  1. <service id="foggyline_customer.customer_menu" class="Foggyline\CustomerBundle\Service\Menu\CustomerMenu">
  2. <argument type="service" id="security.token_storage"/>
  3. <argument type="service" id="router"/>
  4. </service>

这里我们将token_storagerouter 对象注入到我们的服务中,因为我们需要它们根据客户的登录状态来构建菜单。

然后我们继续创建src/Foggyline/CustomerBundle/Service/Menu/CustomerMenu.php,内容如下:

  1. namespace Foggyline\CustomerBundle\Service\Menu;
  2. class CustomerMenu
  3. {
  4. private $token;
  5. private $router;
  6. public function __construct(
  7. $tokenStorage,
  8. \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  9. )
  10. {
  11. $this->token = $tokenStorage->getToken();
  12. $this->router = $router;
  13. }
  14. public function getItems()
  15. {
  16. $items = array();
  17. $user = $this->token->getUser();
  18. if ($user instanceof \Foggyline\CustomerBundle\Entity\Customer) {
  19. // customer authentication
  20. $items[] = array(
  21. 'path' => $this->router->generate('customer_account'),
  22. 'label' => $user->getFirstName() . ' ' . $user->getLastName(),
  23. );
  24. $items[] = array(
  25. 'path' => $this->router->generate('customer_logout'),
  26. 'label' => 'Logout',
  27. );
  28. } else {
  29. $items[] = array(
  30. 'path' => $this->router->generate('foggyline_customer_login'),
  31. 'label' => 'Login',
  32. );
  33. $items[] = array(
  34. 'path' => $this->router->generate('foggyline_customer_register'),
  35. 'label' => 'Register',
  36. );
  37. }
  38. return $items;
  39. }
  40. }

在这里,我们看到一个基于用户登录状态的菜单被构建。这样一来,客户在登录时可以看到Logout链接,在未登录时可以看到Login

然后我们添加src/Foggyline/CustomerBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php,内容如下:

  1. namespace Foggyline\CustomerBundle\DependencyInjection\Compiler;
  2. use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
  3. use Symfony\Component\DependencyInjection\ContainerBuilder;
  4. class OverrideServiceCompilerPass implements CompilerPassInterface
  5. {
  6. public function process(ContainerBuilder $container)
  7. {
  8. // Override the core module 'onsale' service
  9. $container->removeDefinition('customer_menu');
  10. $container->setDefinition('customer_menu', $container->getDefinition('foggyline_customer.customer_menu'));
  11. }
  12. }

这里我们做的是实际的customer_menu服务覆盖。然而,这将不会启动,直到我们编辑src/Foggyline/CustomerBundle/FoggylineCustomerBundle.php,添加构建方法如下:

  1. namespace Foggyline\CustomerBundle;
  2. use Symfony\Component\HttpKernel\Bundle\Bundle;
  3. use Symfony\Component\DependencyInjection\ContainerBuilder;
  4. use Foggyline\CustomerBundle\DependencyInjection\Compiler\OverrideServiceCompilerPass;
  5. class FoggylineCustomerBundle extends Bundle
  6. {
  7. public function build(ContainerBuilder $container)
  8. {
  9. parent::build($container);;
  10. $container->addCompilerPass(new OverrideServiceCompilerPass());
  11. }
  12. }

addCompilerPass方法调用接受我们的OverrideServiceCompilerPass的实例,确保我们的服务覆盖会启动。

实施注册程序

为了实现注册页面,我们首先修改src/Foggyline/CustomerBundle/Controller/CustomerController.php文件如下:

  1. /**
  2. * @Route("/register", name="foggyline_customer_register")
  3. */
  4. public function registerAction(Request $request)
  5. {
  6. // 1) build the form
  7. $user = new Customer();
  8. $form = $this->createForm(CustomerType::class, $user);
  9. // 2) handle the submit (will only happen on POST)
  10. $form->handleRequest($request);
  11. if ($form->isSubmitted() && $form->isValid()) {
  12. // 3) Encode the password (you could also do this via Doctrine listener)
  13. $password = $this->get('security.password_encoder')
  14. ->encodePassword($user, $user->getPlainPassword());
  15. $user->setPassword($password);
  16. // 4) save the User!
  17. $em = $this->getDoctrine()->getManager();
  18. $em->persist($user);
  19. $em->flush();
  20. // ... do any other work - like sending them an email, etc
  21. // maybe set a "flash" success message for the user
  22. return $this->redirectToRoute('customer_account');
  23. }
  24. return $this->render(
  25. 'FoggylineCustomerBundle:default:customer/register.html.twig',
  26. array('form' => $form->createView())
  27. );
  28. }

注册页面使用标准的自动生成的Customer CRUD 表单,只需将其指向src/Foggyline/CustomerBundle/Resources/views/Default/customer/register.html.twig模板文件,内容如下:

  1. {% extends 'base.html.twig' %}
  2. {% block body %}
  3. {{ form_start(form) }}
  4. {{ form_widget(form) }}
  5. <button type="submit">Register!</button>
  6. {{ form_end(form) }}
  7. {% endblock %}

一旦这两个文件到位,我们的注册功能就应该可以使用了。

实施登录流程

我们将在自己的/customer/login URL上实现登录页面,因此我们编辑CustomerController.php文件,添加loginAction函数如下:

  1. /**
  2. * Creates a new Customer entity.
  3. *
  4. * @Route("/login", name="foggyline_customer_login")
  5. */
  6. public function loginAction(Request $request)
  7. {
  8. $authenticationUtils = $this->get('security.authentication_utils');
  9. // get the login error if there is one
  10. $error = $authenticationUtils->getLastAuthenticationError();
  11. // last username entered by the user
  12. $lastUsername = $authenticationUtils->getLastUsername();
  13. return $this->render(
  14. 'FoggylineCustomerBundle:default:customer/login.html.twig',
  15. array(
  16. // last username entered by the user
  17. 'last_username' => $lastUsername,
  18. 'error' => $error,
  19. )
  20. );
  21. }

在这里,我们只是简单地检查用户是否已经尝试登录,如果是,我们将把这些信息和潜在的错误一起传递给模板。然后我们编辑 src/Foggyline/CustomerBundle/Resources/views/default/customer/login.html.twig 文件,内容如下:

  1. {% extends 'base.html.twig' %}
  2. {% block body %}
  3. {% if error %}
  4. <div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
  5. {% endif %}
  6. <form action="{{ path('foggyline_customer_login') }}" method="post">
  7. <label for="username">Username:</label>
  8. <input type="text" id="username" name="_username" value="{{ last_username }}"/>
  9. <label for="password">Password:</label>
  10. <input type="password" id="password" name="_password"/>
  11. <button type="submit">login</button>
  12. </form>
  13. <div class="row">
  14. <a href="{{ path('customer_forgotten_password') }}">Forgot your password?</a>
  15. </div>
  16. {% endblock %}

一旦登录,用户将被重定向到/customer/account页面。我们通过在CustomerController.php文件中添加accountAction方法来创建这个页面,如下所示:

  1. /**
  2. * Finds and displays a Customer entity.
  3. *
  4. * @Route("/account", name="customer_account")
  5. * @Method({"GET", "POST"})
  6. */
  7. public function accountAction(Request $request)
  8. {
  9. if (!$this->get('security.authorization_checker')->isGranted('ROLE_USER')) {
  10. throw $this->createAccessDeniedException();
  11. }
  12. if ($customer = $this->getUser()) {
  13. $editForm = $this->createForm('Foggyline\CustomerBundle\Form\CustomerType', $customer, array( 'action' => $this->generateUrl('customer_account')));
  14. $editForm->handleRequest($request);
  15. if ($editForm->isSubmitted() && $editForm->isValid()) {
  16. $em = $this->getDoctrine()->getManager();
  17. $em->persist($customer);
  18. $em->flush();
  19. $this->addFlash('success', 'Account updated.');
  20. return $this->redirectToRoute('customer_account');
  21. }
  22. return $this->render('FoggylineCustomerBundle:default:customer/account.html.twig', array(
  23. 'customer' => $customer,
  24. 'form' => $editForm->createView(),
  25. 'customer_orders' => $this->get('foggyline_customer.customer_orders')->getOrders()
  26. ));
  27. } else {
  28. $this->addFlash('notice', 'Only logged in customers can access account page.');
  29. return $this->redirectToRoute('foggyline_customer_login');
  30. }
  31. }

使用 $this->getUser() 我们检查是否设置了登录用户,如果是,则将其信息传递给模板。然后我们编辑 src/Foggyline/CustomerBundle/Resources/views/Default/customer/account.html.twig 文件,内容如下:

  1. {% extends 'base.html.twig' %}
  2. {% block body %}
  3. <h1>My Account</h1>
  4. {{ form_start(form) }}
  5. <div class="row">
  6. <div class="medium-6 columns">
  7. {{ form_row(form.email) }}
  8. {{ form_row(form.username) }}
  9. {{ form_row(form.plainPassword.first) }}
  10. {{ form_row(form.plainPassword.second) }}
  11. {{ form_row(form.firstName) }}
  12. {{ form_row(form.lastName) }}
  13. {{ form_row(form.company) }}
  14. {{ form_row(form.phoneNumber) }}
  15. </div>
  16. <div class="medium-6 columns">
  17. {{ form_row(form.country) }}
  18. {{ form_row(form.state) }}
  19. {{ form_row(form.city) }}
  20. {{ form_row(form.postcode) }}
  21. {{ form_row(form.street) }}
  22. <button type="submit">Save</button>
  23. </div>
  24. </div>
  25. {{ form_end(form) }}
  26. <!-- customer_orders -->
  27. {% endblock %}

有了这个,我们解决了 “我的账户 “页面的实际客户信息部分。在当前状态下,这个页面应该呈现一个编辑表单,如下面的截图所示,使我们能够编辑所有的客户信息:

构建客户模块 - 图6

然后,我们对<!--customer_orders-->进行处理,将其替换为以下内容:

  1. {% block customer_orders %}
  2. <h2>My Orders</h2>
  3. <div class="row">
  4. <table>
  5. <thead>
  6. <tr>
  7. <th width="200">Order Id</th>
  8. <th>Date</th>
  9. <th width="150">Ship To</th>
  10. <th width="150">Order Total</th>
  11. <th width="150">Status</th>
  12. <th width="150">Actions</th>
  13. </tr>
  14. </thead>
  15. <tbody>
  16. {% for order in customer_orders %}
  17. <tr>
  18. <td>{{ order.id }}</td>
  19. <td>{{ order.date }}</td>
  20. <td>{{ order.ship_to }}</td>
  21. <td>{{ order.order_total }}</td>
  22. <td>{{ order.status }}</td>
  23. <td>
  24. <div class="small button-group">
  25. {% for action in order.actions %}
  26. <a class="button" href="{{ action.path }}">{{ action.label }}</a>
  27. {% endfor %}
  28. </div>
  29. </td>
  30. </tr>
  31. {% endfor %}
  32. /tbody>
  33. </table>
  34. </div>
  35. {% endblock %}

现在,”我的账户 “页面的 “我的订单 “部分应该会出现如下图所示:

构建客户模块 - 图7

这只是来自src/Foggyline/CustomerBundle/Resources/config/services.xml中定义的服务的虚拟数据。在后面的章节中,当我们进入销售模块时,我们将确保它覆盖foggyline_customer.customer_orders服务,以便在这里插入真实的客户数据。

实施登出程序

在定义防火墙时,我们对security.yml做了一个修改,就是配置注销路径,我们将其指向/customer/logout。这个路径的实现是在CustomerController.php文件中完成的,如下所示:

  1. /**
  2. * @Route("/logout", name="customer_logout")
  3. */
  4. public function logoutAction()
  5. {
  6. }

注意,logoutAction方法其实是空的。没有这样的实现。不需要实现,因为 Symfony 会拦截请求并为我们处理注销。但是,我们需要定义这个路由,因为我们从 system.xml 文件中引用了它。

管理忘记的密码

忘记密码功能要作为一个单独的页面来实现。我们编辑CustomerController.php文件,在其中添加忘记密码功能,如下所示:

  1. /**
  2. * @Route("/forgotten_password", name="customer_forgotten_password")
  3. * @Method({"GET", "POST"})
  4. */
  5. public function forgottenPasswordAction(Request $request)
  6. {
  7. // Build a form, with validation rules in place
  8. $form = $this->createFormBuilder()
  9. ->add('email', EmailType::class, array(
  10. 'constraints' => new Email()
  11. ))
  12. ->add('save', SubmitType::class, array(
  13. 'label' => 'Reset!',
  14. 'attr' => array('class' => 'button'),
  15. ))
  16. ->getForm();
  17. // Check if this is a POST type request and if so, handle form
  18. if ($request->isMethod('POST')) {
  19. $form->handleRequest($request);
  20. if ($form->isSubmitted() && $form->isValid()) {
  21. $this->addFlash('success', 'Please check your email for reset password.');
  22. // todo: Send an email out to website admin or something...
  23. return $this->redirect($this->generateUrl('foggyline_customer_login'));
  24. }
  25. }
  26. // Render "contact us" page
  27. return $this->render('FoggylineCustomerBundle:default:customer/forgotten_password.html.twig', array(
  28. 'form' => $form->createView()
  29. ));
  30. }

这里我们只是检查 HTTP 请求是 GET 还是 POST,然后发送邮件或者加载模板。为了简单起见,我们还没有真正实现电子邮件的实际发送。这是需要在本书之外解决的事情。渲染后的模板指向src/Foggyline/CustomerBundle/Resources/views/default/customer/forgotten_password.html.twig文件,内容如下:

  1. {% extends 'base.html.twig' %}
  2. {% block body %}
  3. <div class="row">
  4. <h1>Forgotten Password</h1>
  5. </div>
  6. <div class="row">
  7. {{ form_start(form) }}
  8. {{ form_widget(form) }}
  9. {{ form_end(form) }}
  10. </div>
  11. {% endblock %}

单元测试

除了自动生成的 Customer 实体和它的CRUD控制器之外,我们只创建了两个自定义服务类作为这个模块的一部分。由于我们并不追求完整的代码覆盖率,所以我们将只覆盖CustomerOrdersCustomerMenu服务类作为单元测试的一部分。

我们首先在phpunit.xml.dist文件的testsuites元素下添加以下一行。

  1. <directory>src/Foggyline/CustomerBundle/Tests</directory>

有了这些,从我们的商店根目录下运行phpunit命令,就可以在src/Foggyline/CustomerBundle/Tests/目录下找到我们定义的测试。

现在让我们继续为CustomerOrders服务创建一个测试。我们创建一个 src/Foggyline/CustomerBundle/Tests/Service/CustomerOrders.php 文件,内容如下:

  1. namespace Foggyline\CustomerBundle\Tests\Service;
  2. use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
  3. class CustomerOrders extends KernelTestCase
  4. {
  5. private $container;
  6. public function setUp()
  7. {
  8. static::bootKernel();
  9. $this->container = static::$kernel->getContainer();
  10. }
  11. public function testGetItemsViaService()
  12. {
  13. $orders = $this->container->get('foggyline_customer.customer_orders');
  14. $this->assertNotEmpty($orders->getOrders());
  15. }
  16. public function testGetItemsViaClass()
  17. {
  18. $orders = new \Foggyline\CustomerBundle\Service\CustomerOrders();
  19. $this->assertNotEmpty($orders->getOrders());
  20. }
  21. }

这里我们一共有两个测试,一个通过服务实例化类,另一个直接实例化类。我们使用setUp方法只是为了设置容器属性,然后在testGetItemsViaService方法中重用。

接下来,我们在目录内创建CustomerMenu测试,具体如下:

  1. namespace Foggyline\CustomerBundle\Tests\Service\Menu;
  2. use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
  3. class CustomerMenu extends KernelTestCase
  4. {
  5. private $container;
  6. private $tokenStorage;
  7. private $router;
  8. public function setUp()
  9. {
  10. static::bootKernel();
  11. $this->container = static::$kernel->getContainer();
  12. $this->tokenStorage = $this->container->get('security.token_storage');
  13. $this->router = $this->container->get('router');
  14. }
  15. public function testGetItemsViaService()
  16. {
  17. $menu = $this->container->get('foggyline_customer.customer_menu');
  18. $this->assertNotEmpty($menu->getItems());
  19. }
  20. public function testGetItemsViaClass()
  21. {
  22. $menu = new \Foggyline\CustomerBundle\Service\Menu\CustomerMenu(
  23. $this->tokenStorage,
  24. $this->router
  25. );
  26. $this->assertNotEmpty($menu->getItems());
  27. }
  28. }

现在,如果我们运行phpunit命令,我们应该会看到我们的测试和其他测试一起被选中并执行。我们甚至可以通过执行phpunit命令来专门针对这两个测试进行全类路径的测试,如图所示:

  1. phpunit src/Foggyline/CustomerBundle/Tests/Service/CustomerOrders.php
  2. phpunit src/Foggyline/CustomerBundle/Tests/Service/Menu/CustomerMenu.php

功能测试

自动生成CRUD工具在src/Foggyline/CustomerBundle/Tests/Controller/目录下为我们生成了CustomerControllerTest.php文件。在上一章中,我们展示了如何向static::createClient传递一个认证参数,以使其模拟用户登录。然而,这与我们客户将要使用的登录方式不同。我们不再使用一个基本的HTTP认证,而是一个完整的登录表单。

为了解决登录表单的测试,让我们继续编辑src/Foggyline/CustomerBundle/Tests/Controller/CustomerControllerTest.php文件如下:

  1. namespace Foggyline\CustomerBundle\Tests\Controller;
  2. use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  3. use Symfony\Component\BrowserKit\Cookie;
  4. use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
  5. class CustomerControllerTest extends WebTestCase
  6. {
  7. private $client = null;
  8. public function setUp()
  9. {
  10. $this->client = static::createClient();
  11. }
  12. public function testMyAccountAccess()
  13. {
  14. $this->logIn();
  15. $crawler = $this->client->request('GET', '/customer/account');
  16. $this->assertTrue($this->client->getResponse()->
  17. isSuccessful());
  18. $this->assertGreaterThan(0, $crawler->filter('html:contains("My Account")')->count());
  19. }
  20. private function logIn()
  21. {
  22. $session = $this->client->getContainer()->get('session');
  23. $firewall = 'foggyline_customer'; // firewall name
  24. $em = $this->client->getContainer()->get('doctrine')->getManager();
  25. $user = $em->getRepository('FoggylineCustomerBundle:Customer')->findOneByUsername('john@test.loc');
  26. $token = new UsernamePasswordToken($user, null, $firewall, array('ROLE_USER'));
  27. $session->set('_security_' . $firewall, serialize($token));
  28. $session->save();
  29. $cookie = new Cookie($session->getName(), $session->getId());
  30. $this->client->getCookieJar()->set($cookie);
  31. }
  32. }

在这里,我们首先创建了logIn方法,其目的是模拟登录,通过在会话中设置适当的 token 值,并将该session ID通过cookie传递给客户端。然后我们创建了testMyAccountAccess方法,该方法首先调用logIn方法,然后检查爬虫是否能够访问我的账户页面。这种方法最大的好处是,我们不需要输入用户密码的代码,只需要输入它的用户名。

现在,我们继续解决客户注册表的问题,在CustomerControllerTest中加入以下内容:

  1. public function testRegisterForm()
  2. {
  3. $crawler = $this->client->request('GET', '/customer/register');
  4. $uniqid = uniqid();
  5. $form = $crawler->selectButton('Register!')->form(array(
  6. 'customer[email]' => 'john_' . $uniqid . '@test.loc',
  7. 'customer[username]' => 'john_' . $uniqid,
  8. 'customer[plainPassword][first]' => 'pass123',
  9. 'customer[plainPassword][second]' => 'pass123',
  10. 'customer[firstName]' => 'John',
  11. 'customer[lastName]' => 'Doe',
  12. 'customer[company]' => 'Foggyline',
  13. 'customer[phoneNumber]' => '00 385 111 222 333',
  14. 'customer[country]' => 'HR',
  15. 'customer[state]' => 'Osijek',
  16. 'customer[city]' => 'Osijek',
  17. 'customer[postcode]' => '31000',
  18. 'customer[street]' => 'The Yellow Street',
  19. ));
  20. $this->client->submit($form);
  21. $crawler = $this->client->followRedirect();
  22. //var_dump($this->client->getResponse()->getContent());
  23. $this->assertGreaterThan(0, $crawler->filter('html:contains("customer/login")')->count());
  24. }

在上一章中我们已经看到了一个类似于这个的测试。这里我们只是打开一个客户/注册页面,然后找到一个带有Register!标签的按钮,这样我们就可以通过它来获取整个表单。然后我们设置所有需要的表单数据,并模拟表单提交。如果成功,我们观察重定向体,并对其中的预期值进行断言。

现在运行phpunit命令应该可以成功执行我们的测试。

小结

在本章中,我们构建了一个微型但功能完善的客户模块。该模块假定在我们的security.yml文件上做了一定程度的设置,如果我们要重新发布它,可以将其作为模块文档的一部分。这些变化包括定义我们自己的自定义防火墙,以及自定义安全提供者。安全提供者指向我们的customer类,而customer类又是以符合Symfony UserInterface的方式构建的。然后我们构建了一个注册、登录和忘记密码的表单。虽然每一个都带有一套最小的功能,但我们看到了构建一个完全自定义的注册和登录系统是多么简单。

此外,我们还应用了一些前瞻性的思维,通过使用专门定义的服务,在 “我的账户 “页面下设置了 “我的订单 “部分。这是目前最理想的方式,而且它也有一定的作用,因为我们以后会从销售模块中干净利落地覆盖这个服务。

接下来,在下一章,我们将建立一个支付模块。