Symfony 2 Joboard : Тестирование форм

Давайте откроем файл JobControllerTest и добавим в него функциональный тест охватывающий процесс создания и валидации вакансии. В конце файла добавьте следующий код:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
public function testJobForm()
{
   $client  = static::createClient();
   $crawler = $client->request('GET', '/job/new');
   $this->assertEquals('App\JoboardBundle\Controller\JobController::newAction',      $client->getRequest()->attributes->get('_controller'));
}

Теперь воспользуемся методом selectButton() объекта $crawler для получения формы. Этот метод позволяет выбирать элементы с тегом button и поля ввода submit. После этого через метод form() этого же объекта, получим доступ к экземпляру формы:

<?php
$form = $crawler->selectButton('Submit Form')->form();

Таким образом, мы получили поля ввода типа submit. При вызове метода form, ему можно передать массив со значениями полей формы «по умолчанию».

<?php
$form = $crawler->selectButton('submit')->form([
     'name' => 'Fabien',
     'my_form[subject]' => 'Symfony Rocks!'
]);

Настало время выбрать нужные поля и задать им значения:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
public function testJobForm()
{
    $client  = static::createClient();
    $crawler = $client->request('GET', '/job/new');
    $this->assertEquals('App\JoboardBundle\Controller\JobController::newAction', $client->getRequest()->attributes->get('_controller'));
    $form = $crawler->selectButton('Просмотр')->form([
            'app_joboardbundle_job[company]'      => 'Test compamy',
            'app_joboardbundle_job[url]'          => 'http://www.example.com/'
            //'app_joboardbundle_job[file]'  => __DIR__.'/../../../../../web/bundles/joboard/images/joboard.png',
            'app_joboardbundle_job[position]'     => 'Разработчик',
            'app_joboardbundle_job[location]'     => 'Москва, Россия',
            'app_joboardbundle_job[description]'  => 'Хорошее знание Symfony2',
            'app_joboardbundle_job[how_to_apply]' => 'Резюме на электронную почту',
            'app_joboardbundle_job[email]'        => '[email protected]',
            'app_joboardbundle_job[is_public]'    => false,
   ]);
    $client->submit($form);
    $this->assertEquals('App\JoboardBundle\Controller\JobController::createAction', $client->getRequest()->attributes->get('_controller'));
}

Также можно сымитировать загрузку файла, если передать абсолютный путь файла в параметр app_joboardbundle_job[file]. После передачи формы проверяем выполненное действие — create.

Тестирование формы

Если форма заполнена верно, то создается вакансия и пользователь перенаправляется на страницу Просмотр.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testJobForm()
{
   // ...
   $client->followRedirect();
   $this->assertEquals('App\JoboardBundle\Controller\JobController::previewAction', $client->getRequest()->attributes->get('_controller'));
}

Проверка записи БД

Теперь необходимо проверить, что вакансия была записана в БД, а поле is_activated установлено в 0(false в коде в базе 0), так как пользователь ещё не опубликовал вакансию.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testJobForm()
{
    // ...
    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
    $query = $em->createQuery('SELECT count(j.id) from AppJoboardBundle:Job j
                                   WHERE j.location = :location
                                   AND (j.is_activated = 0 OR j.is_activated IS NULL)
                                   AND j.is_public = 0');
    $query->setParameter('location', 'Москва, Россия');
    $this->assertTrue((bool) $query->getSingleScalarResult());
}

Проверка на ошибки

Форма для создания вакансии работает, как и ожидалось, если все поля заполнены корректно. Создадим тест для проверки работы при вводе некорректных данных.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testJobForm()
{
    // ...
    $crawler = $client->request('GET', '/job/new');
        $form = $crawler->selectButton('Просмотр')->form([
            'app_joboardbundle_job[company]'      => 'Test compamy',
            'app_joboardbundle_job[position]'     => 'Разработчик',
            'app_joboardbundle_job[location]'     => 'Москва, Россия',
            'app_joboardbundle_job[email]'        => 'not.an.email',
    ]);
    $crawler = $client->submit($form);
    // Проверка количества ошибок
   $this->assertTrue($crawler->filter('.error_list')->count() == 3);
   $this->assertTrue($crawler->filter('#app_joboardbundle_job_description')->siblings()->first()->filter('.error_list')->count() == 1);
   $this->assertTrue($crawler->filter('#app_joboardbundle_job_how_to_apply')->siblings()->first()->filter('.error_list')->count() == 1);
   $this->assertTrue($crawler->filter('#app_joboardbundle_job_email')->siblings()->first()->filter('.error_list')->count() == 1);
}

Проверим область администратора на странице предпросмотра вакансии. Если вакансия не была активирована, то доступны методы — удалить, править, опубликовать. Для проверки этих методов сначала следует создать вакансию. Не будем копировать имеющийся код, а просто добавим метод создания вакансии в класс JobControllerTest.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function createJob($values = [])
    {
        $client  = static::createClient();
        $crawler = $client->request('GET', '/job/new');
        $form    = $crawler->selectButton('Отправить')->form(array_merge([
            'app_joboardbundle_job[company]'      => 'Test company',
            'app_joboardbundle_job[url]'          => 'http://www.example.com/',
            'app_joboardbundle_job[position]'     => 'Разработчик',
            'app_joboardbundle_job[location]'     => 'Москва, Россия',
            'app_joboardbundle_job[description]'  => 'Хорошее знание Symfony2',
            'app_joboardbundle_job[how_to_apply]' => 'Резюме на электронную почту',
            'app_joboardbundle_job[email]'        => '[email protected]',
            'app_joboardbundle_job[is_public]'    => false,
        ], $values));
        $client->submit($form);
        $client->followRedirect();
        return $client;
    }

Метод createJob() создает вакансию и перенаправляет пользователя. Так же можно передать массив значений, который будет смешан со значениями «по умолчанию». Теперь проверить метод publish стало проще:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...    
    public function testPublishJob()
    {
        $client  = $this->createJob(['app_joboardbundle_job[position]' => 'FOO1']);
        $crawler = $client->getCrawler();
        $form = $crawler->selectButton('Опубликовать')->form();
        $client->submit($form);
        $kernel = static::createKernel();
        $kernel->boot();
        $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
        $query = $em->createQuery('SELECT count(j.id) from AppJoboardBundle:Job j
                                   WHERE j.position = :position AND j.is_activated = 1');
        $query->setParameter('position', 'FOO1');
        $this->assertTrue((bool) $query->getSingleScalarResult());
    }

Проверка метода delete:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function testDeleteJob()
    {
        $client  = $this->createJob(['app_joboardbundle_job[position]' => 'FOO2']);
        $crawler = $client->getCrawler();
        $form    = $crawler->selectButton('Удалить')->form();
        $client->submit($form);
        $kernel = static::createKernel();
        $kernel->boot();
        $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
        $query = $em->createQuery('SELECT count(j.id) from AppJoboardBundle:Job j
                                   WHERE j.position = :position');
        $query->setParameter('position', 'FOO2');
        $this->assertTrue(0 == $query->getSingleScalarResult());
    }

Дополнительные тесты

После того, как вакансия была опубликована, править её нельзя. Хотя ссылка на правку отсутствует, добавим тест, чтобы это проверить. Сначала добавим дополнительный параметр методу createJob(), чтобы автоматически публиковать вакансию и создадим метод getJobByPosition(), который будет возвращать вакансию по должности.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function createJob($values = [], $publish = false)
    {
        $client  = static::createClient();
        $crawler = $client->request('GET', '/job/new');
        $form    = $crawler->selectButton('Отправить')->form(array_merge([
            'app_joboardbundle_job[company]'      => 'Test company',
            'app_joboardbundle_job[url]'          => 'http://www.example.com/',
            'app_joboardbundle_job[position]'     => 'Разработчик',
            'app_joboardbundle_job[location]'     => 'Москва, Россия',
            'app_joboardbundle_job[description]'  => 'Хорошее знание Symfony2',
            'app_joboardbundle_job[how_to_apply]' => 'Резюме на электронную почту',
            'app_joboardbundle_job[email]'        => '[email protected]',
            'app_joboardbundle_job[is_public]'    => false,
        ], $values));
        $client->submit($form);
        $client->followRedirect();
        if ($publish) {
            $crawler = $client->getCrawler();
            $form = $crawler->selectButton('Опубликовать')->form();
            $client->submit($form);
            $client->followRedirect();
        }
        return $client;
    }
    public function getJobByPosition($position)
    {
        $kernel = static::createKernel();
        $kernel->boot();
        $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
        $query = $em->createQuery('SELECT j from AppJoboardBundle:Job j WHERE j.position = :position');
        $query->setParameter('position', $position);
        $query->setMaxResults(1);
        return $query->getSingleResult();
    }

Если вакансия опубликована, ссылка на её правку должна возвращать ошибку 404:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function testEditJob()
    {
        $client = $this->createJob(['app_joboardbundle_job[position]' => 'FOO3'], true);
        $client->getCrawler();
        $client->request('GET', sprintf('/job/%s/edit', $this->getJobByPosition('FOO3')->getToken()));
        $this->assertEquals(404, $client->getResponse()->getStatusCode());
    }

Но выполнив этот тест вы не увидите ожидаемого результата, так как вчера мы не дописали эту проверку. Хочется отметить, что написание тестов очень помогает искать ошибки, так как снова приходится продумывать все возможные варианты работы кода. Исправить эту ошибку довольно просто: достаточно перенаправить пользователя на страницу 404, если вакансия опубликована.

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
public function editAction($token)
{
    $em = $this->getDoctrine()->getManager();
    $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
     if (!$entity) {
         throw $this->createNotFoundException('Такой вакансии не существует.');
     }
     if ($entity->getIsActivated()) {
         throw $this->createNotFoundException('Эта вакансия опубликована и не может быть отредактирована.');
     }
     // ...
}

Тест «Назад в будущее»

Если до истечения вакансии осталось меньше пяти дней или её срок уже истек, то пользователь может продлить её действие на 30 дней. Проверить такой функционал в браузере довольно сложно, так как срок действия задается автоматически при создании вакансии. Поэтому при просмотре вакансии ссылка на продление не показывается. Конечно, вы могли бы поменять значение поля в БД или поправить шаблон, чтобы он всегда показывал ссылку, но мы воспользуемся другим методом.

Следует добавить новый маршрут для метода extend:

# src/App/JoboardBundle/Resources/config/routing/job.yml
# ...
app_job_extend:
   pattern:  /{token}/extend/
   defaults: { _controller: "AppJoboardBundle:Job:extend" }
   requirements: { _method: post }

Затем, заменим код ссылки Продлить в admin.html.twig:

<!-- ... -->
{% if job.expiresSoon %}
    <form action="{{ path('app_job_extend', { 'token': job.token }) }}" method="post">
        {{ form_widget(extend_form) }}
        <button type="submit">Продлить</button> ещё на 30 дней
    </form>
 {% endif %}
<!-- ... -->

В JobController создадим метод extendAction и соответствующую форму:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function extendAction($token)
    {
        $form = $this->createExtendForm($token);
        $request = $this->get('request');
        $form->handleRequest($request);
        if ($form->isValid()) {
            $em=$this->getDoctrine()->getManager();
            $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
            if (!$entity) {
                throw $this->createNotFoundException('Такой вакансии не существует.');
            }
            if(!$entity->extend()){
                throw $this->createNodFoundException('Невозможно пролдить вакансию');
            }
            $em->persist($entity);
            $em->flush();
            $this->get('session')->getFlashBag()->add('notice', sprintf('Ваша вакансия продлена до %s', $entity->getExpiresAt()->format('m/d/Y')));
        }
        return $this->redirect($this->generateUrl('app_job_preview', [
            'company'  => $entity->getCompanySlug(),
            'location' => $entity->getLocationSlug(),
            'token'    => $entity->getToken(),
            'position' => $entity->getPositionSlug()
        ]));
    }
    private function createExtendForm($token)
    {
        return $this->createFormBuilder(['token' => $token])
            ->add('token', 'hidden')
            ->getForm();
    }

Добавим форму extend в метод previewAction:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function previewAction($token)
    {
        $em = $this->getDoctrine()->getManager();
        $entity = $em->getRepository('AppJoboardBundle:Job')->findOneByToken($token);
        if (!$entity) {
            throw $this->createNotFoundException('Такой вакансии не существует.');
        }
        $deleteForm = $this->createDeleteForm($entity->getId());
        $publishForm = $this->createPublishForm($entity->getToken());
        $extendForm = $this->createExtendForm($entity->getToken());
        return $this->render('AppJoboardBundle:Job:show.html.twig', array(
            'entity'      => $entity,
            'delete_form' => $deleteForm->createView(),
            'publish_form' => $publishForm->createView(),
            'extend_form' => $extendForm->createView(),
        ));
    }

Добавим метод extend() в модель Job, он возвращает true если вакансия была продлена и false в обратном случае.

<?php
# src/App/JoboardBundle/Entity/Job.php
// ...
public function extend()
{
   if (!$this->expiresSoon())
   {
       return false;
   }
   $this->expires_at = new \DateTime(date('Y-m-d H:i:s', time() + 86400 * 30));
   return true;
}

Добавим сам тест:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function testExtendJob()
    {
        // Вакансия не может быть продлена если ещё не наступил срок продления
        $client = $this->createJob(['app_joboardbundle_job[position]' => 'FOO4'], true);
        $crawler = $client->getCrawler();
        $this->assertTrue($crawler->filter('input[type=submit]:contains("Продлить")')->count() == 0);
        // Вакансия может быть продлена, когда наступил срок продления
        // Создание новой вакансии FOO5
        $client = $this->createJob(['app_joboardbundle_job[position]' => 'FOO5'], true);
        // Получаем вакансию и меняем дату окончания
        $kernel = static::createKernel();
        $kernel->boot();
        $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
        $job = $em->getRepository('AppJoboardBundle:Job')->findOneByPosition('FOO5');
        $job->setExpiresAt(new \DateTime());
        $em->persist($job);
        $em->flush();
        // Идём на страницу просмотра и продлеваем вакансию
        $client->request('GET', sprintf('/job/%s/%s/%s/%s', $job->getCompanySlug(), $job->getLocationSlug(), $job->getToken(), $job->getPositionSlug()));
        $client->followRedirect();
        $crawler = $client->getCrawler();
        $form = $crawler->selectButton('Продлить')->form();
        $client->submit($form);
        // Снова получаем обновлённую вакансию
        $job = $this->getJobByPosition('FOO5');
        // Проверяем дату окончания
        $this->assertTrue($job->getExpiresAt()->format('y/m/d') == date('y/m/d', time() + 86400 * 30));
    }

Удаление просроченных вакансий

Symfony2 поддерживает работу с командной строкой. Мы уже обращались к этому функционалу, когда создавали дерево каталогов нашего приложения и генерировали различные файлы для модели (через php app/console).

Когда пользователь создает вакансию, ему необходимо её активировать, прежде чем она появится в выдаче сайта. Но если этого не делать, то очень скоро ваша БД переполнится недействительными записями. Создадим команду, которая будет удалять их. Выполнять её будем регулярно при помощи cron.

Создайте новый каталог src/App/JoboardBundle/Command, в нём будут храниться все наши команды. Теперь в этом каталоге создайте новый файл JoboardCleanupCommand.php со следующим содержимым:

<?php
# src/App/JoboardBundle/Command/JoboardCleanupCommand.php
namespace App\JoboardBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class JoboardCleanupCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this
            ->setName('app:joboard:cleanup')
            ->setDescription('Очистка базы данных')
            ->addArgument('days', InputArgument::OPTIONAL, 'The email', 90)
        ;
    }
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $days = $input->getArgument('days');
        $em = $this->getContainer()->get('doctrine')->getManager();
        $nb = $em->getRepository('AppJoboardBundle:Job')->cleanup($days);
        $output->writeln(sprintf('Удалено %d вакансий', $nb));
    }
}

Добавим метод cleanup в класс JobRepository:

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
// ...
    public function cleanup($days)
    {
        $query = $this->createQueryBuilder('j')
            ->delete()
            ->andWhere('j.created_at < :created_at')
            ->setParameter('created_at',  date('Y-m-d', time() - 86400 * $days))
            ->getQuery();
        return $query->execute();
    }

Для запуска команды выполните:

php app/console app:joboard:cleanup

или чтобы удалить записи, созданные более 10 дней назад.

php app/console app:joboard:cleanup 10