From 46bf01d93e83ee2ec5311bd5f84065a3ed5b4089 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Wed, 7 Oct 2020 19:44:55 +0200 Subject: [PATCH] lifecycle events for projects (#2013) --- config/services.yaml | 2 +- src/API/ProjectController.php | 19 +-- src/Controller/ProjectController.php | 94 ++++++------ src/Event/AbstractProjectEvent.php | 34 +++++ src/Event/ProjectCreateEvent.php | 17 +++ src/Event/ProjectCreatePostEvent.php | 17 +++ src/Event/ProjectCreatePreEvent.php | 17 +++ src/Event/ProjectUpdatePostEvent.php | 17 +++ src/Event/ProjectUpdatePreEvent.php | 17 +++ src/Project/ProjectDuplicationService.php | 11 +- src/Project/ProjectService.php | 104 ++++++++++++++ src/Security/AccessDeniedException.php | 16 +++ src/Timesheet/TimesheetService.php | 8 +- tests/Event/AbstractProjectEventTest.php | 29 ++++ tests/Event/ProjectCreateEventTest.php | 26 ++++ tests/Event/ProjectCreatePostEventTest.php | 26 ++++ tests/Event/ProjectCreatePreEventTest.php | 26 ++++ tests/Event/ProjectUpdatePostEventTest.php | 26 ++++ tests/Event/ProjectUpdatePreEventTest.php | 26 ++++ tests/Project/ProjectServiceTest.php | 157 +++++++++++++++++++++ 20 files changed, 626 insertions(+), 63 deletions(-) create mode 100644 src/Event/AbstractProjectEvent.php create mode 100644 src/Event/ProjectCreateEvent.php create mode 100644 src/Event/ProjectCreatePostEvent.php create mode 100644 src/Event/ProjectCreatePreEvent.php create mode 100644 src/Event/ProjectUpdatePostEvent.php create mode 100644 src/Event/ProjectUpdatePreEvent.php create mode 100644 src/Project/ProjectService.php create mode 100644 src/Security/AccessDeniedException.php create mode 100644 tests/Event/AbstractProjectEventTest.php create mode 100644 tests/Event/ProjectCreateEventTest.php create mode 100644 tests/Event/ProjectCreatePostEventTest.php create mode 100644 tests/Event/ProjectCreatePreEventTest.php create mode 100644 tests/Event/ProjectUpdatePostEventTest.php create mode 100644 tests/Event/ProjectUpdatePreEventTest.php create mode 100644 tests/Project/ProjectServiceTest.php diff --git a/config/services.yaml b/config/services.yaml index e0cc556f..940f69e6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index c392ecbe..c19e0214 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -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); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index e8ba5764..e5f7aa78 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -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, [ diff --git a/src/Event/AbstractProjectEvent.php b/src/Event/AbstractProjectEvent.php new file mode 100644 index 00000000..3345c988 --- /dev/null +++ b/src/Event/AbstractProjectEvent.php @@ -0,0 +1,34 @@ +project = $project; + } + + public function getProject(): Project + { + return $this->project; + } +} diff --git a/src/Event/ProjectCreateEvent.php b/src/Event/ProjectCreateEvent.php new file mode 100644 index 00000000..d1b79a07 --- /dev/null +++ b/src/Event/ProjectCreateEvent.php @@ -0,0 +1,17 @@ +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 */ diff --git a/src/Project/ProjectService.php b/src/Project/ProjectService.php new file mode 100644 index 00000000..182982ed --- /dev/null +++ b/src/Project/ProjectService.php @@ -0,0 +1,104 @@ +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; + } +} diff --git a/src/Security/AccessDeniedException.php b/src/Security/AccessDeniedException.php new file mode 100644 index 00000000..ff52c522 --- /dev/null +++ b/src/Security/AccessDeniedException.php @@ -0,0 +1,16 @@ +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); diff --git a/tests/Event/AbstractProjectEventTest.php b/tests/Event/AbstractProjectEventTest.php new file mode 100644 index 00000000..f6982cc5 --- /dev/null +++ b/tests/Event/AbstractProjectEventTest.php @@ -0,0 +1,29 @@ +createProjectEvent($project); + + self::assertInstanceOf(Event::class, $sut); + self::assertSame($project, $sut->getProject()); + } +} diff --git a/tests/Event/ProjectCreateEventTest.php b/tests/Event/ProjectCreateEventTest.php new file mode 100644 index 00000000..3b5b07b2 --- /dev/null +++ b/tests/Event/ProjectCreateEventTest.php @@ -0,0 +1,26 @@ +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()); + } +}