From 393489cafc3c0061146a6a3d93d80cb5a6e37cbd Mon Sep 17 00:00:00 2001 From: yakoffka Date: Wed, 14 May 2025 11:20:21 +0300 Subject: [PATCH 1/7] Add RuStore push channel --- .editorconfig | 15 + .gitignore | 7 + .styleci.yml | 1 + CHANGELOG.md | 30 ++ CONTRIBUTING.md | 55 ++++ LICENSE.md | 21 ++ README.md | 265 +++++++++++++++++- composer.json | 56 ++++ config/ru-store.php | 7 + phpunit.xml.dist | 22 ++ src/Exceptions/CouldNotSendNotification.php | 66 +++++ src/Exceptions/RuStorePushException.php | 40 +++ .../RuStorePushNotingSentException.php | 22 ++ src/Reports/RuStoreReport.php | 105 +++++++ src/Reports/RuStoreSingleReport.php | 86 ++++++ src/Resources/MessageAndroid.php | 42 +++ src/Resources/MessageAndroidNotification.php | 147 ++++++++++ src/Resources/MessageNotification.php | 71 +++++ src/Resources/RuStoreResource.php | 23 ++ src/RuStoreChannel.php | 58 ++++ src/RuStoreClient.php | 68 +++++ src/RuStoreMessage.php | 63 +++++ src/RuStoreServiceProvider.php | 30 ++ tests/Feature/.gitkeep | 0 tests/Feature/EventsFireTest.php | 132 +++++++++ tests/Feature/NotificationTest.php | 37 +++ tests/Feature/StatusCodeTest.php | 199 +++++++++++++ tests/Notifiable/User.php | 33 +++ tests/Notifications/TestNotification.php | 39 +++ tests/TestCase.php | 34 +++ tests/Unit/.gitkeep | 0 31 files changed, 1762 insertions(+), 12 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .styleci.yml create mode 100755 CHANGELOG.md create mode 100755 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 config/ru-store.php create mode 100644 phpunit.xml.dist create mode 100644 src/Exceptions/CouldNotSendNotification.php create mode 100644 src/Exceptions/RuStorePushException.php create mode 100644 src/Exceptions/RuStorePushNotingSentException.php create mode 100644 src/Reports/RuStoreReport.php create mode 100644 src/Reports/RuStoreSingleReport.php create mode 100644 src/Resources/MessageAndroid.php create mode 100644 src/Resources/MessageAndroidNotification.php create mode 100644 src/Resources/MessageNotification.php create mode 100644 src/Resources/RuStoreResource.php create mode 100644 src/RuStoreChannel.php create mode 100644 src/RuStoreClient.php create mode 100644 src/RuStoreMessage.php create mode 100644 src/RuStoreServiceProvider.php create mode 100644 tests/Feature/.gitkeep create mode 100644 tests/Feature/EventsFireTest.php create mode 100644 tests/Feature/NotificationTest.php create mode 100644 tests/Feature/StatusCodeTest.php create mode 100644 tests/Notifiable/User.php create mode 100644 tests/Notifications/TestNotification.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/.gitkeep 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/.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..d3ff31da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +[//]: # (https://keepachangelog.com/ru/0.3.0/) + + +## 1.0.1 - 2025-05-07 + +### Added +- Добавлен отчет об отправке уведомлений RuStoreReport в поджигаемых событиях +- Added report on sending notifications RuStoreReport in fired events + - NotificationSent (```$report = $event->response;```) + - NotificationFailed (```$report = Arr::get($event->data, 'report');```) + +### Changed +- Изменена обработка ответов от сервера: все неуспешные ответы (не 2**) интерпретируются как ошибка отправки (включая 1** и 3**) +- Changed handling of server responses: all unsuccessful responses (not 2**) are interpreted as a sending error (including 1** and 3**) +- Дополнено описание пакета [Readme](README.md) +- The package description has been supplemented [Readme](README.md) + +### Fixed +- Исправлено поджигание события NotificationSent при отсутствии успешно отправленных сообщений +- 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..5f862454 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,258 @@ -# New Notification Channels +Please see [this repo](https://github.com/laravel-notification-channels/channels) for instructions on how to submit a channel proposal. -### Suggesting a new channel -Have a suggestion or working on a new channel? Please create a new issue for that service. +[//]: # (# A Boilerplate repo for contributions) -### 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. +[//]: # () +[//]: # ([![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)) -## Workflow for new channels +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -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. +[//]: # ([![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)) -Take a look at our [FAQ](http://laravel-notification-channels.com/) to see our small list of rules, to provide top-notch notification channels. +[//]: # ([![StyleCI](https://styleci.io/repos/:style_ci_id/shield)](https://styleci.io/repos/:style_ci_id)) + +[//]: # ([![SensioLabsInsight](https://img.shields.io/sensiolabs/i/:sensio_labs_id.svg?style=flat-square)](https://insight.sensiolabs.com/projects/:sensio_labs_id)) + +[//]: # ([![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 +Установите пакет с помощью команды: +```bash + composer require yakoffka/laravel-notification-channels-ru-store +``` + +Затем опубликуйте конфигурационный файл: +```bash + php artisan vendor:publish --provider="NotificationChannels\RuStore\RuStoreServiceProvider" +``` +и обновите ваш .env, указав там значения, полученные в [RuStore консоли](https://console.rustore.ru/waiting) + +### Setting up the RuStore service + +Optionally include a few steps how users can set up the service. + +## Usage + +В классе, использующим трейт Notifiable (например User), необходимо реализовать метод, возвращающий массив токенов уведомляемого пользователя: + +```php + /** + * Получение массива ru-store пуш-токенов, полученных пользователем. + * Используется пакетом laravel-notification-channels/rustore + * + * @return array + */ + public function routeNotificationForRuStore(): array + { + return $this->ru_store_tokens; + } +``` + +Затем создать класс уведомления, в методе via() которого указать канал отправки RuStoreChannel и добавить метод toRuStore(): +```php +response;``` +- cобытие NotificationFailed содержит отчет RuStoreReport в свойстве data['report']: ```$report = Arr::get($event->data, 'report');``` + +Метод RuStoreReport::all() вернет коллекцию отчетов RuStoreSingleReport об отправке уведомлений на конкретное устройство с push-токенами в качестве ключей + +Пример использования события NotificationSent: +```php + // class SentListener + + /** + * Обработка успешно отправленных сообщений + */ + public function handle(NotificationSent $event): void + { + match ($event->channel) { + RuStoreChannel::class => $this->handleRuStoreSuccess($event), + default => null + }; + } + + /** + * Логирование успешно отправленных ru-store-уведомлений + */ + 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 Уведомление успешно отправлено', [ + 'user' => $event->notifiable->short_info, + 'token' => $token, + 'message' => $report->getMessage()->toArray(), + 'response_status' => $response->status(), + ]); + }); + } + +``` +NOTE: Событие NotificationSent поджигается только в случае наличия успешно отправленных сообщений. + + +Пример использования события NotificationFailed: +```php + // class FailedSendingListener + + public function handle(NotificationFailed $event): void + { + match ($event->channel) { + RuStoreChannel::class => $this->handleRuStoreFailed($event), + default => null + }; + } + + /** + * Обработка неудачных отправок уведомлений через канал RuStore + * + * @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 Ошибка отправки уведомления', [ + 'user' => $event->notifiable->short_info, + 'token' => $token, + 'message' => $report->getMessage()->toArray(), + 'error_code' => $e->getCode(), + 'error_message' => $e->getMessage(), + ]); + }); + } + +``` +NOTE: Событие NotificationFailed поджигается только в случае наличия хотя-бы одной неуспешной отправки. + + +### Available Message methods + +Сообщение поддерживает все свойства, описанные в [документации](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..3421ada4 --- /dev/null +++ b/config/ru-store.php @@ -0,0 +1,7 @@ + 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/CouldNotSendNotification.php b/src/Exceptions/CouldNotSendNotification.php new file mode 100644 index 00000000..38468edd --- /dev/null +++ b/src/Exceptions/CouldNotSendNotification.php @@ -0,0 +1,66 @@ +hasResponse()) { + return new self('RuStore responded with an error but no response body found'); + } + + $statusCode = $exception->getResponse()->getStatusCode(); + + $result = json_decode($exception->getResponse()->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR); + $description = $result->description ?? 'no description given'; + + return new self("RuStore responded with an error `{$statusCode} - {$description}`", 0, $exception); + } + + /** + * Ошибка связи с сервером RuStore + */ + public static function couldNotCommunicate(Throwable $e): self + { + return new self( + 'The communication with RuStore failed. "' . $e->getMessage() . '"' + ); + } + + /** + * @param PromiseInterface|Response $response + * @return static + */ + public static function serviceRespondedWithAnError(PromiseInterface|Response $response) + { + // $response->throw(); + // dd($response); + return new static("Descriptive error message."); + } +} diff --git a/src/Exceptions/RuStorePushException.php b/src/Exceptions/RuStorePushException.php new file mode 100644 index 00000000..81a513bf --- /dev/null +++ b/src/Exceptions/RuStorePushException.php @@ -0,0 +1,40 @@ +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..8658a9e7 --- /dev/null +++ b/src/Exceptions/RuStorePushNotingSentException.php @@ -0,0 +1,22 @@ + + */ + 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..b80c93db --- /dev/null +++ b/src/Reports/RuStoreSingleReport.php @@ -0,0 +1,86 @@ +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..0685d6ae --- /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..be372501 --- /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..0159fb67 --- /dev/null +++ b/src/Resources/MessageNotification.php @@ -0,0 +1,71 @@ +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..b93350dd --- /dev/null +++ b/src/Resources/RuStoreResource.php @@ -0,0 +1,23 @@ +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..c9225b09 --- /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..7f628357 --- /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..c4c9f673 --- /dev/null +++ b/src/RuStoreServiceProvider.php @@ -0,0 +1,30 @@ +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..b67e546d --- /dev/null +++ b/tests/Feature/EventsFireTest.php @@ -0,0 +1,132 @@ +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..7e9a5e81 --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,37 @@ +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..9e397f84 --- /dev/null +++ b/tests/Feature/StatusCodeTest.php @@ -0,0 +1,199 @@ +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..84eaf71c --- /dev/null +++ b/tests/Notifiable/User.php @@ -0,0 +1,33 @@ +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..dfa7b77e --- /dev/null +++ b/tests/Notifications/TestNotification.php @@ -0,0 +1,39 @@ +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 From 100bbd69b328b8b5ad23742e77478f1a04a25dc3 Mon Sep 17 00:00:00 2001 From: yakoffka Date: Wed, 14 May 2025 12:01:48 +0300 Subject: [PATCH 2/7] Translate Readme --- CHANGELOG.md | 4 --- README.md | 74 ++++++++++++++++++++++------------------------------ 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ff31da..246ecabe 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,15 @@ ## 1.0.1 - 2025-05-07 ### Added -- Добавлен отчет об отправке уведомлений RuStoreReport в поджигаемых событиях - Added report on sending notifications RuStoreReport in fired events - NotificationSent (```$report = $event->response;```) - NotificationFailed (```$report = Arr::get($event->data, 'report');```) ### Changed -- Изменена обработка ответов от сервера: все неуспешные ответы (не 2**) интерпретируются как ошибка отправки (включая 1** и 3**) - Changed handling of server responses: all unsuccessful responses (not 2**) are interpreted as a sending error (including 1** and 3**) -- Дополнено описание пакета [Readme](README.md) - The package description has been supplemented [Readme](README.md) ### Fixed -- Исправлено поджигание события NotificationSent при отсутствии успешно отправленных сообщений - Fixed firing of NotificationSent event when there were no successfully sent messages [//]: # (### Deleted) diff --git a/README.md b/README.md index 5f862454..771ef2a0 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,18 @@ -Please see [this repo](https://github.com/laravel-notification-channels/channels) for instructions on how to submit a channel proposal. +# RuStore push notification channel for Laravel -[//]: # (# A Boilerplate repo for contributions) - -[//]: # () -[//]: # ([![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)) +[![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) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -[//]: # ([![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)) - -[//]: # ([![StyleCI](https://styleci.io/repos/:style_ci_id/shield)](https://styleci.io/repos/:style_ci_id)) +[![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) -[//]: # ([![SensioLabsInsight](https://img.shields.io/sensiolabs/i/:sensio_labs_id.svg?style=flat-square)](https://insight.sensiolabs.com/projects/:sensio_labs_id)) +[![StyleCI](https://styleci.io/repos/:style_ci_id/shield)](https://styleci.io/repos/:style_ci_id) -[//]: # ([![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)) +[![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)) +[![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)) +[![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. @@ -37,29 +32,23 @@ This package makes it easy to send notifications using [RuStore](link to service ## 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" ``` -и обновите ваш .env, указав там значения, полученные в [RuStore консоли](https://console.rustore.ru/waiting) - -### Setting up the RuStore service +Update your .env file with the values obtained from the [RuStore console](https://console.rustore.ru/waiting) -Optionally include a few steps how users can set up the service. ## Usage - -В классе, использующим трейт Notifiable (например User), необходимо реализовать метод, возвращающий массив токенов уведомляемого пользователя: - +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 /** - * Получение массива ru-store пуш-токенов, полученных пользователем. - * Используется пакетом laravel-notification-channels/rustore + * Getting an array of ru-store push tokens of user devices * * @return array */ @@ -69,7 +58,7 @@ Optionally include a few steps how users can set up the service. } ``` -Затем создать класс уведомления, в методе via() которого указать канал отправки RuStoreChannel и добавить метод toRuStore(): +Create a notification class, in the via() method of which specify the RuStoreChannel sending channel and add the toRuStore() method: ```php response;``` -- cобытие NotificationFailed содержит отчет RuStoreReport в свойстве data['report']: ```$report = Arr::get($event->data, 'report');``` +#### Verifying Notification Delivery +To monitor sent notifications, use the following events: +- The NotificationSent event contains a RuStoreReport instance in its response property: ```$report = $event->response;``` +- The NotificationFailed event contains a RuStoreReport instance in its data['report'] property: ```$report = Arr::get($event->data, 'report');``` -Метод RuStoreReport::all() вернет коллекцию отчетов RuStoreSingleReport об отправке уведомлений на конкретное устройство с push-токенами в качестве ключей +The RuStoreReport::all() method returns a collection of RuStoreSingleReport instances, where each report corresponds to a device (keyed by its push token). -Пример использования события NotificationSent: +Example: Handling the NotificationSent Event ```php // class SentListener /** - * Обработка успешно отправленных сообщений + * Handle successfully sent notifications. */ public function handle(NotificationSent $event): void { @@ -164,7 +153,7 @@ class RuStoreTestNotification extends Notification implements ShouldQueue } /** - * Логирование успешно отправленных ru-store-уведомлений + * Log successfully sent RuStore notifications. */ public function handleRuStoreSuccess(NotificationSent $event): void { @@ -174,7 +163,7 @@ class RuStoreTestNotification extends Notification implements ShouldQueue $report->all()->each(function (RuStoreSingleReport $singleReport, string $token) use ($report, $event): void { /** @var Response $response */ $response = $singleReport->response(); - Log::channel('notifications')->info('RuStoreSuccess Уведомление успешно отправлено', [ + Log::channel('notifications')->info('RuStoreSuccess: Notification sent successfully', [ 'user' => $event->notifiable->short_info, 'token' => $token, 'message' => $report->getMessage()->toArray(), @@ -184,10 +173,9 @@ class RuStoreTestNotification extends Notification implements ShouldQueue } ``` -NOTE: Событие NotificationSent поджигается только в случае наличия успешно отправленных сообщений. - +NOTE: The NotificationSent event is only triggered if there are successfully sent messages. -Пример использования события NotificationFailed: +Example: Handling the NotificationFailed Event ```php // class FailedSendingListener @@ -200,7 +188,7 @@ NOTE: Событие NotificationSent поджигается только в с } /** - * Обработка неудачных отправок уведомлений через канал RuStore + * Handle failed RuStore notification deliveries. * * @param NotificationFailed $event * @return void @@ -212,7 +200,7 @@ NOTE: Событие NotificationSent поджигается только в с $report->all()->each(function (RuStoreSingleReport $singleReport, string $token) use ($report, $event): void { $e = $singleReport->error(); - Log::channel('notifications')->error('RuStoreFailed Ошибка отправки уведомления', [ + Log::channel('notifications')->error('RuStoreFailed: Notification delivery error', [ 'user' => $event->notifiable->short_info, 'token' => $token, 'message' => $report->getMessage()->toArray(), @@ -223,12 +211,12 @@ NOTE: Событие NotificationSent поджигается только в с } ``` -NOTE: Событие NotificationFailed поджигается только в случае наличия хотя-бы одной неуспешной отправки. +NOTE: The NotificationFailed event is only triggered if there is at least one failed delivery. ### Available Message methods -Сообщение поддерживает все свойства, описанные в [документации](https://www.rustore.ru/help/sdk/push-notifications/send-push-notifications) +The message supports all the properties described in the [documentation](https://www.rustore.ru/help/sdk/push-notifications/send-push-notifications). ## Changelog From 945bb04c2d3c2bd4a6a5fbcaed757ac8bcaa6210 Mon Sep 17 00:00:00 2001 From: yakoffka Date: Wed, 14 May 2025 12:04:11 +0300 Subject: [PATCH 3/7] Adding tests.yml for github workflow --- .github/workflows/tests.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/tests.yml 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 From 7aa0c382f7a0386f261d59309c2bba491e109165 Mon Sep 17 00:00:00 2001 From: yakoffka Date: Wed, 14 May 2025 12:55:32 +0300 Subject: [PATCH 4/7] 1 Resolving issues identified by CI --- config/ru-store.php | 1 + 1 file changed, 1 insertion(+) diff --git a/config/ru-store.php b/config/ru-store.php index 3421ada4..78204c4d 100644 --- a/config/ru-store.php +++ b/config/ru-store.php @@ -1,4 +1,5 @@ Date: Wed, 14 May 2025 13:50:07 +0300 Subject: [PATCH 5/7] 2 Resolving issues identified by CI --- README.md | 1 + src/Exceptions/CouldNotSendNotification.php | 66 ------------------- src/Exceptions/RuStorePushException.php | 15 +++-- .../RuStorePushNotingSentException.php | 9 +-- src/Reports/RuStoreReport.php | 27 ++++---- src/Reports/RuStoreSingleReport.php | 18 ++--- src/Resources/MessageAndroid.php | 12 ++-- src/Resources/MessageAndroidNotification.php | 42 ++++++------ src/Resources/MessageNotification.php | 13 ++-- src/Resources/RuStoreResource.php | 3 +- src/RuStoreChannel.php | 21 +++--- src/RuStoreClient.php | 13 ++-- src/RuStoreMessage.php | 14 ++-- src/RuStoreServiceProvider.php | 3 +- tests/Feature/EventsFireTest.php | 9 +-- tests/Feature/NotificationTest.php | 3 +- tests/Feature/StatusCodeTest.php | 15 +++-- tests/Notifiable/User.php | 1 + tests/Notifications/TestNotification.php | 7 +- tests/TestCase.php | 3 +- 20 files changed, 123 insertions(+), 172 deletions(-) delete mode 100644 src/Exceptions/CouldNotSendNotification.php diff --git a/README.md b/README.md index 771ef2a0..0a554de2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ In a class using the Notifiable trait (e.g., the User model), implement a method Create a notification class, in the via() method of which specify the RuStoreChannel sending channel and add the toRuStore() method: ```php hasResponse()) { - return new self('RuStore responded with an error but no response body found'); - } - - $statusCode = $exception->getResponse()->getStatusCode(); - - $result = json_decode($exception->getResponse()->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR); - $description = $result->description ?? 'no description given'; - - return new self("RuStore responded with an error `{$statusCode} - {$description}`", 0, $exception); - } - - /** - * Ошибка связи с сервером RuStore - */ - public static function couldNotCommunicate(Throwable $e): self - { - return new self( - 'The communication with RuStore failed. "' . $e->getMessage() . '"' - ); - } - - /** - * @param PromiseInterface|Response $response - * @return static - */ - public static function serviceRespondedWithAnError(PromiseInterface|Response $response) - { - // $response->throw(); - // dd($response); - return new static("Descriptive error message."); - } -} diff --git a/src/Exceptions/RuStorePushException.php b/src/Exceptions/RuStorePushException.php index 81a513bf..2b79049a 100644 --- a/src/Exceptions/RuStorePushException.php +++ b/src/Exceptions/RuStorePushException.php @@ -1,4 +1,5 @@ redirect() => 'RuStoreRedirect', $response->clientError() => 'RuStoreClientError', $response->serverError() => 'RuStoreServerError', }; return new self( - message: "$type: " . $response->getBody()->getContents(), + message: "$type: ".$response->getBody()->getContents(), code: $response->getStatusCode(), ); } diff --git a/src/Exceptions/RuStorePushNotingSentException.php b/src/Exceptions/RuStorePushNotingSentException.php index 8658a9e7..eaedfd4c 100644 --- a/src/Exceptions/RuStorePushNotingSentException.php +++ b/src/Exceptions/RuStorePushNotingSentException.php @@ -1,4 +1,5 @@ */ @@ -49,10 +50,10 @@ public function all(): Collection } /** - * Добавление отчета об отправке уведомления адресату $token + * Добавление отчета об отправке уведомления адресату $token. * - * @param string $token - * @param RuStoreSingleReport $report + * @param string $token + * @param RuStoreSingleReport $report * @return self */ public function addReport(string $token, RuStoreSingleReport $report): self @@ -63,7 +64,7 @@ public function addReport(string $token, RuStoreSingleReport $report): self } /** - * Получение отчета об успешных отправках + * Получение отчета об успешных отправках. * * @return RuStoreReport * @throws RuStorePushNotingSentException @@ -81,7 +82,7 @@ public function getSuccess(): self } /** - * Получение отчета об ошибочных отправках + * Получение отчета об ошибочных отправках. * * @return RuStoreReport */ @@ -94,7 +95,7 @@ public function getFailure(): self } /** - * Получение отправляемого сообщения + * Получение отправляемого сообщения. * * @return RuStoreMessage */ diff --git a/src/Reports/RuStoreSingleReport.php b/src/Reports/RuStoreSingleReport.php index b80c93db..203937f2 100644 --- a/src/Reports/RuStoreSingleReport.php +++ b/src/Reports/RuStoreSingleReport.php @@ -9,13 +9,13 @@ use Throwable; /** - * Отчет об отправке уведомления на одно из устройств пользователя + * Отчет об отправке уведомления на одно из устройств пользователя. */ final class RuStoreSingleReport { /** - * @param PromiseInterface|Response|null $response - * @param Throwable|null $error + * @param PromiseInterface|Response|null $response + * @param Throwable|null $error */ public function __construct( readonly private PromiseInterface|Response|null $response = null, @@ -25,9 +25,9 @@ public function __construct( } /** - * Создание успешного отчета + * Создание успешного отчета. * - * @param PromiseInterface|Response $response + * @param PromiseInterface|Response $response * @return self */ public static function success(PromiseInterface|Response $response): self @@ -38,10 +38,10 @@ public static function success(PromiseInterface|Response $response): self } /** - * Создание отчета об ошибке + * Создание отчета об ошибке. * - * @param Throwable $error - * @param PromiseInterface|Response|null $response + * @param Throwable $error + * @param PromiseInterface|Response|null $response * @return self */ public static function failure(Throwable $error, null|PromiseInterface|Response $response = null): self @@ -65,7 +65,7 @@ public function isSuccess(): bool */ public function isFailure(): bool { - return !$this->isSuccess(); + return ! $this->isSuccess(); } /** diff --git a/src/Resources/MessageAndroid.php b/src/Resources/MessageAndroid.php index 0685d6ae..66d953a2 100644 --- a/src/Resources/MessageAndroid.php +++ b/src/Resources/MessageAndroid.php @@ -1,4 +1,5 @@ publishes([ - __DIR__ . '/../config/ru-store.php' => config_path('ru-store.php'), + __DIR__.'/../config/ru-store.php' => config_path('ru-store.php'), ]); } diff --git a/tests/Feature/EventsFireTest.php b/tests/Feature/EventsFireTest.php index b67e546d..38f9a157 100644 --- a/tests/Feature/EventsFireTest.php +++ b/tests/Feature/EventsFireTest.php @@ -1,4 +1,5 @@ 404, 'message' => 'Requested entity was not found.', 'status' => 'NOT_FOUND', - ] + ], ], 404); try { @@ -77,7 +78,7 @@ public function eventsFireOnOneSuccessOneFail(): void 'code' => 404, 'message' => 'Requested entity was not found.', 'status' => 'NOT_FOUND', - ] + ], ], 404); $notifiable->notify($notification); @@ -106,7 +107,7 @@ public function eventsFireOnTwoSuccessTwoFail(): void 'code' => 404, 'message' => 'Requested entity was not found.', 'status' => 'NOT_FOUND', - ] + ], ], 404) ->push(null, 200) ->push([ diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php index 7e9a5e81..2649990c 100644 --- a/tests/Feature/NotificationTest.php +++ b/tests/Feature/NotificationTest.php @@ -1,4 +1,5 @@ data['report']->all()->sole()->error(); return $e->getCode() === 401 && $e->getMessage() === 'RuStoreClientError: ' - . '{"code":401,"message":"unauthorized: Invalid Authorization header","status":"UNAUTHORIZED"}'; + .'{"code":401,"message":"unauthorized: Invalid Authorization header","status":"UNAUTHORIZED"}'; }); } @@ -111,7 +112,7 @@ public function handle_error_response403(): void 'code' => 403, 'message' => 'SenderId mismatch', 'status' => 'PERMISSION_DENIED', - ] + ], ], 403), ]); $notification = new TestNotification(); @@ -129,7 +130,7 @@ public function handle_error_response403(): void /** @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"}}'; + .'{"error":{"code":403,"message":"SenderId mismatch","status":"PERMISSION_DENIED"}}'; }); } @@ -144,7 +145,7 @@ public function handle_error_response404(): void 'code' => 404, 'message' => 'Requested entity was not found.', 'status' => 'NOT_FOUND', - ] + ], ], 404), ]); $notification = new TestNotification(); @@ -162,7 +163,7 @@ public function handle_error_response404(): void /** @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"}}'; + .'{"error":{"code":404,"message":"Requested entity was not found.","status":"NOT_FOUND"}}'; }); } diff --git a/tests/Notifiable/User.php b/tests/Notifiable/User.php index 84eaf71c..9effd39a 100644 --- a/tests/Notifiable/User.php +++ b/tests/Notifiable/User.php @@ -1,4 +1,5 @@ Date: Wed, 14 May 2025 14:03:51 +0300 Subject: [PATCH 6/7] 3 Resolving issues identified by CI --- src/Reports/RuStoreReport.php | 8 ++++---- src/Reports/RuStoreSingleReport.php | 2 +- src/Resources/MessageAndroid.php | 4 ++-- src/Resources/MessageAndroidNotification.php | 16 ++++++++-------- src/Resources/MessageNotification.php | 6 +++--- src/RuStoreClient.php | 1 - src/RuStoreMessage.php | 11 +++++------ tests/Feature/EventsFireTest.php | 8 +++++++- tests/Feature/StatusCodeTest.php | 5 +++++ 9 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/Reports/RuStoreReport.php b/src/Reports/RuStoreReport.php index d5fefecc..0028975f 100644 --- a/src/Reports/RuStoreReport.php +++ b/src/Reports/RuStoreReport.php @@ -14,11 +14,11 @@ final class RuStoreReport { /** - * @param Collection $reports Коллекция отчетов об отправке уведомлений с push-токенами в качестве ключей - * @param RuStoreMessage $message Отправляемое сообщение + * @param Collection $reports Коллекция отчетов об отправке уведомлений с push-токенами в качестве ключей + * @param RuStoreMessage $message Отправляемое сообщение */ public function __construct( - private Collection $reports, + private Collection $reports, readonly private RuStoreMessage $message, ) { @@ -74,7 +74,7 @@ public function getSuccess(): self $success = clone $this; $success->reports = $this->reports->filter(fn (RuStoreSingleReport $report) => $report->isSuccess()); - if($success->reports->count() === 0) { + if ($success->reports->count() === 0) { throw new RuStorePushNotingSentException(); } diff --git a/src/Reports/RuStoreSingleReport.php b/src/Reports/RuStoreSingleReport.php index 203937f2..a1b19cd5 100644 --- a/src/Reports/RuStoreSingleReport.php +++ b/src/Reports/RuStoreSingleReport.php @@ -19,7 +19,7 @@ final class RuStoreSingleReport */ public function __construct( readonly private PromiseInterface|Response|null $response = null, - readonly private ?Throwable $error = null, + readonly private ?Throwable $error = null, ) { } diff --git a/src/Resources/MessageAndroid.php b/src/Resources/MessageAndroid.php index 66d953a2..ca99f020 100644 --- a/src/Resources/MessageAndroid.php +++ b/src/Resources/MessageAndroid.php @@ -10,8 +10,8 @@ class MessageAndroid extends RuStoreResource { /** - * @param string|null $ttl Как долго (в секундах) сообщение должно храниться в хранилище. Пример: '3.5'. - * @param MessageAndroidNotification|null $notification Уведомление для отправки на устройства Android. + * @param string|null $ttl Как долго (в секундах) сообщение должно храниться в хранилище. Пример: '3.5'. + * @param MessageAndroidNotification|null $notification Уведомление для отправки на устройства Android. */ public function __construct( public ?string $ttl = null, diff --git a/src/Resources/MessageAndroidNotification.php b/src/Resources/MessageAndroidNotification.php index 1950af4b..d4711f6d 100644 --- a/src/Resources/MessageAndroidNotification.php +++ b/src/Resources/MessageAndroidNotification.php @@ -10,14 +10,14 @@ class MessageAndroidNotification extends RuStoreResource { /** - * @param string|null $title Название уведомления. - * @param string|null $body Основной текст уведомления. - * @param string|null $icon Значок уведомления. - * @param string|null $color Цвет значка уведомления в формате #rrggbb. - * @param string|null $image Содержит URL-адрес изображения, которое будет отображаться в уведомлении. - * @param string|null $channel_id Идентификатор канала уведомления. - * @param string|null $click_action Действие, связанное с кликом пользователя по уведомлению. - * @param int|null $click_action_type Необязательное поле, тип click_action + * @param string|null $title Название уведомления. + * @param string|null $body Основной текст уведомления. + * @param string|null $icon Значок уведомления. + * @param string|null $color Цвет значка уведомления в формате #rrggbb. + * @param string|null $image Содержит URL-адрес изображения, которое будет отображаться в уведомлении. + * @param string|null $channel_id Идентификатор канала уведомления. + * @param string|null $click_action Действие, связанное с кликом пользователя по уведомлению. + * @param int|null $click_action_type Необязательное поле, тип click_action * 0 - click_action будет использоваться как intent action (значение по умолчанию) * 1 - click_action будет использоваться как deep link */ diff --git a/src/Resources/MessageNotification.php b/src/Resources/MessageNotification.php index 5bb9b8ed..979e0b21 100644 --- a/src/Resources/MessageNotification.php +++ b/src/Resources/MessageNotification.php @@ -12,9 +12,9 @@ class MessageNotification extends RuStoreResource /** * Create a new notification instance. * - * @param string|null $title Название уведомления - * @param string|null $body Основной текст уведомления - * @param string|null $image Содержит URL-адрес изображения, которое будет отображаться в уведомлении. + * @param string|null $title Название уведомления + * @param string|null $body Основной текст уведомления + * @param string|null $image Содержит URL-адрес изображения, которое будет отображаться в уведомлении. */ public function __construct( public ?string $title = null, diff --git a/src/RuStoreClient.php b/src/RuStoreClient.php index 2c1f1a09..3a5cb7f8 100644 --- a/src/RuStoreClient.php +++ b/src/RuStoreClient.php @@ -57,7 +57,6 @@ public function sendSingle(RuStoreMessage $message, string $token): RuStoreSingl $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); } diff --git a/src/RuStoreMessage.php b/src/RuStoreMessage.php index 7e4c4c1c..82cf7f64 100644 --- a/src/RuStoreMessage.php +++ b/src/RuStoreMessage.php @@ -16,22 +16,21 @@ class RuStoreMessage /** * Create a new message instance. * - * @param array|null $data Объект, содержащий пары "key": value. - * @param MessageNotification|null $notification Базовый шаблон уведомления для использования на всех платформах. - * @param MessageAndroid|null $android Специальные параметры Android для сообщений. + * @param array|null $data Объект, содержащий пары "key": value. + * @param MessageNotification|null $notification Базовый шаблон уведомления для использования на всех платформах. + * @param MessageAndroid|null $android Специальные параметры Android для сообщений. */ public function __construct( public ?array $data = null, public ?MessageNotification $notification = null, public ?MessageAndroid $android = null, - ) - { + ) { } /** * Set the message data. * - * @param array|null $data + * @param array|null $data * @return $this */ public function setData(?array $data): self diff --git a/tests/Feature/EventsFireTest.php b/tests/Feature/EventsFireTest.php index 38f9a157..89f06f2f 100644 --- a/tests/Feature/EventsFireTest.php +++ b/tests/Feature/EventsFireTest.php @@ -33,6 +33,7 @@ public function eventsFireOnOnlyOneSuccess(): void Event::assertDispatched(static function (NotificationSent $event) { $tokens = $event->response->all()->keys()->toArray(); + return $tokens === ['valid']; }); Event::assertNotDispatched(NotificationFailed::class); @@ -60,6 +61,7 @@ public function eventsFireOnOnlyOneFail(): void Event::assertDispatched(static function (NotificationFailed $event) { $tokens = $event->data['report']->all()->keys()->toArray(); + return $tokens === ['invalid']; }); } @@ -85,10 +87,12 @@ public function eventsFireOnOneSuccessOneFail(): void 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']; }); } @@ -115,7 +119,7 @@ public function eventsFireOnTwoSuccessTwoFail(): void 'code' => 404, 'message' => 'Requested entity was not found.', 'status' => 'NOT_FOUND', - ] + ], ], 404); $notifiable->notify($notification); @@ -123,10 +127,12 @@ public function eventsFireOnTwoSuccessTwoFail(): void 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/StatusCodeTest.php b/tests/Feature/StatusCodeTest.php index 85047830..42e1e4cf 100644 --- a/tests/Feature/StatusCodeTest.php +++ b/tests/Feature/StatusCodeTest.php @@ -64,6 +64,7 @@ public function handle_error_response301(): void 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":""}'; }); @@ -95,6 +96,7 @@ public function handle_error_response401(): void 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"}'; @@ -129,6 +131,7 @@ public function handle_error_response403(): void 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"}}'; }); @@ -162,6 +165,7 @@ public function handle_error_response404(): void 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"}}'; }); @@ -193,6 +197,7 @@ public function handle_error_response500(): void 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":""}'; }); From 0ac19b3346e50378e766cae977c58ea70f976959 Mon Sep 17 00:00:00 2001 From: yakoffka Date: Wed, 14 May 2025 14:07:52 +0300 Subject: [PATCH 7/7] 4 Resolving issues identified by CI --- src/Reports/RuStoreReport.php | 4 ++-- src/Reports/RuStoreSingleReport.php | 3 +-- src/Resources/MessageAndroidNotification.php | 4 ++-- src/RuStoreMessage.php | 1 - tests/Feature/EventsFireTest.php | 1 - 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Reports/RuStoreReport.php b/src/Reports/RuStoreReport.php index 0028975f..3fae619d 100644 --- a/src/Reports/RuStoreReport.php +++ b/src/Reports/RuStoreReport.php @@ -20,8 +20,7 @@ final class RuStoreReport public function __construct( private Collection $reports, readonly private RuStoreMessage $message, - ) - { + ) { } /** @@ -67,6 +66,7 @@ public function addReport(string $token, RuStoreSingleReport $report): self * Получение отчета об успешных отправках. * * @return RuStoreReport + * * @throws RuStorePushNotingSentException */ public function getSuccess(): self diff --git a/src/Reports/RuStoreSingleReport.php b/src/Reports/RuStoreSingleReport.php index a1b19cd5..a7f5af6d 100644 --- a/src/Reports/RuStoreSingleReport.php +++ b/src/Reports/RuStoreSingleReport.php @@ -20,8 +20,7 @@ final class RuStoreSingleReport public function __construct( readonly private PromiseInterface|Response|null $response = null, readonly private ?Throwable $error = null, - ) - { + ) { } /** diff --git a/src/Resources/MessageAndroidNotification.php b/src/Resources/MessageAndroidNotification.php index d4711f6d..63a0b23d 100644 --- a/src/Resources/MessageAndroidNotification.php +++ b/src/Resources/MessageAndroidNotification.php @@ -18,8 +18,8 @@ class MessageAndroidNotification extends RuStoreResource * @param string|null $channel_id Идентификатор канала уведомления. * @param string|null $click_action Действие, связанное с кликом пользователя по уведомлению. * @param int|null $click_action_type Необязательное поле, тип click_action - * 0 - click_action будет использоваться как intent action (значение по умолчанию) - * 1 - click_action будет использоваться как deep link + * 0 - click_action будет использоваться как intent action (значение по умолчанию) + * 1 - click_action будет использоваться как deep link */ public function __construct( public ?string $title = null, diff --git a/src/RuStoreMessage.php b/src/RuStoreMessage.php index 82cf7f64..4dc78e76 100644 --- a/src/RuStoreMessage.php +++ b/src/RuStoreMessage.php @@ -49,7 +49,6 @@ public function setData(?array $data): self public function getPayload(string $token): string { return json_encode(['message' => compact('token') + $this->toArray()], JSON_THROW_ON_ERROR); - } /** diff --git a/tests/Feature/EventsFireTest.php b/tests/Feature/EventsFireTest.php index 89f06f2f..d6b22346 100644 --- a/tests/Feature/EventsFireTest.php +++ b/tests/Feature/EventsFireTest.php @@ -124,7 +124,6 @@ public function eventsFireOnTwoSuccessTwoFail(): void $notifiable->notify($notification); - Event::assertDispatched(static function (NotificationSent $event) { $tokens = $event->response->all()->keys()->toArray();