Skip to content

Commit c062856

Browse files
committed
Subscriber filter in get subscribers endpoint
1 parent f49ce4e commit c062856

9 files changed

Lines changed: 251 additions & 28 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,11 @@ contribute and how to run the unit tests and style checks locally.
6060
This project adheres to a [Contributor Code of Conduct](CODE_OF_CONDUCT.md).
6161
By participating in this project and its community, you are expected to uphold
6262
this code.
63+
64+
65+
### Code style checks
66+
```bash
67+
vendor/bin/phpstan analyse -l 5 src/ tests/
68+
vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml
69+
vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/
70+
```

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"zircote/swagger-php": "^4.11",
5050
"ext-dom": "*",
5151
"tatevikgr/rss-feed": "dev-main as 0.1.0",
52-
"psr/simple-cache": "^3.0"
52+
"psr/simple-cache": "^3.0",
53+
"symfony/expression-language": "^6.4"
5354
},
5455
"require-dev": {
5556
"phpunit/phpunit": "^10.0",

src/Common/Validator/RequestValidator.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ public function __construct(
2323
public function validate(Request $request, string $dtoClass): RequestInterface
2424
{
2525
try {
26-
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
26+
$content = $request->getContent();
27+
$body = ($content !== '' && $content !== '0') ? json_decode($content, true, 512, JSON_THROW_ON_ERROR) : [];
2728
} catch (Throwable $e) {
2829
throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage());
2930
}
@@ -33,7 +34,7 @@ public function validate(Request $request, string $dtoClass): RequestInterface
3334
$routeParams['listId'] = (int) $routeParams['listId'];
3435
}
3536

36-
$data = array_merge($routeParams, $body ?? []);
37+
$data = array_merge($routeParams, $request->query->all(), $body ?? []);
3738

3839
try {
3940
/** @var RequestInterface $dto */
@@ -44,7 +45,9 @@ public function validate(Request $request, string $dtoClass): RequestInterface
4445
['allow_extra_attributes' => true]
4546
);
4647
} catch (Throwable $e) {
47-
throw new BadRequestHttpException('Invalid request data: ' . $e->getMessage());
48+
throw new BadRequestHttpException(
49+
'Invalid request data: ' . $e->getMessage() . ' Data: ' . json_encode($data)
50+
);
4851
}
4952

5053
return $this->validateDto($dto);

src/Subscription/Controller/SubscriberController.php

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
use Doctrine\ORM\EntityManagerInterface;
88
use OpenApi\Attributes as OA;
99
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
10-
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
1110
use PhpList\Core\Domain\Subscription\Model\Subscriber;
1211
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
1312
use PhpList\Core\Security\Authentication;
1413
use PhpList\RestBundle\Common\Controller\BaseController;
1514
use PhpList\RestBundle\Common\Validator\RequestValidator;
1615
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
1716
use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest;
17+
use PhpList\RestBundle\Subscription\Request\SubscribersFilterRequest;
1818
use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest;
1919
use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer;
2020
use PhpList\RestBundle\Subscription\Service\SubscriberHistoryService;
@@ -75,7 +75,61 @@ public function __construct(
7575
in: 'query',
7676
required: false,
7777
schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1)
78-
)
78+
),
79+
new OA\Parameter(
80+
name: 'is_confirmed',
81+
description: 'Filter by confirmed status',
82+
in: 'query',
83+
required: false,
84+
schema: new OA\Schema(
85+
type: 'string',
86+
enum: ['true', 'false', '0', '1'],
87+
example: '1'
88+
)
89+
),
90+
new OA\Parameter(
91+
name: 'is_blacklisted',
92+
description: 'Filter by blacklisted status',
93+
in: 'query',
94+
required: false,
95+
schema: new OA\Schema(
96+
type: 'string',
97+
enum: ['true', 'false', '0', '1'],
98+
example: '1'
99+
)
100+
),
101+
new OA\Parameter(
102+
name: 'sort_by',
103+
description: 'Column to sort by',
104+
in: 'query',
105+
required: false,
106+
schema: new OA\Schema(type: 'string', example: 'id')
107+
),
108+
new OA\Parameter(
109+
name: 'sort_direction',
110+
description: 'Sort direction',
111+
in: 'query',
112+
required: false,
113+
schema: new OA\Schema(
114+
type: 'string',
115+
enum: ['asc', 'desc'],
116+
example: 'desc'
117+
)
118+
),
119+
new OA\Parameter(
120+
name: 'find_column',
121+
description: 'Column to search in (requires find_value)',
122+
in: 'query',
123+
required: false,
124+
schema: new OA\Schema(type: 'string', example: 'email')
125+
),
126+
new OA\Parameter(
127+
name: 'find_value',
128+
description: 'Value to search for (requires find_column)',
129+
in: 'query',
130+
required: false,
131+
schema: new OA\Schema(type: 'string', example: 'email@example.com')
132+
),
79133
],
80134
responses: [
81135
new OA\Response(
@@ -104,14 +158,17 @@ public function getSubscribers(Request $request): JsonResponse
104158
{
105159
$this->requireAuthentication($request);
106160

161+
/** @var SubscribersFilterRequest $subscriberRequest */
162+
$subscriberRequest = $this->validator->validate($request, SubscribersFilterRequest::class);
163+
107164
return $this->json(
108-
$this->paginatedDataProvider->getPaginatedList(
109-
$request,
110-
$this->subscriberNormalizer,
111-
Subscriber::class,
112-
new SubscriberFilter(),
165+
data: $this->paginatedDataProvider->getPaginatedList(
166+
request: $request,
167+
normalizer: $this->subscriberNormalizer,
168+
className: Subscriber::class,
169+
filter: $subscriberRequest->getDto(),
113170
),
114-
Response::HTTP_OK
171+
status: Response::HTTP_OK
115172
);
116173
}
117174

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Subscription\Request;
6+
7+
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
8+
use PhpList\RestBundle\Common\Request\RequestInterface;
9+
use Symfony\Component\Validator\Constraints as Assert;
10+
11+
class SubscribersFilterRequest implements RequestInterface
12+
{
13+
#[Assert\Choice(
14+
choices: ['true', 'false', '1', '0', true, false, 1, 0],
15+
message: 'isConfirmed must be one of: true, false, 1, 0'
16+
)]
17+
public mixed $isConfirmed = null;
18+
19+
#[Assert\Choice(
20+
choices: ['true', 'false', '1', '0', true, false, 1, 0],
21+
message: 'isBlacklisted must be one of: true, false, 1, 0'
22+
)]
23+
public mixed $isBlacklisted = null;
24+
25+
#[Assert\Choice(
26+
choices: ['email', 'confirmedAt', 'createdAt'],
27+
message: 'Invalid sortBy value'
28+
)]
29+
public ?string $sortBy = null;
30+
31+
#[Assert\Choice(
32+
choices: ['asc', 'desc'],
33+
message: 'sortDirection must be asc or desc'
34+
)]
35+
public ?string $sortDirection = null;
36+
37+
#[Assert\Choice(
38+
choices: ['email', 'name'],
39+
message: 'Invalid findColumn value'
40+
)]
41+
public ?string $findColumn = null;
42+
43+
#[Assert\Type(type: 'string')]
44+
public ?string $findValue = null;
45+
46+
#[Assert\Expression(
47+
expression: '(this.findColumn === null and this.findValue === null) or (this.findColumn !== null and this.findValue !== null)',
48+
message: 'findColumn and findValue must be provided together'
49+
)]
50+
public mixed $findPairValidation = null;
51+
52+
public function getDto(): SubscriberFilter
53+
{
54+
return new SubscriberFilter(
55+
isConfirmed: $this->normalizeBoolean($this->isConfirmed),
56+
isBlacklisted: $this->normalizeBoolean($this->isBlacklisted),
57+
sortBy: $this->sortBy,
58+
sortDirection: $this->sortDirection,
59+
findColumn: $this->findColumn,
60+
findValue: $this->findColumn ? $this->findValue : null,
61+
);
62+
}
63+
64+
private function normalizeBoolean(mixed $value): ?bool
65+
{
66+
if ($value === null) {
67+
return null;
68+
}
69+
70+
if (is_bool($value)) {
71+
return $value;
72+
}
73+
74+
return (bool) filter_var($value, FILTER_VALIDATE_BOOLEAN);
75+
}
76+
}

tests/Integration/Identity/Controller/PasswordResetControllerTest.php

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ public function testControllerIsAvailableViaContainer(): void
1818
);
1919
}
2020

21-
public function testRequestPasswordResetWithNoJsonReturnsError400(): void
21+
public function testRequestPasswordResetWithNoJsonReturnsError422(): void
2222
{
2323
$this->jsonRequest('post', '/api/v2/password-reset/request');
2424

25-
$this->assertHttpBadRequest();
26-
$data = $this->getDecodedJsonResponseContent();
27-
$this->assertStringContainsString('Invalid JSON:', $data['message']);
25+
$this->assertHttpUnprocessableEntity();
2826
}
2927

3028
public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void
@@ -55,13 +53,11 @@ public function testRequestPasswordResetWithValidEmailReturnsSuccess(): void
5553
$this->assertHttpNoContent();
5654
}
5755

58-
public function testValidateTokenWithNoJsonReturnsError400(): void
56+
public function testValidateTokenWithNoJsonReturnsError422(): void
5957
{
6058
$this->jsonRequest('post', '/api/v2/password-reset/validate');
6159

62-
$this->assertHttpBadRequest();
63-
$data = $this->getDecodedJsonResponseContent();
64-
$this->assertStringContainsString('Invalid JSON:', $data['message']);
60+
$this->assertHttpUnprocessableEntity();
6561
}
6662

6763
public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void
@@ -75,13 +71,11 @@ public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void
7571
$this->assertFalse($data['valid']);
7672
}
7773

78-
public function testResetPasswordWithNoJsonReturnsError400(): void
74+
public function testResetPasswordWithNoJsonReturnsError422(): void
7975
{
8076
$this->jsonRequest('post', '/api/v2/password-reset/reset');
8177

82-
$this->assertHttpBadRequest();
83-
$data = $this->getDecodedJsonResponseContent();
84-
$this->assertStringContainsString('Invalid JSON:', $data['message']);
78+
$this->assertHttpUnprocessableEntity();
8579
}
8680

8781
public function testResetPasswordWithInvalidTokenReturnsBadRequest(): void

tests/Integration/Identity/Controller/SessionControllerTest.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,11 @@ public function testGetSessionsIsNotAllowed()
4242
$this->assertHttpMethodNotAllowed();
4343
}
4444

45-
public function testPostSessionsWithNoJsonReturnsError400()
45+
public function testPostSessionsWithNoJsonReturnsError422()
4646
{
4747
$this->jsonRequest('post', '/api/v2/sessions');
4848

49-
$this->assertHttpBadRequest();
50-
$data = $this->getDecodedJsonResponseContent();
51-
$this->assertStringContainsString('Invalid JSON:', $data['message']);
49+
$this->assertHttpUnprocessableEntity();
5250
}
5351

5452
public function testPostSessionsWithInvalidJsonWithJsonContentTypeReturnsError400()

tests/Integration/Subscription/Controller/SubscriberControllerTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* Testcase.
1616
*
1717
* @author Oliver Klee <oliver@phplist.com>
18+
* @author Tatevik Grigoryan <tatevik@phplist.com>
1819
*/
1920
class SubscriberControllerTest extends AbstractTestController
2021
{
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Unit\Subscription\Request;
6+
7+
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
8+
use PhpList\RestBundle\Subscription\Request\SubscribersFilterRequest;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class SubscribersFilterRequestTest extends TestCase
12+
{
13+
public function testGetDtoReturnsCorrectDtoWithAllValues(): void
14+
{
15+
$request = new SubscribersFilterRequest();
16+
$request->isConfirmed = 'true';
17+
$request->isBlacklisted = 'false';
18+
$request->sortBy = 'email';
19+
$request->sortDirection = 'desc';
20+
$request->findColumn = 'email';
21+
$request->findValue = 'test@example.com';
22+
23+
$dto = $request->getDto();
24+
25+
$this->assertInstanceOf(SubscriberFilter::class, $dto);
26+
$this->assertTrue($dto->getIsConfirmed());
27+
$this->assertFalse($dto->getIsBlacklisted());
28+
$this->assertEquals('email', $dto->getSortBy());
29+
$this->assertEquals('desc', $dto->getSortDirection());
30+
$this->assertEquals('email', $dto->getFindColumn());
31+
$this->assertEquals('test@example.com', $dto->getFindValue());
32+
}
33+
34+
public function testGetDtoWithBooleanValues(): void
35+
{
36+
$request = new SubscribersFilterRequest();
37+
$request->isConfirmed = true;
38+
$request->isBlacklisted = false;
39+
40+
$dto = $request->getDto();
41+
42+
$this->assertTrue($dto->getIsConfirmed());
43+
$this->assertFalse($dto->getIsBlacklisted());
44+
}
45+
46+
public function testGetDtoWithNumericStringValues(): void
47+
{
48+
$request = new SubscribersFilterRequest();
49+
$request->isConfirmed = '1';
50+
$request->isBlacklisted = '0';
51+
52+
$dto = $request->getDto();
53+
54+
$this->assertTrue($dto->getIsConfirmed());
55+
$this->assertFalse($dto->getIsBlacklisted());
56+
}
57+
58+
public function testGetDtoReturnsCorrectDtoWithNullValues(): void
59+
{
60+
$request = new SubscribersFilterRequest();
61+
62+
$dto = $request->getDto();
63+
64+
$this->assertInstanceOf(SubscriberFilter::class, $dto);
65+
$this->assertNull($dto->getIsConfirmed());
66+
$this->assertNull($dto->getIsBlacklisted());
67+
$this->assertNull($dto->getSortBy());
68+
$this->assertNull($dto->getSortDirection());
69+
$this->assertNull($dto->getFindColumn());
70+
$this->assertNull($dto->getFindValue());
71+
}
72+
73+
public function testGetDtoNullsFindColumnAndValueWhenOnlyValueProvided(): void
74+
{
75+
$request = new SubscribersFilterRequest();
76+
$request->findColumn = null;
77+
$request->findValue = 'test@example.com';
78+
79+
$dto = $request->getDto();
80+
81+
$this->assertInstanceOf(SubscriberFilter::class, $dto);
82+
$this->assertNull($dto->getFindColumn());
83+
$this->assertNull($dto->getFindValue());
84+
}
85+
}

0 commit comments

Comments
 (0)