diff --git a/CHANGELOG.md b/CHANGELOG.md index f866108..f98c479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - Run the system test on Travis (#113) - Add security headers to the default response (#110) - Whitelist BadRequestHttpException so that messages are not sanitized (#108) +- REST API endpoint for adding a subscriber to a list ### Changed diff --git a/docs/Api/RestApi.apib b/docs/Api/RestApi.apib index 026075a..f88b1b4 100644 --- a/docs/Api/RestApi.apib +++ b/docs/Api/RestApi.apib @@ -392,3 +392,73 @@ object containing the following key-value pairs: "code": 422, "message": "Some fields invalid: email, confirmed, html_email" } + +# Subscriptions + +Resources related to subscriptions. + +All requests in this group need to be authenticated with a valid session key +provided as basic auth password. (The basic auth user name can be any string.) + +## Subscriptions [/subscriptions] + +### Create a new subscription [POST] + +Given a valid authentication, this will generate a subscription, which means add a member to a list. +It takes a JSON object containing the following key-value pairs: + ++ `subscriber_id` (integer): ID of the subscriber (required) ++ `subscriber_list_id` (integer): ID of the list (required) + ++ Response 201 (application/json) + + + Body + + { + "creation_date": "2020-01-09T18:44:27+00:00", + } + ++ Response 403 (application/json) + + + Body + + { + "code": 403, + "message": "No valid session key was provided as basic auth password." + } + ++ Response 409 (application/json) + + + Body + + { + "code": 409, + "message": "This resource already exists." + } + ++ Response 422 (application/json) + + + Body + + { + "code": 422, + "message": "Some fields invalid: subscriber_id, subscriber_list_id" + } + ++ Response 422 (application/json) + + + Body + + { + "code": 422, + "message": "subscriber_id not found: 42" + } + ++ Response 422 (application/json) + + + Body + + { + "code": 422, + "message": "subscriber_list_id not found: 42" + } diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php new file mode 100644 index 0000000..49dda0f --- /dev/null +++ b/src/Controller/SubscriptionController.php @@ -0,0 +1,130 @@ + + */ +class SubscriptionController extends FOSRestController implements ClassResourceInterface +{ + use AuthenticationTrait; + + /** + * @var SubscriberRepository + */ + private $subscriberRepository = null; + + /** + * @var SubscriberListRepository + */ + private $subscriberListRepository; + + /** + * @var SubscriptionRepository + */ + private $subscriptionRepository; + + /** + * @param Authentication $authentication + * @param SubscriberRepository|null $subscriberRepository + * @param SubscriberListRepository $subscriberListRepository + * @param SubscriptionRepository $subscriptionRepository + */ + public function __construct( + Authentication $authentication, + SubscriberRepository $subscriberRepository, + SubscriberListRepository $subscriberListRepository, + SubscriptionRepository $subscriptionRepository + ) { + $this->authentication = $authentication; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberListRepository = $subscriberListRepository; + $this->subscriptionRepository = $subscriptionRepository; + } + + /** + * Creates a new subscription. + * + * @param Request $request + * + * @return View + * + * @throws UnprocessableEntityHttpException + * @throws ConflictHttpException + */ + public function postAction(Request $request): View + { + $this->requireAuthentication($request); + + $this->validateSubscription($request); + + $subscriber = $this->subscriberRepository->findOneById($request->get('subscriber_id')); + if ($subscriber === null) { + throw new UnprocessableEntityHttpException( + 'subscriber_id not found: '.$request->get('subscriber_id'), + null, + 1598917596 + ); + } + + $subscriberList = $this->subscriberListRepository->findOneById($request->get('subscriber_list_id')); + if ($subscriberList === null) { + throw new UnprocessableEntityHttpException( + 'subscriber_list_id not found: '.$request->get('subscriber_list_id'), + null, + 1598917574 + ); + } + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + + try { + $this->subscriptionRepository->save($subscription); + } catch (UniqueConstraintViolationException $e) { + throw new ConflictHttpException('This resource already exists.', null, 1598918448); + } + + return View::create()->setStatusCode(Response::HTTP_CREATED)->setData($subscription); + } + + private function validateSubscription(Request $request) + { + /** @var string[] $invalidFields */ + $invalidFields = []; + if (filter_var($request->get('subscriber_id'), FILTER_VALIDATE_INT) === false) { + $invalidFields[] = 'subscriber_id'; + } + + if (filter_var($request->get('subscriber_list_id'), FILTER_VALIDATE_INT) === false) { + $invalidFields[] = 'subscriber_list_id'; + } + + if (!empty($invalidFields)) { + throw new UnprocessableEntityHttpException( + 'Some fields invalid:' . implode(', ', $invalidFields), + null, + 1598914359 + ); + } + } +} diff --git a/tests/Integration/Controller/SubscriptionControllerTest.php b/tests/Integration/Controller/SubscriptionControllerTest.php new file mode 100644 index 0000000..263401c --- /dev/null +++ b/tests/Integration/Controller/SubscriptionControllerTest.php @@ -0,0 +1,223 @@ + + */ +class SubscriptionControllerTest extends AbstractControllerTest +{ + /** + * @var string + */ + const SUBSCRIBER_TABLE_NAME = 'phplist_user_user'; + + /** + * @var string + */ + const SUBSCRIBER_LIST_TABLE_NAME = 'phplist_list'; + + /** + * @var string + */ + const SUBSCRIPTION_TABLE_NAME = 'phplist_listuser'; + + /** + * @var SubscriptionRepository + */ + private $subscriptionRepository = null; + + protected function setUp() + { + $this->setUpDatabaseTest(); + $this->setUpWebTest(); + + $this->subscriptionRepository = $this->bootstrap->getContainer() + ->get(SubscriptionRepository::class); + } + + /** + * @test + */ + public function controllerIsAvailableViaContainer() + { + static::assertInstanceOf( + SubscriptionController::class, + $this->client->getContainer()->get(SubscriptionController::class) + ); + } + + /** + * @test + */ + public function getSubscriptionsIsNotAllowed() + { + $this->client->request('get', '/api/v2/subscriptions'); + + $this->assertHttpMethodNotAllowed(); + } + + /** + * @test + */ + public function postSubscriptionsWithoutSessionKeyReturnsForbiddenStatus() + { + $this->jsonRequest('post', '/api/v2/subscriptions'); + + $this->assertHttpForbidden(); + } + + /** + * @test + */ + public function postSubscriptionsWithValidSessionKeyAndMinimalValidSubscriberDataCreatesResource() + { + $this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv'); + $this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv'); + $this->applyDatabaseChanges(); + $this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME); + + $jsonData = [ + 'subscriber_id' => 1, + 'subscriber_list_id' => 1 + ]; + + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + $this->assertHttpCreated(); + } + + /** + * @test + */ + public function postSubscriptionsWithValidSessionKeyAndMinimalValidDataReturnsCreationDate() + { + $this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv'); + $this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv'); + $this->applyDatabaseChanges(); + $this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME); + + $jsonData = [ + 'subscriber_id' => 1, + 'subscriber_list_id' => 1 + ]; + + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + $responseContent = $this->getDecodedJsonResponseContent(); + + static::assertGreaterThan(0, $responseContent['creation_date']); + } + + /** + * @test + */ + public function postSubscriptionsWithValidSessionKeyAndValidDataCreatesSubscription() + { + $this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv'); + $this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv'); + $this->applyDatabaseChanges(); + $this->touchDatabaseTable(static::SUBSCRIPTION_TABLE_NAME); + + + $jsonData = [ + 'subscriber_id' => 1, + 'subscriber_list_id' => 1 + ]; + + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + static::assertInstanceOf(Subscription::class, $this->subscriptionRepository->findOneBy([])); + } + + /** + * @test + */ + public function postSubscriptionsWithValidSessionKeyAndExistingSubscriberAndSubscriberListCreatesConflictStatus() + { + $this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv'); + $this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv'); + $this->getDataSet()->addTable(static::SUBSCRIPTION_TABLE_NAME, __DIR__ . '/Fixtures/Subscription.csv'); + $this->applyDatabaseChanges(); + + $jsonData = [ + 'subscriber_id' => 1, + 'subscriber_list_id' => 2 + ]; + + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + $this->assertHttpConflict(); + } + + /** + * @return array[] + */ + public function nonexistentSubscriberOrSubscriberListDataProvider() + { + return [ + [2, 3], // nonexistent subscriber + [1, 4] // nonexistent subscriberList + ]; + } + + /** + * @test + * @dataProvider nonexistentSubscriberOrSubscriberListDataProvider + * @param int $subscriberId + * @param int $subscriberListId + */ + public function postSubscriptionsWithValidSessionKeyAndNonexistentSubscriberOrListCreatesUnprocessableEntityStatus( + $subscriberId, + $subscriberListId + ) { + $this->getDataSet()->addTable(static::SUBSCRIBER_TABLE_NAME, __DIR__ . '/Fixtures/Subscriber.csv'); + $this->getDataSet()->addTable(static::SUBSCRIBER_LIST_TABLE_NAME, __DIR__ . '/Fixtures/SubscriberList.csv'); + $this->getDataSet()->addTable(static::SUBSCRIPTION_TABLE_NAME, __DIR__ . '/Fixtures/Subscription.csv'); + $this->applyDatabaseChanges(); + + $jsonData = [ + 'subscriber_id' => $subscriberId, + 'subscriber_list_id' => $subscriberListId + ]; + + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + $this->assertHttpUnprocessableEntity(); + } + + /** + * @return array[][] + */ + public function invalidSubscriptionDataProvider(): array + { + return [ + 'no data' => [[]], + 'subscriber_id is null' => [['subscriber_id' => null, 'subscriber_list_id' => 1]], + 'subscriber_id is a string' => [['subscriber_id' => 'foo', 'subscriber_list_id' => 1]], + 'subscriber_id as boolean' => [['subscriber_id' => true, 'subscriber_list_id' => 1]], + 'subscriber_list_id is null' => [['subscriber_id' => 1, 'subscriber_list_id' => null]], + 'subscriber_list_id is a string' => [['subscriber_id' => 1, 'subscriber_list_id' => 'foo']], + 'subscriber_list_id as boolean' => [['subscriber_id' => 1, 'subscriber_list_id' => true]], + ]; + } + + /** + * @test + * @dataProvider invalidSubscriptionDataProvider + * @param array[] $jsonData + */ + public function postSubscribersWithInvalidDataCreatesUnprocessableEntityStatus(array $jsonData) + { + $this->authenticatedJsonRequest('post', '/api/v2/subscriptions', [], [], [], json_encode($jsonData)); + + $this->assertHttpUnprocessableEntity(); + } +}