diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..cd8eb86e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..4657b11e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: PHPUnit tests + +on: + - push + - pull_request + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3] + + name: Tests on PHP ${{ matrix.php }} - ${{ matrix.stability }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: pcov + + - name: Install dependencies + run: composer update --prefer-source --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..76f1daaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/temp +/vendor +composer.lock +phpunit.xml +.env +.idea/ +.phpunit.result.cache diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..0285f179 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1 @@ +preset: laravel diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 00000000..246ecabe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +[//]: # (https://keepachangelog.com/ru/0.3.0/) + + +## 1.0.1 - 2025-05-07 + +### Added +- Added report on sending notifications RuStoreReport in fired events + - NotificationSent (```$report = $event->response;```) + - NotificationFailed (```$report = Arr::get($event->data, 'report');```) + +### Changed +- Changed handling of server responses: all unsuccessful responses (not 2**) are interpreted as a sending error (including 1** and 3**) +- The package description has been supplemented [Readme](README.md) + +### Fixed +- Fixed firing of NotificationSent event when there were no successfully sent messages + +[//]: # (### Deleted) + + + +## 1.0.0 - 2025-05-06 + +- initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100755 index 00000000..4da74e3f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c405cbe1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) yakOffKa + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/README.md b/README.md index fa8362b7..0a554de2 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,247 @@ -# New Notification Channels +# RuStore push notification channel for Laravel -### Suggesting a new channel -Have a suggestion or working on a new channel? Please create a new issue for that service. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/laravel-notification-channels/ru-store.svg?style=flat-square)](https://packagist.org/packages/laravel-notification-channels/ru-store) -### I'm working on a new channel -Please create an issue for it if it does not already exist, then PR you code for review. +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -## Workflow for new channels +[![Build Status](https://img.shields.io/travis/laravel-notification-channels/ru-store/master.svg?style=flat-square)](https://travis-ci.org/laravel-notification-channels/ru-store) -1) Head over to the [skeleton repo](https://github.com/laravel-notification-channels/skeleton) download a ZIP copy. This is important, to ensure you start from a fresh commit history. -2) Use find/replace to replace all of the placeholders with the correct values (package name, author name, email, etc). -3) Implement to logic for the channel & add tests. -4) Fork this repo, add it as a remote and push your new channel to a branch. -5) Submit a new PR against this repo for review. +[![StyleCI](https://styleci.io/repos/:style_ci_id/shield)](https://styleci.io/repos/:style_ci_id) -Take a look at our [FAQ](http://laravel-notification-channels.com/) to see our small list of rules, to provide top-notch notification channels. +[![Quality Score](https://img.shields.io/scrutinizer/g/laravel-notification-channels/ru-store.svg?style=flat-square)](https://scrutinizer-ci.com/g/laravel-notification-channels/ru-store) + +[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/laravel-notification-channels/ru-store/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/laravel-notification-channels/ru-store/?branch=master) + +[![Total Downloads](https://img.shields.io/packagist/dt/laravel-notification-channels/ru-store.svg?style=flat-square)](https://packagist.org/packages/laravel-notification-channels/ru-store) + +This package makes it easy to send notifications using [RuStore](link to service) with Laravel 10.x. + + +## Contents + +- [Installation](#installation) +- [Setting up the RuStore service](#setting-up-the-RuStore-service) +- [Usage](#usage) +- [Available Message methods](#available-message-methods) +- [Changelog](#changelog) +- [Testing](#testing) +- [Security](#security) +- [Contributing](#contributing) +- [Credits](#credits) +- [License](#license) + + +## Installation +You can install the package via composer: +```bash + composer require yakoffka/laravel-notification-channels-ru-store +``` + +Publish the configuration file: +```bash + php artisan vendor:publish --provider="NotificationChannels\RuStore\RuStoreServiceProvider" +``` +Update your .env file with the values obtained from the [RuStore console](https://console.rustore.ru/waiting) + + +## Usage +In a class using the Notifiable trait (e.g., the User model), implement a method that returns an array of the notifiable user’s push tokens: +```php + /** + * Getting an array of ru-store push tokens of user devices + * + * @return array + */ + public function routeNotificationForRuStore(): array + { + return $this->ru_store_tokens; + } +``` + +Create a notification class, in the via() method of which specify the RuStoreChannel sending channel and add the toRuStore() method: +```php +response;``` +- The NotificationFailed event contains a RuStoreReport instance in its data['report'] property: ```$report = Arr::get($event->data, 'report');``` + +The RuStoreReport::all() method returns a collection of RuStoreSingleReport instances, where each report corresponds to a device (keyed by its push token). + +Example: Handling the NotificationSent Event +```php + // class SentListener + + /** + * Handle successfully sent notifications. + */ + public function handle(NotificationSent $event): void + { + match ($event->channel) { + RuStoreChannel::class => $this->handleRuStoreSuccess($event), + default => null + }; + } + + /** + * Log successfully sent RuStore notifications. + */ + public function handleRuStoreSuccess(NotificationSent $event): void + { + /** @var RuStoreReport $report */ + $report = $event->response; + + $report->all()->each(function (RuStoreSingleReport $singleReport, string $token) use ($report, $event): void { + /** @var Response $response */ + $response = $singleReport->response(); + Log::channel('notifications')->info('RuStoreSuccess: Notification sent successfully', [ + 'user' => $event->notifiable->short_info, + 'token' => $token, + 'message' => $report->getMessage()->toArray(), + 'response_status' => $response->status(), + ]); + }); + } + +``` +NOTE: The NotificationSent event is only triggered if there are successfully sent messages. + +Example: Handling the NotificationFailed Event +```php + // class FailedSendingListener + + public function handle(NotificationFailed $event): void + { + match ($event->channel) { + RuStoreChannel::class => $this->handleRuStoreFailed($event), + default => null + }; + } + + /** + * Handle failed RuStore notification deliveries. + * + * @param NotificationFailed $event + * @return void + */ + private function handleRuStoreFailed(NotificationFailed $event): void + { + /** @var RuStoreReport $report */ + $report = Arr::get($event->data, 'report'); + + $report->all()->each(function (RuStoreSingleReport $singleReport, string $token) use ($report, $event): void { + $e = $singleReport->error(); + Log::channel('notifications')->error('RuStoreFailed: Notification delivery error', [ + 'user' => $event->notifiable->short_info, + 'token' => $token, + 'message' => $report->getMessage()->toArray(), + 'error_code' => $e->getCode(), + 'error_message' => $e->getMessage(), + ]); + }); + } + +``` +NOTE: The NotificationFailed event is only triggered if there is at least one failed delivery. + + +### Available Message methods + +The message supports all the properties described in the [documentation](https://www.rustore.ru/help/sdk/push-notifications/send-push-notifications). + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. + +## Testing + +``` bash +$ composer test +``` + +## Security + +If you discover any security related issues, please email yagithub@mail.ru instead of using the issue tracker. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Credits + +- [yakOffKa](https://github.com/yakoffka) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..97754c83 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "laravel-notification-channels/ru-store", + "description": "RuStore push notifications Driver for Laravel", + "homepage": "https://github.com/laravel-notification-channels/ru-store", + "license": "MIT", + "keywords": [ + "laravel", + "notification", + "driver", + "channel", + "ru-store" + ], + "authors": [ + { + "name": "yakOffKa", + "email": "yagithub@mail.ru", + "homepage": "https://yakoffka.ru/", + "role": "Developer" + } + ], + "require": { + "php": ">=8.2", + "illuminate/notifications": "~10.0 || ~11.0", + "illuminate/support": "~10.0 || ~11.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "NotificationChannels\\RuStore\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "NotificationChannels\\RuStore\\Test\\": "tests" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text --coverage-clover=coverage.clover" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\RuStore\\RuStoreServiceProvider" + ] + } + } +} diff --git a/config/ru-store.php b/config/ru-store.php new file mode 100644 index 00000000..78204c4d --- /dev/null +++ b/config/ru-store.php @@ -0,0 +1,8 @@ + env('RUSTORE_PROJECT_ID', 'none'), + 'token' => env('RUSTORE_TOKEN', 'none'), +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..7fe1f144 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + + + diff --git a/src/Exceptions/RuStorePushException.php b/src/Exceptions/RuStorePushException.php new file mode 100644 index 00000000..2b79049a --- /dev/null +++ b/src/Exceptions/RuStorePushException.php @@ -0,0 +1,41 @@ +redirect() => 'RuStoreRedirect', + $response->clientError() => 'RuStoreClientError', + $response->serverError() => 'RuStoreServerError', + }; + + return new self( + message: "$type: ".$response->getBody()->getContents(), + code: $response->getStatusCode(), + ); + } +} diff --git a/src/Exceptions/RuStorePushNotingSentException.php b/src/Exceptions/RuStorePushNotingSentException.php new file mode 100644 index 00000000..eaedfd4c --- /dev/null +++ b/src/Exceptions/RuStorePushNotingSentException.php @@ -0,0 +1,23 @@ + + */ + public function all(): Collection + { + return $this->reports; + } + + /** + * Добавление отчета об отправке уведомления адресату $token. + * + * @param string $token + * @param RuStoreSingleReport $report + * @return self + */ + public function addReport(string $token, RuStoreSingleReport $report): self + { + $this->reports->put($token, $report); + + return $this; + } + + /** + * Получение отчета об успешных отправках. + * + * @return RuStoreReport + * + * @throws RuStorePushNotingSentException + */ + public function getSuccess(): self + { + $success = clone $this; + $success->reports = $this->reports->filter(fn (RuStoreSingleReport $report) => $report->isSuccess()); + + if ($success->reports->count() === 0) { + throw new RuStorePushNotingSentException(); + } + + return $success; + } + + /** + * Получение отчета об ошибочных отправках. + * + * @return RuStoreReport + */ + public function getFailure(): self + { + $failure = clone $this; + $failure->reports = $this->reports->filter(fn (RuStoreSingleReport $report) => $report->isFailure()); + + return $failure; + } + + /** + * Получение отправляемого сообщения. + * + * @return RuStoreMessage + */ + public function getMessage(): RuStoreMessage + { + return $this->message; + } +} diff --git a/src/Reports/RuStoreSingleReport.php b/src/Reports/RuStoreSingleReport.php new file mode 100644 index 00000000..a7f5af6d --- /dev/null +++ b/src/Reports/RuStoreSingleReport.php @@ -0,0 +1,85 @@ +error === null; + } + + /** + * @return bool + */ + public function isFailure(): bool + { + return ! $this->isSuccess(); + } + + /** + * @return PromiseInterface|Response|null + */ + public function response(): PromiseInterface|Response|null + { + return $this->response; + } + + /** + * @return Throwable|null + */ + public function error(): ?Throwable + { + return $this->error; + } +} diff --git a/src/Resources/MessageAndroid.php b/src/Resources/MessageAndroid.php new file mode 100644 index 00000000..ca99f020 --- /dev/null +++ b/src/Resources/MessageAndroid.php @@ -0,0 +1,42 @@ +ttl = $ttl; + + return $this; + } + + /** + * Map the resource to an array. + * + * @return array + */ + public function toArray(): array + { + return array_filter(get_object_vars($this)); + } +} diff --git a/src/Resources/MessageAndroidNotification.php b/src/Resources/MessageAndroidNotification.php new file mode 100644 index 00000000..63a0b23d --- /dev/null +++ b/src/Resources/MessageAndroidNotification.php @@ -0,0 +1,147 @@ +title = $title; + + return $this; + } + + /** + * Set the notification body. + * + * @param string|null $body + * @return $this + */ + public function body(?string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * Set the notification icon. + * + * @param string|null $icon + * @return $this + */ + public function icon(?string $icon): self + { + $this->icon = $icon; + + return $this; + } + + /** + * Set the notification color. + * + * @param string|null $color + * @return $this + */ + public function color(?string $color): self + { + $this->color = $color; + + return $this; + } + + /** + * Set the notification image. + * + * @param string|null $image + * @return $this + */ + public function image(?string $image): self + { + $this->image = $image; + + return $this; + } + + /** + * Set the notification image. + * + * @param string|null $channel_id + * @return $this + */ + public function channelId(?string $channel_id): self + { + $this->channel_id = $channel_id; + + return $this; + } + + /** + * Set the notification image. + * + * @param string|null $click_action + * @return $this + */ + public function clickAction(?string $click_action): self + { + $this->click_action = $click_action; + + return $this; + } + + /** + * Set the notification image. + * + * @param int|null $click_action_type + * @return $this + */ + public function clickActionType(?int $click_action_type): self + { + $this->click_action_type = $click_action_type; + + return $this; + } + + /** + * Map the resource to an array. + * + * @return array + */ + public function toArray(): array + { + return array_filter(get_object_vars($this)); + } +} diff --git a/src/Resources/MessageNotification.php b/src/Resources/MessageNotification.php new file mode 100644 index 00000000..979e0b21 --- /dev/null +++ b/src/Resources/MessageNotification.php @@ -0,0 +1,72 @@ +title = $title; + + return $this; + } + + /** + * Set the notification body. + * + * @param string|null $body + * @return $this + */ + public function body(?string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * Set the notification image. + * + * @param string|null $image + * @return $this + */ + public function image(?string $image): self + { + $this->image = $image; + + return $this; + } + + /** + * Map the resource to an array. + * + * @return array + */ + public function toArray(): array + { + return array_filter(get_object_vars($this)); + } +} diff --git a/src/Resources/RuStoreResource.php b/src/Resources/RuStoreResource.php new file mode 100644 index 00000000..aa49a985 --- /dev/null +++ b/src/Resources/RuStoreResource.php @@ -0,0 +1,24 @@ +toRuStore($notifiable); + $tokens = Arr::wrap($notifiable->routeNotificationForRuStore()); + $report = $this->client->send($message, $tokens); + $this->dispatchFailedNotification($notifiable, $notification, $report->getFailure()); + + return $report->getSuccess(); + } + + /** + * Поджигание события NotificationFailed. + * + * @param mixed $notifiable + * @param Notification $notification + * @param RuStoreReport $report + * @return void + */ + private function dispatchFailedNotification(mixed $notifiable, Notification $notification, RuStoreReport $report): void + { + if ($report->all()->isNotEmpty()) { + $this->events->dispatch(new NotificationFailed($notifiable, $notification, self::class, [ + 'report' => $report, + ])); + } + } +} diff --git a/src/RuStoreClient.php b/src/RuStoreClient.php new file mode 100644 index 00000000..3a5cb7f8 --- /dev/null +++ b/src/RuStoreClient.php @@ -0,0 +1,68 @@ +url = sprintf(self::URL_FORMAT, config('ru-store.project_id')); + $this->bearer_token = config('ru-store.token'); + } + + /** + * Отправка уведомлений на все устройства пользователя. + * + * @param RuStoreMessage $message + * @param array $tokens + * @return RuStoreReport + */ + public function send(RuStoreMessage $message, array $tokens): RuStoreReport + { + $report = RuStoreReport::init($tokens, $message); + $report->all()->each(function (?RuStoreSingleReport $_, string $token) use ($report, $message) { + $report->addReport($token, $this->sendSingle($message, $token)); + }); + + return $report; + } + + /** + * Отправка уведомления на конкретное устройство пользователя. + * + * @param RuStoreMessage $message + * @param string $token + * @return RuStoreSingleReport + */ + public function sendSingle(RuStoreMessage $message, string $token): RuStoreSingleReport + { + try { + $request = Http::withToken($this->bearer_token)->withBody($message->getPayload($token)); + /** @var PromiseInterface|Response $response */ + $response = $request->send('POST', $this->url); + } catch (Throwable $exception) { + return RuStoreSingleReport::failure($exception); + } + + return $response->successful() + ? RuStoreSingleReport::success($response) + : RuStoreSingleReport::failure(RuStorePushException::fromResponse($response), $response); + } +} diff --git a/src/RuStoreMessage.php b/src/RuStoreMessage.php new file mode 100644 index 00000000..4dc78e76 --- /dev/null +++ b/src/RuStoreMessage.php @@ -0,0 +1,63 @@ +data = $data; + + return $this; + } + + /** + * @param string $token + * @return string + * + * @throws JsonException + */ + public function getPayload(string $token): string + { + return json_encode(['message' => compact('token') + $this->toArray()], JSON_THROW_ON_ERROR); + } + + /** + * Map the resource to an array. + * + * @return array + */ + public function toArray(): array + { + return array_filter(get_object_vars($this)); + } +} diff --git a/src/RuStoreServiceProvider.php b/src/RuStoreServiceProvider.php new file mode 100644 index 00000000..000547a7 --- /dev/null +++ b/src/RuStoreServiceProvider.php @@ -0,0 +1,31 @@ +publishes([ + __DIR__.'/../config/ru-store.php' => config_path('ru-store.php'), + ]); + } + + /** + * Register the application services. + * + * @return void + */ + public function register(): void + { + } +} diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/Feature/EventsFireTest.php b/tests/Feature/EventsFireTest.php new file mode 100644 index 00000000..d6b22346 --- /dev/null +++ b/tests/Feature/EventsFireTest.php @@ -0,0 +1,138 @@ +setTokens(['valid']); + Http::fakeSequence()->push(null, 200); + + $notifiable->notify($notification); + + Event::assertDispatched(static function (NotificationSent $event) { + $tokens = $event->response->all()->keys()->toArray(); + + return $tokens === ['valid']; + }); + Event::assertNotDispatched(NotificationFailed::class); + } + + #[Test] + #[TestDox('Ошибочная отправка уведомления на одно устройство. NotificationSent поджигается, но response->reports пуст')] + public function eventsFireOnOnlyOneFail(): void + { + Event::fake(); + $notification = new TestNotification(); + $notifiable = (new User())->setTokens(['invalid']); + Http::fakeSequence()->push([ + 'error' => [ + 'code' => 404, + 'message' => 'Requested entity was not found.', + 'status' => 'NOT_FOUND', + ], + ], 404); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + Event::assertDispatched(static function (NotificationFailed $event) { + $tokens = $event->data['report']->all()->keys()->toArray(); + + return $tokens === ['invalid']; + }); + } + + #[Test] + #[TestDox('Отправка уведомления на два устройства: отправка на первое вернула 200, на второе - 404')] + public function eventsFireOnOneSuccessOneFail(): void + { + Event::fake(); + $notification = new TestNotification(); + $notifiable = (new User())->setTokens(['valid', 'invalid']); + Http::fakeSequence() + ->push(null, 200) + ->push([ + 'error' => [ + 'code' => 404, + 'message' => 'Requested entity was not found.', + 'status' => 'NOT_FOUND', + ], + ], 404); + + $notifiable->notify($notification); + + Event::assertDispatched(static function (NotificationSent $event) { + $tokens = $event->response->all()->keys()->toArray(); + + return $tokens === ['valid']; + }); + Event::assertDispatched(static function (NotificationFailed $event) { + $tokens = $event->data['report']->all()->keys()->toArray(); + + return $tokens === ['invalid']; + }); + } + + #[Test] + #[TestDox('Отправка уведомления на четыре устройства: на два удачно, на два - неудачно')] + public function eventsFireOnTwoSuccessTwoFail(): void + { + Event::fake(); + $notification = new TestNotification(); + $notifiable = (new User())->setTokens(['1_valid', '2_invalid', '3_valid', '4_invalid']); + Http::fakeSequence() + ->push(null, 200) + ->push([ + 'error' => [ + 'code' => 404, + 'message' => 'Requested entity was not found.', + 'status' => 'NOT_FOUND', + ], + ], 404) + ->push(null, 200) + ->push([ + 'error' => [ + 'code' => 404, + 'message' => 'Requested entity was not found.', + 'status' => 'NOT_FOUND', + ], + ], 404); + + $notifiable->notify($notification); + + Event::assertDispatched(static function (NotificationSent $event) { + $tokens = $event->response->all()->keys()->toArray(); + + return $tokens === ['1_valid', '3_valid']; + }); + Event::assertDispatched(static function (NotificationFailed $event) { + $tokens = $event->data['report']->all()->keys()->toArray(); + + return $tokens === ['2_invalid', '4_invalid']; + }); + } +} diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 00000000..2649990c --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,38 @@ +notify($notification); + + Notification::assertSentTo( + $notifiable, + TestNotification::class, + static function ($notification, $channels) { + return in_array(RuStoreChannel::class, $channels, true); + } + ); + } +} diff --git a/tests/Feature/StatusCodeTest.php b/tests/Feature/StatusCodeTest.php new file mode 100644 index 00000000..42e1e4cf --- /dev/null +++ b/tests/Feature/StatusCodeTest.php @@ -0,0 +1,205 @@ +url => Http::response()]); + $notification = new TestNotification(); + $notifiable = new User(); + + $notifiable->notify($notification); + + Event::assertDispatched(NotificationSending::class); + Event::assertDispatched(NotificationSent::class); + Event::assertNotDispatched(NotificationFailed::class); + } + + #[Test] + #[TestDox('Проверка обработки ошибочного ответа 301 Moved Permanently')] + public function handle_error_response301(): void + { + Event::fake(); + Http::fake([ + $this->url => Http::response([ + 'code' => 301, + 'message' => 'Moved Permanently', + 'status' => '', + ], 301), + ]); + $notification = new TestNotification(); + $notifiable = new User(); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + $this::assertEquals(RuStorePushNotingSentException::class, $e::class); + Event::assertDispatched(NotificationSending::class); + Event::assertNotDispatched(NotificationSent::class); + Event::assertDispatched(static function (NotificationFailed $event) { + /** @var RequestException $e */ + $e = $event->data['report']->all()->sole()->error(); + + return $e->getCode() === 301 + && $e->getMessage() === 'RuStoreRedirect: {"code":301,"message":"Moved Permanently","status":""}'; + }); + } + + #[Test] + #[TestDox('Проверка обработки ошибочного ответа 401 Forbidden')] + public function handle_error_response401(): void + { + Event::fake(); + Http::fake([ + $this->url => Http::response([ + 'code' => 401, + 'message' => 'unauthorized: Invalid Authorization header', + 'status' => 'UNAUTHORIZED', + ], 401), + ]); + $notification = new TestNotification(); + $notifiable = new User(); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + $this::assertEquals(RuStorePushNotingSentException::class, $e::class); + Event::assertDispatched(NotificationSending::class); + Event::assertNotDispatched(NotificationSent::class); + Event::assertDispatched(static function (NotificationFailed $event) { + /** @var RequestException $e */ + $e = $event->data['report']->all()->sole()->error(); + + return $e->getCode() === 401 + && $e->getMessage() === 'RuStoreClientError: ' + .'{"code":401,"message":"unauthorized: Invalid Authorization header","status":"UNAUTHORIZED"}'; + }); + } + + #[Test] + #[TestDox('Проверка обработки ошибочного ответа 403 Forbidden')] + public function handle_error_response403(): void + { + Event::fake(); + Http::fake([ + $this->url => Http::response([ + 'error' => [ + 'code' => 403, + 'message' => 'SenderId mismatch', + 'status' => 'PERMISSION_DENIED', + ], + ], 403), + ]); + $notification = new TestNotification(); + $notifiable = new User(); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + $this::assertEquals(RuStorePushNotingSentException::class, $e::class); + Event::assertDispatched(NotificationSending::class); + Event::assertNotDispatched(NotificationSent::class); + Event::assertDispatched(static function (NotificationFailed $event) { + /** @var RequestException $e */ + $e = $event->data['report']->all()->sole()->error(); + + return $e->getCode() === 403 && $e->getMessage() === 'RuStoreClientError: ' + .'{"error":{"code":403,"message":"SenderId mismatch","status":"PERMISSION_DENIED"}}'; + }); + } + + #[Test] + #[TestDox('Проверка обработки ошибочного ответа 404')] + public function handle_error_response404(): void + { + Event::fake(); + Http::fake([ + $this->url => Http::response([ + 'error' => [ + 'code' => 404, + 'message' => 'Requested entity was not found.', + 'status' => 'NOT_FOUND', + ], + ], 404), + ]); + $notification = new TestNotification(); + $notifiable = new User(); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + $this::assertEquals(RuStorePushNotingSentException::class, $e::class); + Event::assertDispatched(NotificationSending::class); + Event::assertNotDispatched(NotificationSent::class); + Event::assertDispatched(static function (NotificationFailed $event) { + /** @var RequestException $e */ + $e = $event->data['report']->all()->sole()->error(); + + return $e->getCode() === 404 && $e->getMessage() === 'RuStoreClientError: ' + .'{"error":{"code":404,"message":"Requested entity was not found.","status":"NOT_FOUND"}}'; + }); + } + + #[Test] + #[TestDox('Проверка обработки ошибочного ответа 500 Internal Server Error')] + public function handle_error_response500(): void + { + Event::fake(); + Http::fake([ + $this->url => Http::response([ + 'code' => 500, + 'message' => 'Internal Server Error', + 'status' => '', + ], 500), + ]); + $notification = new TestNotification(); + $notifiable = new User(); + + try { + $notifiable->notify($notification); + } catch (RuStorePushNotingSentException $e) { + } + + $this::assertEquals(RuStorePushNotingSentException::class, $e::class); + Event::assertDispatched(NotificationSending::class); + Event::assertNotDispatched(NotificationSent::class); + Event::assertDispatched(static function (NotificationFailed $event) { + /** @var RequestException $e */ + $e = $event->data['report']->all()->sole()->error(); + + return $e->getCode() === 500 + && $e->getMessage() === 'RuStoreServerError: {"code":500,"message":"Internal Server Error","status":""}'; + }); + } +} diff --git a/tests/Notifiable/User.php b/tests/Notifiable/User.php new file mode 100644 index 00000000..9effd39a --- /dev/null +++ b/tests/Notifiable/User.php @@ -0,0 +1,34 @@ +tokens = $tokens; + + return $this; + } + + /** + * @return array + */ + public function routeNotificationForRuStore(): array + { + return $this->tokens ?? [env('RUSTORE_EXAMPLE_PUSH_TOKEN', 'none')]; + } +} diff --git a/tests/Notifications/TestNotification.php b/tests/Notifications/TestNotification.php new file mode 100644 index 00000000..83e2ba47 --- /dev/null +++ b/tests/Notifications/TestNotification.php @@ -0,0 +1,40 @@ +app['config']->set('ru-store.project_id', env('RUSTORE_PROJECT_ID', 'test')); + $this->app['config']->set('ru-store.token', env('RUSTORE_TOKEN', 'test')); + } + + /** + * @param $app + * @return class-string[] + */ + protected function getPackageProviders($app) + { + return [RuStoreServiceProvider::class]; + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b