lifecycle events for projects (#2013)

This commit is contained in:
Kevin Papst
2020-10-07 19:44:55 +02:00
committed by GitHub
parent c2ec15aeb3
commit 46bf01d93e
20 changed files with 626 additions and 63 deletions

View File

@@ -20,7 +20,7 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php,Constants.php}'
exclude: '../src/{DependencyInjection,Entity,Event,Migrations,Tests,Kernel.php,Constants.php}'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class

View File

@@ -17,6 +17,7 @@ use App\Entity\User;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm;
use App\Form\API\ProjectRateApiForm;
use App\Project\ProjectService;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
@@ -64,13 +65,18 @@ class ProjectController extends BaseApiController
* @var ProjectRateRepository
*/
private $projectRateRepository;
/**
* @var ProjectService
*/
private $projectService;
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, ProjectRateRepository $projectRateRepository)
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, ProjectRateRepository $projectRateRepository, ProjectService $projectService)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->dispatcher = $dispatcher;
$this->projectRateRepository = $projectRateRepository;
$this->projectService = $projectService;
}
/**
@@ -216,10 +222,7 @@ class ProjectController extends BaseApiController
throw new AccessDeniedHttpException('User cannot create projects');
}
$project = new Project();
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$project = $this->projectService->createNewProject();
$form = $this->createForm(ProjectApiEditForm::class, $project, [
'date_format' => self::DATE_FORMAT,
@@ -229,7 +232,7 @@ class ProjectController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveProject($project);
$this->projectService->saveNewProject($project);
$view = new View($project, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -301,7 +304,7 @@ class ProjectController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveProject($project);
$this->projectService->updateProject($project);
$view = new View($project, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -354,7 +357,7 @@ class ProjectController extends BaseApiController
$meta->setValue($value);
$this->repository->saveProject($project);
$this->projectService->updateProject($project);
$view = new View($project, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -29,6 +29,7 @@ use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Project\ProjectDuplicationService;
use App\Project\ProjectService;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
@@ -40,9 +41,7 @@ use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -65,12 +64,17 @@ final class ProjectController extends AbstractController
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var ProjectService
*/
private $projectService;
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher, ProjectService $projectService)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
$this->projectService = $projectService;
}
/**
@@ -129,7 +133,7 @@ final class ProjectController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->repository->saveProject($project);
$this->projectService->updateProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project');
@@ -151,13 +155,33 @@ final class ProjectController extends AbstractController
*/
public function createAction(Request $request, ?Customer $customer = null)
{
$project = new Project();
$project = $this->projectService->createNewProject($customer);
if (null !== $customer) {
$project->setCustomer($customer);
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->projectService->saveNewProject($project);
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newProject = $this->projectService->createNewProject($project->getCustomer());
$editForm = $this->createEditForm($newProject);
$editForm->get('create_more')->setData(true);
$project = $newProject;
} else {
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
}
return $this->renderProjectForm($project, $request);
return $this->render('project/edit.html.twig', [
'project' => $project,
'form' => $editForm->createView()
]);
}
/**
@@ -356,7 +380,24 @@ final class ProjectController extends AbstractController
*/
public function editAction(Project $project, Request $request)
{
return $this->renderProjectForm($project, $request);
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->projectService->updateProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
}
return $this->render('project/edit.html.twig', [
'project' => $project,
'form' => $editForm->createView()
]);
}
/**
@@ -449,41 +490,6 @@ final class ProjectController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
/**
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
private function renderProjectForm(Project $project, Request $request)
{
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->repository->saveProject($project);
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newProject = new Project();
$newProject->setCustomer($project->getCustomer());
$editForm = $this->createEditForm($newProject);
$editForm->get('create_more')->setData(true);
$project = $newProject;
} else {
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
}
return $this->render('project/edit.html.twig', [
'project' => $project,
'form' => $editForm->createView()
]);
}
protected function getToolbarForm(ProjectQuery $query): FormInterface
{
return $this->createForm(ProjectToolbarForm::class, $query, [

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Project;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base event class to used with project manipulations.
*/
abstract class AbstractProjectEvent extends Event
{
/**
* @var Project
*/
private $project;
public function __construct(Project $project)
{
$this->project = $project;
}
public function getProject(): Project
{
return $this->project;
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for new project instances, which might or might not be saved.
*/
final class ProjectCreateEvent extends AbstractProjectEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for project instances, which were just saved.
*/
final class ProjectCreatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for project instances, which are just about to being saved.
*/
final class ProjectCreatePreEvent extends AbstractProjectEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for project instances, which were just updated.
*/
final class ProjectUpdatePostEvent extends AbstractProjectEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for project instances, which are just about to being updated.
*/
final class ProjectUpdatePreEvent extends AbstractProjectEvent
{
}

View File

@@ -15,14 +15,13 @@ use App\Entity\ProjectRate;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
final class ProjectDuplicationService
{
/**
* @var ProjectRepository
* @var ProjectService
*/
private $projectRepository;
private $projectService;
/**
* @var ActivityRepository
*/
@@ -37,12 +36,12 @@ final class ProjectDuplicationService
private $activityRateRepository;
public function __construct(
ProjectRepository $projectRepository,
ProjectService $projectService,
ActivityRepository $activityRepository,
ProjectRateRepository $projectRateRepository,
ActivityRateRepository $activityRateRepository
) {
$this->projectRepository = $projectRepository;
$this->projectService = $projectService;
$this->activityRepository = $activityRepository;
$this->projectRateRepository = $projectRateRepository;
$this->activityRateRepository = $activityRateRepository;
@@ -68,7 +67,7 @@ final class ProjectDuplicationService
$newProject->setEnd(null);
}
$this->projectRepository->saveProject($newProject);
$this->projectService->saveNewProject($newProject);
foreach ($this->projectRateRepository->getRatesForProject($project) as $rate) {
/** @var ProjectRate $newRate */

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Project;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\ProjectCreateEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectCreatePreEvent;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\ProjectUpdatePreEvent;
use App\Repository\ProjectRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ProjectService
{
/**
* @var ProjectRepository
*/
private $repository;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var ValidatorInterface
*/
private $validator;
public function __construct(
ProjectRepository $projectRepository,
EventDispatcherInterface $dispatcher,
ValidatorInterface $validator
) {
$this->repository = $projectRepository;
$this->dispatcher = $dispatcher;
$this->validator = $validator;
}
public function createNewProject(?Customer $customer = null): Project
{
$project = new Project();
if ($customer !== null) {
$project->setCustomer($customer);
}
$this->dispatcher->dispatch(new ProjectMetaDefinitionEvent($project));
$this->dispatcher->dispatch(new ProjectCreateEvent($project));
return $project;
}
public function saveNewProject(Project $project): Project
{
if (null !== $project->getId()) {
throw new InvalidArgumentException('Cannot create project, already persisted');
}
$this->validateProject($project);
$this->dispatcher->dispatch(new ProjectCreatePreEvent($project));
$this->repository->saveProject($project);
$this->dispatcher->dispatch(new ProjectCreatePostEvent($project));
return $project;
}
/**
* @param Project $project
* @param string[] $groups
* @throws ValidationFailedException
*/
private function validateProject(Project $project, array $groups = []): void
{
$errors = $this->validator->validate($project, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
}
}
public function updateProject(Project $project): Project
{
$this->validateProject($project);
$this->dispatcher->dispatch(new ProjectUpdatePreEvent($project));
$this->repository->saveProject($project);
$this->dispatcher->dispatch(new ProjectUpdatePostEvent($project));
return $project;
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AccessDeniedException extends AccessDeniedHttpException
{
}

View File

@@ -26,12 +26,12 @@ use App\Event\TimesheetUpdateMultiplePreEvent;
use App\Event\TimesheetUpdatePostEvent;
use App\Event\TimesheetUpdatePreEvent;
use App\Repository\TimesheetRepository;
use App\Security\AccessDeniedException;
use App\Validator\ValidationException;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -117,7 +117,7 @@ final class TimesheetService
* @param Timesheet $copyFrom
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedHttpException if user is not allowed to start timesheet
* @throws AccessDeniedException if user is not allowed to start timesheet
*/
public function restartTimesheet(Timesheet $timesheet, Timesheet $copyFrom): Timesheet
{
@@ -133,7 +133,7 @@ final class TimesheetService
* @return Timesheet
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedHttpException if user is not allowed to start timesheet
* @throws AccessDeniedException if user is not allowed to start timesheet
*/
public function saveNewTimesheet(Timesheet $timesheet): Timesheet
{
@@ -142,7 +142,7 @@ final class TimesheetService
}
if (null === $timesheet->getEnd() && !$this->auth->isGranted('start', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
throw new AccessDeniedException('You are not allowed to start this timesheet record');
}
$this->validateTimesheet($timesheet);

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\Event;
abstract class AbstractProjectEventTest extends TestCase
{
abstract protected function createProjectEvent(Project $project): AbstractProjectEvent;
public function testGetterAndSetter()
{
$project = new Project();
$sut = $this->createProjectEvent($project);
self::assertInstanceOf(Event::class, $sut);
self::assertSame($project, $sut->getProject());
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectCreateEvent;
/**
* @covers \App\Event\AbstractProjectEvent
* @covers \App\Event\ProjectCreateEvent
*/
class ProjectCreateEventTest extends AbstractProjectEventTest
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectCreateEvent($project);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectCreatePostEvent;
/**
* @covers \App\Event\AbstractProjectEvent
* @covers \App\Event\ProjectCreatePostEvent
*/
class ProjectCreatePostEventTest extends AbstractProjectEventTest
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectCreatePostEvent($project);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectCreatePreEvent;
/**
* @covers \App\Event\AbstractProjectEvent
* @covers \App\Event\ProjectCreatePreEvent
*/
class ProjectCreatePreEventTest extends AbstractProjectEventTest
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectCreatePreEvent($project);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectUpdatePostEvent;
/**
* @covers \App\Event\AbstractProjectEvent
* @covers \App\Event\ProjectUpdatePostEvent
*/
class ProjectUpdatePostEventTest extends AbstractProjectEventTest
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectUpdatePostEvent($project);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectUpdatePreEvent;
/**
* @covers \App\Event\AbstractProjectEvent
* @covers \App\Event\ProjectUpdatePreEvent
*/
class ProjectUpdatePreEventTest extends AbstractProjectEventTest
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectUpdatePreEvent($project);
}
}

View File

@@ -0,0 +1,157 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Project;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\ProjectCreateEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectCreatePreEvent;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\ProjectUpdatePreEvent;
use App\Project\ProjectService;
use App\Repository\ProjectRepository;
use App\Validator\ValidationFailedException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @covers \App\Project\ProjectService
*/
class ProjectServiceTest extends TestCase
{
private function getSut(
?EventDispatcherInterface $dispatcher = null,
?ValidatorInterface $validator = null,
?ProjectRepository $repository = null
): ProjectService {
if ($repository === null) {
$repository = $this->createMock(ProjectRepository::class);
}
if ($dispatcher === null) {
$dispatcher = $this->createMock(EventDispatcherInterface::class);
}
if ($validator === null) {
$validator = $this->createMock(ValidatorInterface::class);
$validator->method('validate')->willReturn(new ConstraintViolationList());
}
$service = new ProjectService($repository, $dispatcher, $validator);
return $service;
}
public function testCannotSavePersistedProjectAsNew()
{
$project = $this->createMock(Project::class);
$project->expects($this->once())->method('getId')->willReturn(1);
$sut = $this->getSut();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Cannot create project, already persisted');
$sut->saveNewProject($project);
}
public function testSaveNewProjectHasValidationError()
{
$constraints = new ConstraintViolationList();
$constraints->add(new ConstraintViolation('toooo many tests', 'abc.def', [], '$root', 'begin', 4, null, null, null, '$cause'));
$validator = $this->createMock(ValidatorInterface::class);
$validator->method('validate')->willReturn($constraints);
$sut = $this->getSut(null, $validator);
$this->expectException(ValidationFailedException::class);
$this->expectExceptionMessage('Validation Failed');
$sut->saveNewProject(new Project());
}
public function testUpdateDispatchesEvents()
{
$project = $this->createMock(Project::class);
$project->method('getId')->willReturn(1);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) use ($project) {
if ($event instanceof ProjectUpdatePostEvent) {
self::assertSame($project, $event->getProject());
} elseif ($event instanceof ProjectUpdatePreEvent) {
self::assertSame($project, $event->getProject());
} else {
$this->fail('Invalid event received');
}
});
$sut = $this->getSut($dispatcher);
$sut->updateProject($project);
}
public function testCreateNewProjectDispatchesEvents()
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) {
if ($event instanceof ProjectMetaDefinitionEvent) {
self::assertInstanceOf(Project::class, $event->getEntity());
} elseif ($event instanceof ProjectCreateEvent) {
self::assertInstanceOf(Project::class, $event->getProject());
} else {
$this->fail('Invalid event received');
}
});
$sut = $this->getSut($dispatcher);
$customer = new Customer();
$project = $sut->createNewProject($customer);
self::assertSame($customer, $project->getCustomer());
}
public function testSaveNewProjectDispatchesEvents()
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) {
if ($event instanceof ProjectCreatePreEvent) {
self::assertInstanceOf(Project::class, $event->getProject());
} elseif ($event instanceof ProjectCreatePostEvent) {
self::assertInstanceOf(Project::class, $event->getProject());
} else {
$this->fail('Invalid event received');
}
});
$sut = $this->getSut($dispatcher);
$project = new Project();
$sut->saveNewProject($project);
}
public function testCreateNewProjectWithoutCustomer()
{
$sut = $this->getSut();
$project = $sut->createNewProject();
self::assertNull($project->getCustomer());
$project = $sut->createNewProject();
self::assertNull($project->getCustomer());
}
}