API endpoints to delete customer/project/activity (#5181)

* added service methods with events to delete customer, project, activity
* added API endpoints to delete customer, project, activity
* added tests for new API endpoints
This commit is contained in:
Kevin Papst
2024-11-27 15:25:13 +01:00
committed by GitHub
parent f13b81ede7
commit e030ff08db
13 changed files with 408 additions and 80 deletions

View File

@@ -9,6 +9,7 @@
namespace App\API;
use App\Activity\ActivityService;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\User;
@@ -46,7 +47,8 @@ final class ActivityController extends BaseApiController
private readonly ViewHandlerInterface $viewHandler,
private readonly ActivityRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityRateRepository $activityRateRepository
private readonly ActivityRateRepository $activityRateRepository,
private readonly ActivityService $activityService
) {
}
@@ -208,6 +210,25 @@ final class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing activity
*
* [DANGER] This will also delete ALL linked timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'activity')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
#[OA\Parameter(name: 'id', description: 'Activity ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_activity', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Activity $activity): Response
{
$this->activityService->deleteActivity($activity);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing activity
*/

View File

@@ -46,7 +46,8 @@ final class CustomerController extends BaseApiController
private readonly ViewHandlerInterface $viewHandler,
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly CustomerRateRepository $customerRateRepository
private readonly CustomerRateRepository $customerRateRepository,
private readonly CustomerService $customerService,
) {
}
@@ -185,6 +186,25 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing customer
*
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'customer')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
#[OA\Parameter(name: 'id', description: 'Customer ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_customer', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Customer $customer): Response
{
$this->customerService->deleteCustomer($customer);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing customer
*/

View File

@@ -240,6 +240,25 @@ final class ProjectController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing project
*
* [DANGER] This will also delete ALL linked activities and timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'project')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
#[OA\Parameter(name: 'id', description: 'Project ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_project', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Project $project): Response
{
$this->projectService->deleteProject($project);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing project
*/

View File

@@ -15,6 +15,7 @@ use App\Entity\Project;
use App\Event\ActivityCreateEvent;
use App\Event\ActivityCreatePostEvent;
use App\Event\ActivityCreatePreEvent;
use App\Event\ActivityDeleteEvent;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\ActivityUpdatePreEvent;
@@ -69,8 +70,13 @@ class ActivityService
return $activity;
}
public function deleteActivity(Activity $activity): void
{
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
$this->repository->deleteActivity($activity);
}
/**
* @param Activity $activity
* @param string[] $groups
* @throws ValidationFailedException
*/

View File

@@ -14,6 +14,7 @@ use App\Entity\Customer;
use App\Event\CustomerCreateEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerCreatePreEvent;
use App\Event\CustomerDeleteEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\CustomerUpdatePreEvent;
@@ -73,6 +74,12 @@ final class CustomerService
return $customer;
}
public function deleteCustomer(Customer $customer): void
{
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
$this->repository->deleteCustomer($customer);
}
/**
* @param string[] $groups
* @throws ValidationFailedException

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 right before a activity will be deleted.
*/
final class ActivityDeleteEvent extends AbstractActivityEvent
{
}

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 right before a customer will be deleted.
*/
final class CustomerDeleteEvent extends AbstractCustomerEvent
{
}

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 right before a project will be deleted.
*/
final class ProjectDeleteEvent extends AbstractProjectEvent
{
}

View File

@@ -15,6 +15,7 @@ use App\Entity\Project;
use App\Event\ProjectCreateEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectCreatePreEvent;
use App\Event\ProjectDeleteEvent;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\ProjectUpdatePreEvent;
@@ -77,8 +78,13 @@ final class ProjectService
return $project;
}
public function deleteProject(Project $project): void
{
$this->dispatcher->dispatch(new ProjectDeleteEvent($project));
$this->repository->deleteProject($project);
}
/**
* @param Project $project
* @param string[] $groups
* @throws ValidationFailedException
*/

View File

@@ -22,6 +22,8 @@ use App\Repository\ActivityRepository;
use App\Repository\Query\VisibilityInterface;
use App\Tests\Mocks\ActivityTestMetaFieldSubscriberMock;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
@@ -449,4 +451,62 @@ class ActivityControllerTest extends APIControllerBaseTest
$activity = $em->getRepository(Activity::class)->find(1);
$this->assertEquals('another,testing,bar', $activity->getMetaField('metatestmock')->getValue());
}
// ------------------------------- [DELETE] -------------------------------
public function testDeleteIsSecure(): void
{
$this->assertUrlIsSecured('/api/activities/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithUnknownTimesheet(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/activities/' . PHP_INT_MAX);
}
public function testDeleteEntityIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/activities/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithoutAuthorization(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$imports = $this->loadActivityTestData();
$this->request($client, '/api/activities/' . $imports[2]->getId(), Request::METHOD_DELETE);
$response = $client->getResponse();
$this->assertApiResponseAccessDenied($response);
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$imports = $this->loadActivityTestData();
$getUrl = '/api/activities/' . $imports[2]->getId();
$this->assertAccessIsGranted($client, $getUrl);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ActivityEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsNumeric($result['id']);
$id = $result['id'];
$this->request($client, '/api/activities/' . $id, Request::METHOD_DELETE);
$this->assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent());
$this->request($client, $getUrl);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
}

View File

@@ -22,6 +22,8 @@ use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
@@ -88,6 +90,45 @@ class CustomerControllerTest extends APIControllerBaseTest
return [$rate1, $rate2];
}
/**
* @return array{0: Customer, 1: Customer}
*/
private function loadCustomerData(): array
{
/** @var CustomerRateRepository $rateRepository */
$rateRepository = $this->getEntityManager()->getRepository(CustomerRate::class);
/** @var CustomerRepository $repository */
$repository = $this->getEntityManager()->getRepository(Customer::class);
$customer1 = new Customer('foooo');
$customer1->setCountry('DE');
$customer1->setTimezone('Europe/Paris');
$repository->saveCustomer($customer1);
$customer2 = new Customer('baaaaar');
$customer2->setCountry('RU');
$customer2->setTimezone('Europe/Moscow');
$repository->saveCustomer($customer2);
$rate1 = new CustomerRate();
$rate1->setCustomer($customer1);
$rate1->setRate(17.45);
$rate1->setIsFixed(false);
$rateRepository->saveRate($rate1);
$rate2 = new CustomerRate();
$rate2->setCustomer($customer1);
$rate2->setRate(99);
$rate2->setInternalRate(9);
$rate2->setIsFixed(true);
$rate2->setUser($this->getUserByName(UserFixtures::USERNAME_USER));
$rateRepository->saveRate($rate2);
return [$customer1, $customer2];
}
public function testIsSecure(): void
{
$this->assertUrlIsSecured('/api/customers');
@@ -392,4 +433,62 @@ class CustomerControllerTest extends APIControllerBaseTest
$customer = $em->getRepository(Customer::class)->find(1);
$this->assertEquals('another,testing,bar', $customer->getMetaField('metatestmock')->getValue());
}
// ------------------------------- [DELETE] -------------------------------
public function testDeleteIsSecure(): void
{
$this->assertUrlIsSecured('/api/customers/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithUnknownTimesheet(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/customers/' . PHP_INT_MAX);
}
public function testDeleteEntityIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithoutAuthorization(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$imports = $this->loadCustomerData();
$this->request($client, '/api/customers/' . $imports[1]->getId(), Request::METHOD_DELETE);
$response = $client->getResponse();
$this->assertApiResponseAccessDenied($response);
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$imports = $this->loadCustomerData();
$getUrl = '/api/customers/' . $imports[0]->getId();
$this->assertAccessIsGranted($client, $getUrl);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsNumeric($result['id']);
$id = $result['id'];
$this->request($client, '/api/customers/' . $id, Request::METHOD_DELETE);
$this->assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent());
$this->request($client, $getUrl);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
}

View File

@@ -22,7 +22,8 @@ use App\Repository\ProjectRepository;
use App\Repository\Query\VisibilityInterface;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpKernel\HttpKernelBrowser;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
@@ -110,10 +111,14 @@ class ProjectControllerTest extends APIControllerBaseTest
self::assertApiResponseTypeStructure('ProjectCollection', $result[0]);
}
protected function loadProjectTestData(HttpKernelBrowser $client)
/**
* @return array{0: Project, 1: Project, 2: Project, 3: Project, 4: Project}
*/
protected function loadProjectTestData(): array
{
$em = $this->getEntityManager();
/** @var Customer $customer */
$customer = $em->getRepository(Customer::class)->find(1);
$customer2 = new Customer('first one');
@@ -127,49 +132,49 @@ class ProjectControllerTest extends APIControllerBaseTest
$customer3->setTimezone('Europe/Vienna');
$em->persist($customer3);
$project = new Project();
$project->setName('first');
$project->setVisible(false);
$project->setCustomer($customer2);
$em->persist($project);
$project1 = new Project();
$project1->setName('first');
$project1->setVisible(false);
$project1->setCustomer($customer2);
$em->persist($project1);
$project = new Project();
$project->setName('second');
$project->setVisible(false);
$project->setCustomer($customer);
$em->persist($project);
$project2 = new Project();
$project2->setName('second');
$project2->setVisible(false);
$project2->setCustomer($customer);
$em->persist($project2);
$project = new Project();
$project->setName('third');
$project->setVisible(true);
$project->setCustomer($customer2);
$em->persist($project);
$project3 = new Project();
$project3->setName('third');
$project3->setVisible(true);
$project3->setCustomer($customer2);
$em->persist($project3);
$project = new Project();
$project->setName('fourth');
$project->setVisible(true);
$project->setCustomer($customer3);
$em->persist($project);
$project4 = new Project();
$project4->setName('fourth');
$project4->setVisible(true);
$project4->setCustomer($customer3);
$em->persist($project4);
$project = new Project();
$project->setName('fifth');
$project->setVisible(true);
$project->setCustomer($customer);
$project5 = new Project();
$project5->setName('fifth');
$project5->setVisible(true);
$project5->setCustomer($customer);
// add meta fields
$meta = new ProjectMeta();
$meta->setName('bar')->setValue('foo')->setIsVisible(false);
$project->setMetaField($meta);
$project5->setMetaField($meta);
$meta = new ProjectMeta();
$meta->setName('foo')->setValue('bar')->setIsVisible(true);
$project->setMetaField($meta);
$em->persist($project);
$project5->setMetaField($meta);
$em->persist($project5);
// and a team
$team = new Team('Testing project team');
$team->addTeamlead($this->getUserByRole(User::ROLE_USER));
$team->addCustomer($customer);
$team->addProject($project);
$team->addProject($project5);
$team->addUser($this->getUserByRole(User::ROLE_TEAMLEAD));
$em->persist($team);
@@ -178,18 +183,24 @@ class ProjectControllerTest extends APIControllerBaseTest
$em->flush();
return [$customer, $customer2, $customer3];
return [
$project1,
$project2,
$project3,
$project4,
$project5,
];
}
/**
* @dataProvider getCollectionTestData
*/
public function testGetCollectionWithParams($url, $customer, $parameters, $expected): void
public function testGetCollectionWithParams(string $url, ?int $project, array $parameters, array $expected): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$imports = $this->loadProjectTestData($client);
$imports = $this->loadProjectTestData();
$customerId = $customer !== null ? $imports[$customer]->getId() : null;
$customerId = $project !== null ? $imports[$project]->getCustomer()?->getId() : null;
if ($customerId !== null) {
if (\array_key_exists('customer', $parameters)) {
@@ -236,19 +247,19 @@ class ProjectControllerTest extends APIControllerBaseTest
{
// if you wonder why: case-sensitive ordering feels strange ... "Title" > "fifth”
yield ['/api/projects', null, [], [[true, 1], [false, 1], [false, 3]]];
yield ['/api/projects', 0, ['customer' => '1'], [[true, 1], [false, 1]]];
yield ['/api/projects', 0, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_VISIBLE], [[true, 1], [false, 1]]];
yield ['/api/projects', 0, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]];
yield ['/api/projects', 0, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_HIDDEN], [[false, 1]]];
yield ['/api/projects', 1, ['customer' => '1'], [[true, 1], [false, 1]]];
yield ['/api/projects', 1, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_VISIBLE], [[true, 1], [false, 1]]];
yield ['/api/projects', 1, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]];
yield ['/api/projects', 1, ['customer' => '1', 'visible' => VisibilityInterface::SHOW_HIDDEN], [[false, 1]]];
// customer is invisible => query only returns results for VisibilityInterface::SHOW_BOTH
yield ['/api/projects', 1, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_VISIBLE], []];
yield ['/api/projects', 1, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 1, ['customer' => '2', 'customers' => ['2'], 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 1, ['customers' => ['2', '2'], 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 1, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_HIDDEN], []];
yield ['/api/projects', 1, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
yield ['/api/projects', 1, ['customers' => ['2'], 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
yield ['/api/projects', 1, ['customers' => ['2', '2'], 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
yield ['/api/projects', 0, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_VISIBLE], []];
yield ['/api/projects', 0, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 0, ['customer' => '2', 'customers' => ['2'], 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 0, ['customers' => ['2', '2'], 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', 0, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_HIDDEN], []];
yield ['/api/projects', 0, ['customer' => '2', 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
yield ['/api/projects', 0, ['customers' => ['2'], 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
yield ['/api/projects', 0, ['customers' => ['2', '2'], 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11', 'end' => '2030-12-11'], []];
}
public function testGetEntityIsSecure(): void
@@ -599,4 +610,62 @@ class ProjectControllerTest extends APIControllerBaseTest
$project = $em->getRepository(Project::class)->find(1);
$this->assertEquals('another,testing,bar', $project->getMetaField('metatestmock')->getValue());
}
// ------------------------------- [DELETE] -------------------------------
public function testDeleteIsSecure(): void
{
$this->assertUrlIsSecured('/api/projects/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithUnknownTimesheet(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/projects/' . PHP_INT_MAX);
}
public function testDeleteEntityIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithoutAuthorization(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$imports = $this->loadProjectTestData();
$this->request($client, '/api/projects/' . $imports[2]->getId(), Request::METHOD_DELETE);
$response = $client->getResponse();
$this->assertApiResponseAccessDenied($response);
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$imports = $this->loadProjectTestData();
$getUrl = '/api/projects/' . $imports[2]->getId();
$this->assertAccessIsGranted($client, $getUrl);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ProjectEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsNumeric($result['id']);
$id = $result['id'];
$this->request($client, '/api/projects/' . $id, Request::METHOD_DELETE);
$this->assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent());
$this->request($client, $getUrl);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
}

View File

@@ -335,36 +335,6 @@ parameters:
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\ProjectControllerTest\\:\\:loadProjectTestData\\(\\) has no return type specified\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\ProjectControllerTest\\:\\:testGetCollectionWithParams\\(\\) has parameter \\$customer with no type specified\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\ProjectControllerTest\\:\\:testGetCollectionWithParams\\(\\) has parameter \\$expected with no type specified\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\ProjectControllerTest\\:\\:testGetCollectionWithParams\\(\\) has parameter \\$parameters with no type specified\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\ProjectControllerTest\\:\\:testGetCollectionWithParams\\(\\) has parameter \\$url with no type specified\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Parameter \\#1 \\$customer of method App\\\\Entity\\\\Team\\:\\:addCustomer\\(\\) expects App\\\\Entity\\\\Customer, App\\\\Entity\\\\Customer\\|null given\\.$#"
count: 1
path: API/ProjectControllerTest.php
-
message: "#^Parameter \\#5 \\$content of method App\\\\Tests\\\\API\\\\APIControllerBaseTest\\:\\:request\\(\\) expects string\\|null, string\\|false given\\.$#"
count: 15