八、客户模块构建

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

在本章中,我们将介绍以下主题:

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

要求

根据第 4 章模块化网店 App需求规范中定义的高层应用需求,我们的模块将定义一个Customer实体。

Customer实体包括以下属性:

  • id:整数,自动递增
  • email:字符串,唯一
  • username:字符串,唯一,登录系统需要
  • password:字符串
  • first_name:字符串
  • last_name:字符串
  • company:字符串
  • phone_number:字符串
  • country:字符串
  • state:字符串
  • city:字符串
  • postcode:字符串
  • street:字符串

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

依赖关系

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

实施

我们首先创建一个名为Foggyline\CustomerBundle的新模块。我们在 console 的帮助下,通过运行以下命令来完成此操作:

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

该命令会触发一个交互过程,在过程中向我们询问几个问题,如以下屏幕截图所示:

Implementation

完成后,将为我们生成以下结构:

Implementation

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

new Foggyline\CustomerBundle\FoggylineCustomerBundle()

类似地,app/config/routing.yml目录添加了以下路由定义:

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

这里我们需要将prefix: /更改为prefix: /customer/,这样我们就不会与核心模块路由冲突。将其保留为prefix: /只会超出我们的核心AppBundle并输出Hello World!此时从src/Foggyline/CustomerBundle/Resources/views/Default/index.html.twig模板到浏览器的。我们想保持事物的美好和分离。这意味着模块本身没有定义root路由。

创建客户实体

让我们继续创建一个Customer实体。我们使用控制台来实现这一点,如下所示:

php bin/console generate:doctrine:entity

此命令触发交互式生成器,我们需要在其中提供实体属性。完成后,生成器将在src/Foggyline/CustomerBundle/目录中创建Entity/Customer.phpRepository/CustomerRepository.php文件。在此之后,我们需要更新数据库,因此它通过运行以下命令拉入Customer实体:

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

这将产生如下屏幕截图所示的屏幕:

Creating a customer entity

实体就位后,我们准备生成其 CRUD。我们使用以下命令执行此操作:

php bin/console generate:doctrine:crud

这将产生交互式输出,如下所示:

Creating a customer entity

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

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

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

修改安全配置

在我们继续进一步讨论模块内的实际更改之前,让我们设想一下,为了使其正常工作,我们的模块需求要求进行某种安全配置。这些要求说明我们需要对app/config/security.yml文件应用一些更改。我们首先通过添加以下条目来编辑providers元素:

foggyline_customer:
  entity:
    class: FoggylineCustomerBundle:Customer
  property: username

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

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

Foggyline\CustomerBundle\Entity\Customer:
  algorithm: bcrypt
  cost: 12

这告诉 Symfony 在加密密码时使用值为12bcrypt算法作为算法开销。这样,我们的密码保存在数据库中时不会以明文形式结束。

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

foggyline_customer:
  anonymous: ~
  provider: foggyline_customer
  form_login:
    login_path: foggyline_customer_login
    check_path: foggyline_customer_login
    default_target_path: customer_account
  logout:
    path:   /customer/logout
    target: /

这里发生了很多事情。我们的防火墙使用anonymous: ~定义来表示它并不真正需要用户登录才能查看某些页面。默认情况下,所有 Symfony 用户都被认证为匿名用户,如以下屏幕截图所示,位于开发者工具栏上:

Modifying the security configuration

form_login定义具有三个属性。login_pathcheck_path指向我们的定制路线foggyline_customer_login。当安全系统启动身份验证过程时,它会将用户重定向到foggyline_customer_login路由,我们将很快实现所需的控制器逻辑和视图模板,以便处理登录表单。登录后,default_target_path确定用户将被重定向到的位置。

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

- { path: customer/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: customer/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: customer/forgotten_password, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: customer/account, roles: ROLE_USER }
- { path: customer/logout, roles: ROLE_USER }
- { path: customer/, roles: ROLE_ADMIN }

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

扩展客户实体

有了之前的security.yml新增内容,我们现在准备开始实际实施注册流程。首先我们在src/Foggyline/CustomerBundle/Entity/目录中编辑Customer实体,使其实现Symfony\Component\Security\Core\User\UserInterface\Serializable。这意味着实施以下方法:

public function getSalt()
{
  return null;
}

public function getRoles()
{
  return array('ROLE_USER');
}

public function eraseCredentials()
{
}

public function serialize()
{
  return serialize(array(
    $this->id,
    $this->username,
    $this->password
  ));
}

public function unserialize($serialized)
{
  list (
    $this->id,
    $this->username,
    $this->password,
  ) = unserialize($serialized);
}

尽管所有密码都需要用盐散列,但本例中的getSalt函数是不相关的,因为bcrypt在内部执行此操作。getRoles功能是重要的位。我们可以返回单个客户将拥有的一个或多个角色。为了简单起见,我们只为每个客户分配一个ROLE_USER角色。但这很容易变得更加健壮,因此角色也存储在数据库中。eraseCredentials函数只是一个清理方法,我们将其留空。

由于用户对象首先是非序列化的,序列化的,并保存到每个请求的会话中,因此我们实现了\Serializable接口。serialize 和 unserialize 的实际实现只能包含一小部分客户属性,因为我们不需要在会话中存储所有内容。

在我们开始实现注册、登录、忘记密码和其他功能之前,让我们继续定义我们稍后将使用的所需服务。

创建订单服务

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

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

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

namespace Foggyline\CustomerBundle\Service;

class CustomerOrders
{
  public function getOrders()
  {
    return array(
      array(
        'id' => '0000000001',
        'date' => '23/06/2016 18:45',
        'ship_to' => 'John Doe',
        'order_total' => 49.99,
        'status' => 'Processing',
        'actions' => array(
          array(
            'label' => 'Cancel',
            'path' => '#'
          ),
          array(
            'label' => 'Print',
            'path' => '#'
          )
        )
      ),
    );
  }
}

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

创建客户菜单服务

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

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

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

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

namespace Foggyline\CustomerBundle\Service\Menu;

class CustomerMenu
{
  private $token;
  private $router;

  public function __construct(
    $tokenStorage,
    \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  )
  {
    $this->token = $tokenStorage->getToken();
    $this->router = $router;
  }

  public function getItems()
  {
    $items = array();
    $user = $this->token->getUser();

    if ($user instanceof \Foggyline\CustomerBundle\Entity\Customer) {
      // customer authentication
      $items[] = array(
        'path' => $this->router->generate('customer_account'),
        'label' => $user->getFirstName() . ' ' . $user->getLastName(),
      );
      $items[] = array(
        'path' => $this->router->generate('customer_logout'),
        'label' => 'Logout',
      );
    } else {
      $items[] = array(
        'path' => $this->router->generate('foggyline_customer_login'),
        'label' => 'Login',
      );
      $items[] = array(
        'path' => $this->router->generate('foggyline_customer_register'),
        'label' => 'Register',
      );
    }

    return $items;
  }
}

这里我们看到一个基于用户登录状态的菜单。这样,客户可以在登录时看到注销链接,或者在未登录时看到登录链接。

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

namespace Foggyline\CustomerBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class OverrideServiceCompilerPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    // Override the core module 'onsale' service
    $container->removeDefinition('customer_menu');
    $container->setDefinition('customer_menu', $container->getDefinition('foggyline_customer.customer_menu'));
  }
}

这里我们正在执行实际的customer_menu服务覆盖。但是,在我们编辑src/Foggyline/CustomerBundle/FoggylineCustomerBundle.php目录之前,这不会生效,我们将build方法添加到该目录中,如下所示:

namespace Foggyline\CustomerBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Foggyline\CustomerBundle\DependencyInjection\Compiler\OverrideServiceCompilerPass;

class FoggylineCustomerBundle extends Bundle
{
  public function build(ContainerBuilder $container)
  {
    parent::build($container);;
    $container->addCompilerPass(new OverrideServiceCompilerPass());
  }
}

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

实现注册流程

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

/**
 * @Route("/register", name="foggyline_customer_register")
 */
public function registerAction(Request $request)
{
  // 1) build the form
  $user = new Customer();
  $form = $this->createForm(CustomerType::class, $user);

  // 2) handle the submit (will only happen on POST)
  $form->handleRequest($request);
  if ($form->isSubmitted() && $form->isValid()) {

    // 3) Encode the password (you could also do this via Doctrine listener)
    $password = $this->get('security.password_encoder')
    ->encodePassword($user, $user->getPlainPassword());
    $user->setPassword($password);

    // 4) save the User!
    $em = $this->getDoctrine()->getManager();
    $em->persist($user);
    $em->flush();

    // ... do any other work - like sending them an email, etc
    // maybe set a "flash" success message for the user

    return $this->redirectToRoute('customer_account');
  }

  return $this->render(
    'FoggylineCustomerBundle:default:customer/register.html.twig',
    array('form' => $form->createView())
  );
}

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

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

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

实现登录流程

我们将在自己的/customer/loginURL 上实现登录页面,因此我们通过添加loginAction功能来编辑CustomerController.php文件,如下所示:

/**
 * Creates a new Customer entity.
 *
 * @Route("/login", name="foggyline_customer_login")
 */
public function loginAction(Request $request)
{
  $authenticationUtils = $this->get('security.authentication_utils');

  // get the login error if there is one
  $error = $authenticationUtils->getLastAuthenticationError();

  // last username entered by the user
  $lastUsername = $authenticationUtils->getLastUsername();

  return $this->render(
    'FoggylineCustomerBundle:default:customer/login.html.twig',
    array(
      // last username entered by the user
      'last_username' => $lastUsername,
      'error'         => $error,
    )
  );
}

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

{% extends 'base.html.twig' %}
{% block body %}
{% if error %}
<div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}

<form action="{{ path('foggyline_customer_login') }}" method="post">
  <label for="username">Username:</label>
  <input type="text" id="username" name="_username" value="{{ last_username }}"/>
  <label for="password">Password:</label>
  <input type="password" id="password" name="_password"/>
  <button type="submit">login</button>
</form>

<div class="row">
  <a href="{{ path('customer_forgotten_password') }}">Forgot your password?</a>
</div>
{% endblock %}

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

/**
 * Finds and displays a Customer entity.
 *
 * @Route("/account", name="customer_account")
 * @Method({"GET", "POST"})
 */
public function accountAction(Request $request)
{
  if (!$this->get('security.authorization_checker')->isGranted('ROLE_USER')) {
    throw $this->createAccessDeniedException();
  }

  if ($customer = $this->getUser()) {

    $editForm = $this->createForm('Foggyline\CustomerBundle\Form\CustomerType', $customer, array( 'action' => $this->generateUrl('customer_account')));
    $editForm->handleRequest($request);

    if ($editForm->isSubmitted() && $editForm->isValid()) {
      $em = $this->getDoctrine()->getManager();
      $em->persist($customer);
      $em->flush();

      $this->addFlash('success', 'Account updated.');
      return $this->redirectToRoute('customer_account');
    }

    return $this->render('FoggylineCustomerBundle:default:customer/account.html.twig', array(
    'customer' => $customer,
    'form' => $editForm->createView(),
    'customer_orders' => $this->get('foggyline_customer.customer_orders')->getOrders()
    ));
  } else {
    $this->addFlash('notice', 'Only logged in customers can access account page.');
    return $this->redirectToRoute('foggyline_customer_login');
  }
}

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

{% extends 'base.html.twig' %}
{% block body %}
<h1>My Account</h1>
{{ form_start(form) }}
<div class="row">
  <div class="medium-6 columns">
    {{ form_row(form.email) }}
    {{ form_row(form.username) }}
    {{ form_row(form.plainPassword.first) }}
    {{ form_row(form.plainPassword.second) }}
    {{ form_row(form.firstName) }}
    {{ form_row(form.lastName) }}
    {{ form_row(form.company) }}
    {{ form_row(form.phoneNumber) }}
  </div>
  <div class="medium-6 columns">
    {{ form_row(form.country) }}
    {{ form_row(form.state) }}
    {{ form_row(form.city) }}
    {{ form_row(form.postcode) }}
    {{ form_row(form.street) }}
    <button type="submit">Save</button>
  </div>
</div>
{{ form_end(form) }}
<!-- customer_orders -->
{% endblock %}

通过此链接,我们可以访问我的账户页面的实际客户信息部分。在当前状态下,此页面应呈现如下屏幕截图所示的编辑表单,使我们能够编辑所有客户信息:

Implementing the login process

然后我们用以下位替换地址<!-- customer_orders -->

{% block customer_orders %}
<h2>My Orders</h2>
<div class="row">
  <table>
    <thead>
      <tr>
        <th width="200">Order Id</th>
        <th>Date</th>
        <th width="150">Ship To</th>
        <th width="150">Order Total</th>
        <th width="150">Status</th>
        <th width="150">Actions</th>
      </tr>
    </thead>
    <tbody>
      {% for order in customer_orders %}
      <tr>
        <td>{{ order.id }}</td>
        <td>{{ order.date }}</td>
        <td>{{ order.ship_to }}</td>
        <td>{{ order.order_total }}</td>
        <td>{{ order.status }}</td>
        <td>
          <div class="small button-group">
            {% for action in order.actions %}
            <a class="button" href="{{ action.path }}">{{ action.label }}</a>
            {% endfor %}
          </div>
        </td>
      </tr>
      {% endfor %}
    /tbody>
  </table>
</div>
{% endblock %}

现在应呈现我的账户页面的我的订单部分,如下所示:

Implementing the login process

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

执行注销流程

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

/**
 * @Route("/logout", name="customer_logout")
 */
public function logoutAction()
{

}

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

管理忘记的密码

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

/**
 * @Route("/forgotten_password", name="customer_forgotten_password")
 * @Method({"GET", "POST"})
 */
public function forgottenPasswordAction(Request $request)
{

  // Build a form, with validation rules in place
  $form = $this->createFormBuilder()
  ->add('email', EmailType::class, array(
    'constraints' => new Email()
  ))
  ->add('save', SubmitType::class, array(
    'label' => 'Reset!',
    'attr' => array('class' => 'button'),
  ))
  ->getForm();

  // Check if this is a POST type request and if so, handle form
  if ($request->isMethod('POST')) {
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
      $this->addFlash('success', 'Please check your email for reset password.');

      // todo: Send an email out to website admin or something...

      return $this->redirect($this->generateUrl('foggyline_customer_login'));
    }
  }

  // Render "contact us" page
  return $this->render('FoggylineCustomerBundle:default:customer/forgotten_password.html.twig', array(
      'form' => $form->createView()
    ));
}

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

{% extends 'base.html.twig' %}
{% block body %}
<div class="row">
  <h1>Forgotten Password</h1>
</div>

<div class="row">
  {{ form_start(form) }}
  {{ form_widget(form) }}
  {{ form_end(form) }}
</div>
{% endblock %}

单元测试

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

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

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

有了它,从我们商店的根目录运行phpunit命令应该可以获取我们在src/Foggyline/CustomerBundle/Tests/目录下定义的任何测试。

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

namespace Foggyline\CustomerBundle\Tests\Service;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class CustomerOrders extends KernelTestCase
{
  private $container;

  public function setUp()
  {
    static::bootKernel();
    $this->container = static::$kernel->getContainer();
  }

  public function testGetItemsViaService()
  {
    $orders = $this->container->get('foggyline_customer.customer_orders');
    $this->assertNotEmpty($orders->getOrders());
  }

  public function testGetItemsViaClass()
  {
    $orders = new \Foggyline\CustomerBundle\Service\CustomerOrders();
    $this->assertNotEmpty($orders->getOrders());
  }
}

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

接下来,我们在目录中创建CustomerMenu测试,如下所示:

namespace Foggyline\CustomerBundle\Tests\Service\Menu;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class CustomerMenu extends KernelTestCase
{
  private $container;
  private $tokenStorage;
  private $router;

  public function setUp()
  {
    static::bootKernel();
    $this->container = static::$kernel->getContainer();
    $this->tokenStorage = $this->container->get('security.token_storage');
    $this->router = $this->container->get('router');
  }

  public function testGetItemsViaService()
  {
    $menu = $this->container->get('foggyline_customer.customer_menu');
    $this->assertNotEmpty($menu->getItems());
  }

  public function testGetItemsViaClass()
  {
    $menu = new \Foggyline\CustomerBundle\Service\Menu\CustomerMenu(
      $this->tokenStorage,
      $this->router
    );

    $this->assertNotEmpty($menu->getItems());
  }
}

现在,如果我们运行phpunit命令,我们将看到我们的测试被拾取并与其他测试一起执行。我们甚至可以通过使用完整的类路径执行phpunit命令来专门针对这两个测试,如下所示:

phpunit src/Foggyline/CustomerBundle/Tests/Service/CustomerOrders.php
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文件,如下所示:

namespace Foggyline\CustomerBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

class CustomerControllerTest extends WebTestCase
{
  private $client = null;

  public function setUp()
  {
    $this->client = static::createClient();
  }

  public function testMyAccountAccess()
  {
    $this->logIn();
    $crawler = $this->client->request('GET', '/customer/account');

    $this->assertTrue($this->client->getResponse()->
      isSuccessful());
    $this->assertGreaterThan(0, $crawler->filter('html:contains("My Account")')->count());
  }

  private function logIn()
  {
    $session = $this->client->getContainer()->get('session');
    $firewall = 'foggyline_customer'; // firewall name
    $em = $this->client->getContainer()->get('doctrine')->getManager();
    $user = $em->getRepository('FoggylineCustomerBundle:Customer')->findOneByUsername('john@test.loc');
    $token = new UsernamePasswordToken($user, null, $firewall, array('ROLE_USER'));
    $session->set('_security_' . $firewall, serialize($token));
    $session->save();
    $cookie = new Cookie($session->getName(), $session->getId());
    $this->client->getCookieJar()->set($cookie);
  }
}

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

现在,让我们在CustomerControllerTest中添加以下内容,继续填写客户登记表:

public function testRegisterForm()
{
  $crawler = $this->client->request('GET', '/customer/register');
  $uniqid = uniqid();
  $form = $crawler->selectButton('Register!')->form(array(
    'customer[email]' => 'john_' . $uniqid . '@test.loc',
    'customer[username]' => 'john_' . $uniqid,
    'customer[plainPassword][first]' => 'pass123',
    'customer[plainPassword][second]' => 'pass123',
    'customer[firstName]' => 'John',
    'customer[lastName]' => 'Doe',
    'customer[company]' => 'Foggyline',
    'customer[phoneNumber]' => '00 385 111 222 333',
    'customer[country]' => 'HR',
    'customer[state]' => 'Osijek',
    'customer[city]' => 'Osijek',
    'customer[postcode]' => '31000',
    'customer[street]' => 'The Yellow Street',
  ));

  $this->client->submit($form);
  $crawler = $this->client->followRedirect();
  //var_dump($this->client->getResponse()->getContent());
  $this->assertGreaterThan(0, $crawler->filter('html:contains("customer/login")')->count());
}

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

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

总结

在本章中,我们构建了一个微型但功能强大的客户模块。该模块假设在security.yml文件上完成了一定程度的设置,如果我们要重新分发该文件,则可以将其作为模块文档的一部分进行介绍。这些更改包括定义我们自己的自定义防火墙,以及自定义安全提供程序。安全提供商指出了我们的customer类,该类又以符合 SymfonyUserInterface的方式构建。然后,我们构建了一个注册、登录和忘记密码表单。虽然每一个都带有一组最小的功能,但我们看到构建一个完全定制的注册和登录系统是多么简单。

此外,我们运用了一些前瞻性的思维,使用专门定义的服务在我的账户页面下设置我的订单部分。到目前为止,这是一种理想的方式,而且它也有其作用,因为我们稍后将从sales模块中完全覆盖此服务。

接下来,在下一章中,我们将构建一个payment模块。