Skip to content

Commit e83e2a5

Browse files
committed
ISSUE-345: subscriber export/import
1 parent 64d2b6f commit e83e2a5

File tree

4 files changed

+368
-0
lines changed

4 files changed

+368
-0
lines changed

config/services/managers.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ services:
3535
PhpList\Core\Domain\Udentity\Service\AdministratorManager:
3636
autowire: true
3737
autoconfigure: true
38+
39+
PhpList\Core\Domain\Subscription\Service\SubscriberCsvExportManager:
40+
autowire: true
41+
autoconfigure: true
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Subscription\Controller;
6+
7+
use OpenApi\Attributes as OA;
8+
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
9+
use PhpList\Core\Domain\Subscription\Service\SubscriberCsvExportManager;
10+
use PhpList\Core\Security\Authentication;
11+
use PhpList\RestBundle\Common\Controller\BaseController;
12+
use PhpList\RestBundle\Common\Validator\RequestValidator;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Response;
15+
use Symfony\Component\Routing\Attribute\Route;
16+
17+
#[Route('/subscribers', name: 'subscriber_export_')]
18+
class SubscriberExportController extends BaseController
19+
{
20+
private SubscriberCsvExportManager $exportManager;
21+
22+
public function __construct(
23+
Authentication $authentication,
24+
RequestValidator $validator,
25+
SubscriberCsvExportManager $exportManager
26+
) {
27+
parent::__construct($authentication, $validator);
28+
$this->exportManager = $exportManager;
29+
}
30+
31+
#[Route('/export', name: 'csv', methods: ['GET'])]
32+
#[OA\Get(
33+
path: '/subscribers/export',
34+
description: 'Export subscribers to CSV file.',
35+
summary: 'Export subscribers',
36+
tags: ['subscribers'],
37+
parameters: [
38+
new OA\Parameter(
39+
name: 'session',
40+
description: 'Session ID obtained from authentication',
41+
in: 'header',
42+
required: true,
43+
schema: new OA\Schema(type: 'string')
44+
),
45+
new OA\Parameter(
46+
name: 'batch_size',
47+
description: 'Number of subscribers to process in each batch (default: 1000)',
48+
in: 'query',
49+
required: false,
50+
schema: new OA\Schema(type: 'integer', default: 1000)
51+
)
52+
],
53+
responses: [
54+
new OA\Response(
55+
response: 200,
56+
description: 'Success',
57+
content: new OA\MediaType(
58+
mediaType: 'text/csv',
59+
schema: new OA\Schema(type: 'string', format: 'binary')
60+
)
61+
),
62+
new OA\Response(
63+
response: 403,
64+
description: 'Failure',
65+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
66+
)
67+
]
68+
)]
69+
public function exportSubscribers(Request $request): Response
70+
{
71+
$this->requireAuthentication($request);
72+
73+
$batchSize = (int)$request->query->get('batch_size', 1000);
74+
75+
$filter = new SubscriberFilter();
76+
77+
return $this->exportManager->exportToCsv($filter, $batchSize);
78+
}
79+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Subscription\Controller;
6+
7+
use Exception;
8+
use OpenApi\Attributes as OA;
9+
use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
10+
use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImportManager;
11+
use PhpList\Core\Security\Authentication;
12+
use PhpList\RestBundle\Common\Controller\BaseController;
13+
use PhpList\RestBundle\Common\Validator\RequestValidator;
14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
15+
use Symfony\Component\HttpFoundation\JsonResponse;
16+
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\Routing\Attribute\Route;
19+
20+
#[Route('/subscribers', name: 'subscriber_import_')]
21+
class SubscriberImportController extends BaseController
22+
{
23+
private SubscriberCsvImportManager $importManager;
24+
25+
public function __construct(
26+
Authentication $authentication,
27+
RequestValidator $validator,
28+
SubscriberCsvImportManager $importManager
29+
) {
30+
parent::__construct($authentication, $validator);
31+
$this->importManager = $importManager;
32+
}
33+
34+
#[Route('/import', name: 'csv', methods: ['POST'])]
35+
#[OA\Post(
36+
path: '/subscribers/import',
37+
description: 'Import subscribers from CSV file.',
38+
summary: 'Import subscribers',
39+
requestBody: new OA\RequestBody(
40+
required: true,
41+
content: new OA\MediaType(
42+
mediaType: 'multipart/form-data',
43+
schema: new OA\Schema(
44+
properties: [
45+
new OA\Property(
46+
property: 'file',
47+
description: 'CSV file with subscribers data',
48+
type: 'string',
49+
format: 'binary'
50+
),
51+
new OA\Property(
52+
property: 'request_confirmation',
53+
description: 'Whether to request confirmation from imported subscribers',
54+
type: 'boolean',
55+
default: false
56+
),
57+
new OA\Property(
58+
property: 'html_email',
59+
description: 'Whether imported subscribers prefer HTML emails',
60+
type: 'boolean',
61+
default: true
62+
)
63+
],
64+
type: 'object'
65+
)
66+
)
67+
),
68+
tags: ['subscribers'],
69+
parameters: [
70+
new OA\Parameter(
71+
name: 'session',
72+
description: 'Session ID obtained from authentication',
73+
in: 'header',
74+
required: true,
75+
schema: new OA\Schema(type: 'string')
76+
)
77+
],
78+
responses: [
79+
new OA\Response(
80+
response: 200,
81+
description: 'Success',
82+
content: new OA\JsonContent(
83+
properties: [
84+
new OA\Property(property: 'success', type: 'boolean'),
85+
new OA\Property(property: 'imported', type: 'integer'),
86+
new OA\Property(property: 'skipped', type: 'integer'),
87+
new OA\Property(
88+
property: 'errors',
89+
type: 'array',
90+
items: new OA\Items(type: 'string')
91+
)
92+
]
93+
)
94+
),
95+
new OA\Response(
96+
response: 400,
97+
description: 'Bad Request',
98+
content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse')
99+
),
100+
new OA\Response(
101+
response: 403,
102+
description: 'Unauthorized',
103+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
104+
)
105+
]
106+
)]
107+
public function importSubscribers(Request $request): JsonResponse
108+
{
109+
$this->requireAuthentication($request);
110+
111+
/** @var UploadedFile|null $file */
112+
$file = $request->files->get('file');
113+
114+
if (!$file) {
115+
return $this->json(['success' => false, 'message' => 'No file uploaded'], Response::HTTP_BAD_REQUEST);
116+
}
117+
118+
if ($file->getClientMimeType() !== 'text/csv' && $file->getClientOriginalExtension() !== 'csv') {
119+
return $this->json(['success' => false, 'message' => 'File must be a CSV'], Response::HTTP_BAD_REQUEST);
120+
}
121+
122+
try {
123+
$options = new SubscriberImportOptions();
124+
125+
$stats = $this->importManager->importFromCsv($file, $options);
126+
127+
return $this->json([
128+
'success' => true,
129+
'imported' => $stats['created'],
130+
'skipped' => $stats['skipped'],
131+
'errors' => $stats['errors']
132+
]);
133+
} catch (Exception $e) {
134+
return $this->json([
135+
'success' => false,
136+
'message' => $e->getMessage()
137+
], Response::HTTP_BAD_REQUEST);
138+
}
139+
}
140+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Subscription\Controller;
6+
7+
use PhpList\RestBundle\Subscription\Controller\SubscriberImportController;
8+
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
9+
use Symfony\Component\HttpFoundation\File\UploadedFile;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
/**
13+
* Integration tests for the SubscriberImportController.
14+
*/
15+
class SubscriberImportControllerTest extends AbstractTestController
16+
{
17+
private string $tempDir;
18+
19+
protected function setUp(): void
20+
{
21+
parent::setUp();
22+
23+
$this->tempDir = sys_get_temp_dir();
24+
}
25+
26+
public function testControllerIsAvailableViaContainer(): void
27+
{
28+
self::assertInstanceOf(
29+
SubscriberImportController::class,
30+
self::getContainer()->get(SubscriberImportController::class)
31+
);
32+
}
33+
34+
public function testImportSubscribersWithoutSessionKeyReturnsForbiddenStatus(): void
35+
{
36+
self::getClient()->request('POST', '/api/v2/subscribers/import');
37+
38+
$this->assertHttpForbidden();
39+
}
40+
41+
public function testImportSubscribersWithoutFileReturnsBadRequestStatus(): void
42+
{
43+
$this->authenticatedJsonRequest('POST', '/api/v2/subscribers/import');
44+
45+
$this->assertHttpBadRequest();
46+
$responseContent = $this->getDecodedJsonResponseContent();
47+
self::assertSame(false, $responseContent['success']);
48+
self::assertStringContainsString('No file uploaded', $responseContent['message']);
49+
}
50+
51+
public function testImportSubscribersWithNonCsvFileReturnsBadRequestStatus(): void
52+
{
53+
$filePath = $this->tempDir . '/test.txt';
54+
file_put_contents($filePath, 'This is not a CSV file');
55+
56+
$file = new UploadedFile(
57+
$filePath,
58+
'test.txt',
59+
'text/plain',
60+
null,
61+
true
62+
);
63+
64+
$this->authenticatedJsonRequest(
65+
'POST',
66+
'/api/v2/subscribers/import',
67+
[],
68+
['file' => $file]
69+
);
70+
71+
$this->assertHttpBadRequest();
72+
$responseContent = $this->getDecodedJsonResponseContent();
73+
self::assertSame(false, $responseContent['success']);
74+
self::assertStringContainsString('File must be a CSV', $responseContent['message']);
75+
}
76+
77+
public function testImportSubscribersWithValidCsvFile(): void
78+
{
79+
$filePath = $this->tempDir . '/subscribers.csv';
80+
$csvContent = "email,name\n[email protected],Test User\n[email protected],Test User 2";
81+
file_put_contents($filePath, $csvContent);
82+
83+
$file = new UploadedFile(
84+
$filePath,
85+
'subscribers.csv',
86+
'text/csv',
87+
null,
88+
true
89+
);
90+
91+
$this->authenticatedJsonRequest(
92+
'POST',
93+
'/api/v2/subscribers/import',
94+
[],
95+
['file' => $file]
96+
);
97+
98+
$response = self::getClient()->getResponse();
99+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
100+
101+
$responseContent = $this->getDecodedJsonResponseContent();
102+
self::assertSame(true, $responseContent['success']);
103+
self::assertArrayHasKey('imported', $responseContent);
104+
self::assertArrayHasKey('skipped', $responseContent);
105+
self::assertArrayHasKey('errors', $responseContent);
106+
}
107+
108+
public function testImportSubscribersWithOptions(): void
109+
{
110+
$filePath = $this->tempDir . '/subscribers.csv';
111+
$csvContent = "email,name\n[email protected],Test User";
112+
file_put_contents($filePath, $csvContent);
113+
114+
$file = new UploadedFile(
115+
$filePath,
116+
'subscribers.csv',
117+
'text/csv',
118+
null,
119+
true
120+
);
121+
122+
$this->authenticatedJsonRequest(
123+
'POST',
124+
'/api/v2/subscribers/import',
125+
[
126+
'request_confirmation' => 'true',
127+
'html_email' => 'false'
128+
],
129+
['file' => $file]
130+
);
131+
132+
$response = self::getClient()->getResponse();
133+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
134+
135+
$responseContent = $this->getDecodedJsonResponseContent();
136+
self::assertSame(true, $responseContent['success']);
137+
}
138+
139+
public function testGetMethodIsNotAllowed(): void
140+
{
141+
$this->authenticatedJsonRequest('GET', '/api/v2/subscribers/import');
142+
143+
$this->assertHttpMethodNotAllowed();
144+
}
145+
}

0 commit comments

Comments
 (0)