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/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b2638710 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d13e72b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +build +composer.phar +composer.lock +.idea \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 00000000..6fad5be9 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,21 @@ +filter: + excluded_paths: [tests/*] + +checks: + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true + +tools: + external_code_coverage: true 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/.travis.yml b/.travis.yml new file mode 100644 index 00000000..8c61e12f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: php + +php: + - 7.2 + - 7.3 + +before_script: + - travis_retry composer self-update + - travis_retry composer update --no-interaction --prefer-source + +script: + - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + +after_script: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover coverage.clover diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 00000000..017196cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `exponent-push-notifications` will be documented in this file + +## 1.0.0 - 2017-XX-XX + +- 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..2aa66b6c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) Aly Suleiman + +> 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 746a917d..c59154ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,157 @@ -# new-channels +# Exponent push notifications channel for Laravel -Discuss about new channel proposals our share your finished channels in the [proposal issue](https://github.com/laravel-notification-channels/new-channels/issues/6). +[![Latest Version on Packagist](https://img.shields.io/packagist/v/alymosul/laravel-exponent-push-notifications.svg?style=flat-square)](https://packagist.org/packages/alymosul/laravel-exponent-push-notifications) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Build Status](https://travis-ci.org/Alymosul/laravel-exponent-push-notifications.svg?branch=master)](https://travis-ci.org/Alymosul/laravel-exponent-push-notifications) +[![StyleCI](https://styleci.io/repos/96645200/shield?branch=master)](https://styleci.io/repos/96645200) +[![SensioLabsInsight](https://img.shields.io/sensiolabs/i/afe0ba9a-e35c-4759-a06f-14a081cf452c.svg?style=flat-square)](https://insight.sensiolabs.com/projects/afe0ba9a-e35c-4759-a06f-14a081cf452c) +[![Quality Score](https://img.shields.io/scrutinizer/g/alymosul/laravel-exponent-push-notifications.svg?style=flat-square)](https://scrutinizer-ci.com/g/alymosul/laravel-exponent-push-notifications) +[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/alymosul/laravel-exponent-push-notifications/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/alymosul/laravel-exponent-push-notifications/?branch=master) +[![Total Downloads](https://img.shields.io/packagist/dt/alymosul/laravel-exponent-push-notifications.svg?style=flat-square)](https://packagist.org/packages/alymosul/laravel-exponent-push-notifications) -Take a look at our [FAQ](http://laravel-notification-channels.com/) to see our small list of rules, to provide top-notch notification channels. +## Contents + +- [Installation](#installation) +- [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 alymosul/laravel-exponent-push-notifications +``` + +If you are using Laravel 5.5 or higher this package will automatically register itself using [Package Discovery](https://laravel.com/docs/5.5/packages#package-discovery). For older versions of Laravel you must install the service provider manually: + +```php +// config/app.php +'providers' => [ + ... + NotificationChannels\ExpoPushNotifications\ExpoPushNotificationsServiceProvider::class, +], + +``` + +Before publish exponent notification migration you must add in .env file: + +```bash +EXPONENT_PUSH_NOTIFICATION_INTERESTS_STORAGE_DRIVER=database +``` + +You can publish the migration with: + +```bash +php artisan vendor:publish --provider="NotificationChannels\ExpoPushNotifications\ExpoPushNotificationsServiceProvider" --tag="migrations" +``` + +After publishing the migration you can create the `exponent_push_notification_interests` table by running the migrations: + +```bash +php artisan migrate +``` + +You can optionally publish the config file with: + +```bash +php artisan vendor:publish --provider="NotificationChannels\ExpoPushNotifications\ExpoPushNotificationsServiceProvider" --tag="config" +``` + +This is the contents of the published config file: + +```php +return [ + 'interests' => [ + /* + * Supported: "file", "database" + */ + 'driver' => env('EXPONENT_PUSH_NOTIFICATION_INTERESTS_STORAGE_DRIVER', 'file'), + + 'database' => [ + 'events' => [], + + 'table_name' => 'exponent_push_notification_interests', + ], + ] +]; +``` + +## Usage + +```php +use NotificationChannels\ExpoPushNotifications\ExpoChannel; +use NotificationChannels\ExpoPushNotifications\ExpoMessage; +use Illuminate\Notifications\Notification; + +class AccountApproved extends Notification +{ + public function via($notifiable) + { + return [ExpoChannel::class]; + } + + public function toExpoPush($notifiable) + { + return ExpoMessage::create() + ->badge(1) + ->enableSound() + ->title("Congratulations!") + ->body("Your {$notifiable->service} account was approved!"); + } +} +``` + +### Available Message methods + +A list of all available options + +- `title('')`: Accepts a string value for the title. +- `body('')`: Accepts a string value for the body. +- `enableSound()`: Enables the notification sound. +- `disableSound()`: Mutes the notification sound. +- `badge(1)`: Accepts an integer value for the badge. +- `ttl(60)`: Accepts an integer value for the time to live. +- `jsonData('')`: Accepts a json string or an array for additional. +- `channelID('')`: Accepts a string to set the channelId of the notification for Android devices. +- `priority('default')`: Accepts a string to set the priority of the notification, must be one of [default, normal, high]. + +### Managing Recipients + +This package registers two endpoints that handle the subscription of recipients, the endpoints are defined in src/Http/routes.php file, used by ExpoController and all loaded through the package service provider. + +### Routing a message + +By default the exponent "interest" messages will be sent to will be defined using the {notifiable}.{id} convention, for example `App.User.1`, however you can change this behaviour by including a `routeNotificationForExpoPushNotifications()` in the notifiable class method that returns the interest name. + +## 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 alymosul@gmail.com instead of using the issue tracker. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Credits + +- [Aly Suleiman](https://github.com/Alymosul) +- [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..f5195d2b --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "alymosul/laravel-exponent-push-notifications", + "description": "Exponent push notifications driver for laravel", + "homepage": "https://github.com/alymosul/laravel-exponent-push-notifications", + "license": "MIT", + "authors": [ + { + "name": "Aly Suleiman", + "email": "alymosul@gmail.com", + "role": "Developer" + } + ], + "require": { + "php": "^7.1.3|^8.0", + "alymosul/exponent-server-sdk-php": "1.3.*", + "laravel/framework": "^5.6 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "mockery/mockery": "^0.9.5||^1.0", + "phpunit/phpunit": "~5.4||~5.7||~6.0||^7.0||^8.0||^9.0", + "orchestra/testbench": "~3.3||~4.0||^6.0" + }, + "autoload": { + "psr-4": { + "NotificationChannels\\ExpoPushNotifications\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "NotificationChannels\\ExpoPushNotifications\\Test\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\ExpoPushNotifications\\ExpoPushNotificationsServiceProvider" + ] + } + } +} diff --git a/config/exponent-push-notifications.php b/config/exponent-push-notifications.php new file mode 100644 index 00000000..29a3ad13 --- /dev/null +++ b/config/exponent-push-notifications.php @@ -0,0 +1,26 @@ + [ + //'auth:sanctum', //<- Use only this middleware if you're using Sanctum + 'auth:api', + 'bindings', + ], + 'debug' => env('EXPONENT_PUSH_NOTIFICATION_DEBUG', true), + + 'interests' => [ + 'driver' => env('EXPONENT_PUSH_NOTIFICATION_INTERESTS_STORAGE_DRIVER', 'file'), + + 'database' => [ + 'events' => [], + + 'table_name' => 'exponent_push_notification_interests', + ], + ], +]; diff --git a/migrations/create_exponent_push_notification_interests_table.php.stub b/migrations/create_exponent_push_notification_interests_table.php.stub new file mode 100644 index 00000000..b705c1d4 --- /dev/null +++ b/migrations/create_exponent_push_notification_interests_table.php.stub @@ -0,0 +1,29 @@ +string('key')->index(); + $table->string('value'); + + $table->unique(['key','value']); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::drop(config('exponent-push-notifications.interests.database.table_name')); + } +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..a09f43bd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + + + + + src/ + + + + + + + + + + diff --git a/src/Exceptions/CouldNotCreateMessage.php b/src/Exceptions/CouldNotCreateMessage.php new file mode 100644 index 00000000..55027fea --- /dev/null +++ b/src/Exceptions/CouldNotCreateMessage.php @@ -0,0 +1,7 @@ +events = $events; + $this->expo = $expo; + } + + /** + * Send the given notification. + * + * @param mixed $notifiable + * @param \Illuminate\Notifications\Notification $notification + * + * @throws CouldNotSendNotification + * + * @return void + */ + public function send($notifiable, Notification $notification) + { + $interest = $notifiable->routeNotificationFor('ExpoPushNotifications') + ?: $this->interestName($notifiable); + + $interests = [$interest]; + + try { + $this->expo->notify( + $interests, + $notification->toExpoPush($notifiable)->toArray(), + config('exponent-push-notifications.debug') + ); + } catch (ExpoException $e) { + $this->events->dispatch( + new NotificationFailed($notifiable, $notification, 'expo-push-notifications', $e->getMessage()) + ); + } + } + + /** + * Get the interest name for the notifiable. + * + * @param $notifiable + * + * @return string + */ + public function interestName($notifiable) + { + $class = str_replace('\\', '.', get_class($notifiable)); + + return $class.'.'.$notifiable->getKey(); + } +} diff --git a/src/ExpoMessage.php b/src/ExpoMessage.php new file mode 100644 index 00000000..60dedba8 --- /dev/null +++ b/src/ExpoMessage.php @@ -0,0 +1,244 @@ +body = $body; + } + + /** + * Set the message title. + * + * @param string $value + * + * @return $this + */ + public function title(string $value) + { + $this->title = $value; + + return $this; + } + + /** + * Set the message body. + * + * @param string $value + * + * @return $this + */ + public function body(string $value) + { + $this->body = $value; + + return $this; + } + + /** + * Enable the message sound. + * + * @return $this + */ + public function enableSound() + { + $this->sound = 'default'; + + return $this; + } + + /** + * Disable the message sound. + * + * @return $this + */ + public function disableSound() + { + $this->sound = null; + + return $this; + } + + /** + * Set the message badge (iOS). + * + * @param int $value + * + * @return $this + */ + public function badge(int $value) + { + $this->badge = $value; + + return $this; + } + + /** + * Set the time to live of the notification. + * + * @param int $ttl + * + * @return $this + */ + public function setTtl(int $ttl) + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Set the channelId of the notification for Android devices. + * + * @param string $channelId + * + * @return $this + */ + public function setChannelId(string $channelId) + { + $this->channelId = $channelId; + + return $this; + } + + /** + * Set the json Data attached to the message. + * + * @param array|string $data + * + * @return $this + * + * @throws CouldNotCreateMessage + */ + public function setJsonData($data) + { + if (is_array($data)) { + $data = json_encode($data); + } elseif (is_string($data)) { + @json_decode($data); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new CouldNotCreateMessage('Invalid json format passed to the setJsonData().'); + } + } + + $this->jsonData = $data; + + return $this; + } + + /** + * Set the priority of the notification, must be one of [default, normal, high]. + * + * @param string $priority + * + * @return $this + */ + public function priority(string $priority) + { + $this->priority = $priority; + + return $this; + } + + /** + * Get an array representation of the message. + * + * @return array + */ + public function toArray() + { + $message = [ + 'title' => $this->title, + 'body' => $this->body, + 'sound' => $this->sound, + 'badge' => $this->badge, + 'ttl' => $this->ttl, + 'data' => $this->jsonData, + 'priority' => $this->priority, + ]; + if (! empty($this->channelId)) { + $message['channelId'] = $this->channelId; + } + + return $message; + } +} diff --git a/src/ExpoPushNotificationsServiceProvider.php b/src/ExpoPushNotificationsServiceProvider.php new file mode 100644 index 00000000..131877da --- /dev/null +++ b/src/ExpoPushNotificationsServiceProvider.php @@ -0,0 +1,99 @@ +setupConfig(); + + $repository = $this->getInterestsDriver(); + + $this->shouldPublishMigrations($repository); + + $this->app->when(ExpoChannel::class) + ->needs(Expo::class) + ->give(function () use ($repository) { + return new Expo(new ExpoRegistrar($repository)); + }); + + $router = $this->app['router']; + + $router->middlewareGroup('expo.middleware', isset(config('exponent-push-notifications')['middleware']) ? config('exponent-push-notifications')['middleware'] : []); + + + $this->loadRoutesFrom(__DIR__.'/Http/routes.php'); + } + + /** + * Register the application services. + * + * @return void + */ + public function register() + { + $this->app->bind(ExpoRepository::class, get_class($this->getInterestsDriver())); + } + + /** + * Gets the Expo repository driver based on config. + * + * @return ExpoRepository + */ + public function getInterestsDriver() + { + $driver = config('exponent-push-notifications.interests.driver'); + + switch ($driver) { + case 'database': + return new ExpoDatabaseDriver(); + break; + default: + return new ExpoFileDriver(); + } + } + + /** + * Publishes the configuration files for the package. + * + * @return void + */ + protected function setupConfig() + { + $this->publishes([ + __DIR__.'/../config/exponent-push-notifications.php' => config_path('exponent-push-notifications.php'), + ], 'config'); + + $this->mergeConfigFrom(__DIR__.'/../config/exponent-push-notifications.php', 'exponent-push-notifications'); + } + + /** + * Publishes the migration files needed in the package. + * + * @param ExpoRepository $repository + * + * @return void + */ + private function shouldPublishMigrations(ExpoRepository $repository) + { + if ($repository instanceof ExpoDatabaseDriver && ! class_exists('CreateExponentPushNotificationInterestsTable')) { + $timestamp = date('Y_m_d_His', time()); + $this->publishes([ + __DIR__.'/../migrations/create_exponent_push_notification_interests_table.php.stub' => database_path("/migrations/{$timestamp}_create_exponent_push_notification_interests_table.php"), + ], 'migrations'); + } + } +} diff --git a/src/Http/ExpoController.php b/src/Http/ExpoController.php new file mode 100644 index 00000000..df059386 --- /dev/null +++ b/src/Http/ExpoController.php @@ -0,0 +1,111 @@ +expoChannel = $expoChannel; + } + + /** + * Handles subscription endpoint for an expo token. + * + * @param Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function subscribe(Request $request) + { + $validator = Validator::make($request->all(), [ + 'expo_token' => 'required|string', + ]); + + if ($validator->fails()) { + return JsonResponse::create([ + 'status' => 'failed', + 'error' => [ + 'message' => 'Expo Token is required', + ], + ], 422); + } + + $token = $request->get('expo_token'); + + $interest = $this->expoChannel->interestName(Auth::user()); + + try { + $this->expoChannel->expo->subscribe($interest, $token); + } catch (\Exception $e) { + return JsonResponse::create([ + 'status' => 'failed', + 'error' => [ + 'message' => $e->getMessage(), + ], + ], 500); + } + + return JsonResponse::create([ + 'status' => 'succeeded', + 'expo_token' => $token, + ], 200); + } + + /** + * Handles removing subscription endpoint for the authenticated interest. + * + * @param Request $request + * + * @return JsonResponse + */ + public function unsubscribe(Request $request) + { + $interest = $this->expoChannel->interestName(Auth::user()); + + $validator = Validator::make($request->all(), [ + 'expo_token' => 'sometimes|string', + ]); + + if ($validator->fails()) { + return JsonResponse::create([ + 'status' => 'failed', + 'error' => [ + 'message' => 'Expo Token is invalid', + ], + ], 422); + } + + $token = $request->get('expo_token') ?: null; + + try { + $deleted = $this->expoChannel->expo->unsubscribe($interest, $token); + } catch (\Exception $e) { + return JsonResponse::create([ + 'status' => 'failed', + 'error' => [ + 'message' => $e->getMessage(), + ], + ], 500); + } + + return JsonResponse::create(['deleted' => $deleted]); + } +} diff --git a/src/Http/routes.php b/src/Http/routes.php new file mode 100644 index 00000000..ef090a0d --- /dev/null +++ b/src/Http/routes.php @@ -0,0 +1,13 @@ + 'api/exponent/devices', 'middleware' => 'expo.middleware'], function () { + Route::post('subscribe', [ + 'as' => 'register-interest', + 'uses' => 'NotificationChannels\ExpoPushNotifications\Http\ExpoController@subscribe', + ]); + + Route::post('unsubscribe', [ + 'as' => 'remove-interest', + 'uses' => 'NotificationChannels\ExpoPushNotifications\Http\ExpoController@unsubscribe', + ]); +}); diff --git a/src/Models/Interest.php b/src/Models/Interest.php new file mode 100644 index 00000000..093fd640 --- /dev/null +++ b/src/Models/Interest.php @@ -0,0 +1,53 @@ +dispatchesEvents = config('exponent-push-notifications.interests.database.events'); + + $this->table = config('exponent-push-notifications.interests.database.table_name'); + + parent::__construct($attributes); + } +} diff --git a/src/Repositories/ExpoDatabaseDriver.php b/src/Repositories/ExpoDatabaseDriver.php new file mode 100644 index 00000000..86eb2932 --- /dev/null +++ b/src/Repositories/ExpoDatabaseDriver.php @@ -0,0 +1,58 @@ + $key, + 'value' => $value, + ]); + + return $interest instanceof Interest; + } + + /** + * Retrieves an Expo token with a given identifier. + * + * @param string $key + * + * @return array + */ + public function retrieve(string $key) + { + return Interest::where('key', $key)->pluck('value')->toArray(); + } + + /** + * Removes an Expo token with a given identifier. + * + * @param string $key + * @param string $value + * + * @return bool + */ + public function forget(string $key, string $value = null): bool + { + $query = Interest::where('key', $key); + + if ($value) { + $query->where('value', $value); + } + + return $query->delete() > 0; + } +} diff --git a/tests/ChannelTest.php b/tests/ChannelTest.php new file mode 100644 index 00000000..710c0d2a --- /dev/null +++ b/tests/ChannelTest.php @@ -0,0 +1,112 @@ +expo = Mockery::mock(Expo::class); + + $this->events = Mockery::mock(Dispatcher::class); + + $this->channel = new ExpoChannel($this->expo, $this->events); + + $this->notification = new TestNotification; + + $this->notifiable = new TestNotifiable; + } + + protected function tearDown(): void + { + parent::tearDown(); + + Mockery::close(); + } + + /** @test */ + public function itCanSendANotification() + { + $message = $this->notification->toExpoPush($this->notifiable); + + $data = $message->toArray(); + + $this->expo->shouldReceive('notify')->with(['interest_name'], $data, true)->andReturn([['status' => 'ok']]); + + $this->channel->send($this->notifiable, $this->notification); + } + + /** @test */ + public function itFiresFailureEventOnFailure() + { + $message = $this->notification->toExpoPush($this->notifiable); + + $data = $message->toArray(); + + $this->expo->shouldReceive('notify')->with(['interest_name'], $data, true)->andThrow(ExpoException::class, ''); + + $this->events->shouldReceive('dispatch')->with(Mockery::type(NotificationFailed::class)); + + $this->channel->send($this->notifiable, $this->notification); + } +} + +class TestNotifiable +{ + use Notifiable; + + public function routeNotificationForExpoPushNotifications() + { + return 'interest_name'; + } + + public function getKey() + { + return 1; + } +} + +class TestNotification extends Notification +{ + public function toExpoPush($notifiable) + { + return new ExpoMessage(); + } +} diff --git a/tests/ExpoControllerTest.php b/tests/ExpoControllerTest.php new file mode 100644 index 00000000..c4a925d5 --- /dev/null +++ b/tests/ExpoControllerTest.php @@ -0,0 +1,279 @@ +setUpDatabase(); + + // We will fake an authenticated user + Auth::shouldReceive('user')->andReturn(new User()); + } + + protected function tearDown(): void + { + \Mockery::close(); + + parent::tearDown(); + } + + /** + * Data provider to help test the expo controller with the different repositories. + * + * @return array + */ + public function availableRepositories() + { + return [ + [new ExpoDatabaseDriver], + [new ExpoFileDriver], + ]; + } + + /** @test + * + * @param $expoRepository + * + * @dataProvider availableRepositories + */ + public function aDeviceCanSubscribeToTheSystem($expoRepository) + { + [$expoController, $expoChannel] = $this->setupExpo($expoRepository); + + // We will fake a request with the following data + $data = ['expo_token' => 'ExponentPushToken[fakeToken]']; + $request = $this->mockRequest($data); + $request->shouldReceive('get')->with('expo_token')->andReturn($data['expo_token']); + + $this->mockValidator(false); + + /** @var Request $request */ + $response = $expoController->subscribe($request); + $response = json_decode($response->content()); + + // The response should contain a succeeded status + $this->assertEquals('succeeded', $response->status); + // The response should return the registered token + $this->assertEquals($data['expo_token'], $response->expo_token); + + if ($expoRepository instanceof ExpoDatabaseDriver) { + $this->assertDatabaseHas(config('exponent-push-notifications.interests.database.table_name'), [ + 'key' => 'NotificationChannels.ExpoPushNotifications.Test.User.'.(new User)->getKey(), + 'value' => $data['expo_token'], + ]); + } + } + + /** @test + * + * @param $expoRepository + * + * @dataProvider availableRepositories + */ + public function subscribeReturnsErrorResponseIfTokenInvalid($expoRepository) + { + [$expoController, $expoChannel] = $this->setupExpo($expoRepository); + + // We will fake a request with no data + $request = $this->mockRequest([]); + + $this->mockValidator(true); + + /** @var Request $request */ + $response = $expoController->subscribe($request); + + // The response should contain a failed status + $this->assertEquals('failed', json_decode($response->content())->status); + // The response status should be 422 + $this->assertEquals(422, $response->getStatusCode()); + } + + /** @test */ + public function subscribeReturnsErrorResponseIfExceptionIsThrown() + { + // We will fake a request with the following data + $data = ['expo_token' => 'ExponentPushToken[fakeToken]']; + $request = $this->mockRequest($data); + $request->shouldReceive('get')->andReturn($data['expo_token']); + + $this->mockValidator(false); + + $expo = \Mockery::mock(Expo::class); + $expo->shouldReceive('subscribe')->andThrow(\Exception::class); + + /** @var Expo $expo */ + $expoChannel = new ExpoChannel($expo, new Dispatcher()); + + /** @var Request $request */ + $response = (new ExpoController($expoChannel))->subscribe($request); + $response = json_decode($response->content()); + + $this->assertEquals('failed', $response->status); + } + + /** @test + * + * + * @dataProvider availableRepositories + * + * @param $expoRepository + */ + public function aDeviceCanUnsubscribeSingleTokenFromTheSystem($expoRepository) + { + [$expoController, $expoChannel] = $this->setupExpo($expoRepository); + + // We will fake a request with the following data + $data = ['expo_token' => 'ExponentPushToken[fakeToken]']; + $request = $this->mockRequest($data); + $request->shouldReceive('get')->with('expo_token')->andReturn($data['expo_token']); + + $this->mockValidator(false); + + // We will subscribe an interest to the server. + $token = 'ExponentPushToken[fakeToken]'; + $interest = $expoChannel->interestName(new User()); + $expoChannel->expo->subscribe($interest, $token); + + $response = $expoController->unsubscribe($request); + $response = json_decode($response->content()); + + // The response should contain a deleted property with value true + $this->assertTrue($response->deleted); + + if ($expoRepository instanceof ExpoDatabaseDriver) { + $this->assertDatabaseMissing(config('exponent-push-notifications.interests.database.table_name'), [ + 'key' => 'NotificationChannels.ExpoPushNotifications.Test.User.'.(new User)->getKey(), + 'value' => $data['expo_token'], + ]); + } + } + + /** @test + * + * @param $expoRepository + * + * @dataProvider availableRepositories + */ + public function aDeviceCanUnsubscribeFromTheSystem($expoRepository) + { + [$expoController, $expoChannel] = $this->setupExpo($expoRepository); + + // We will fake a request with the following data + $request = $this->mockRequest([]); + $request->shouldReceive('get')->with('expo_token')->andReturn([]); + + $this->mockValidator(false); + + // We will subscribe an interest to the server. + $token = 'ExponentPushToken[fakeToken]'; + $interest = $expoChannel->interestName(new User()); + $expoChannel->expo->subscribe($interest, $token); + + $response = $expoController->unsubscribe($request); + $response = json_decode($response->content()); + + // The response should contain a deleted property with value true + $this->assertTrue($response->deleted); + + if ($expoRepository instanceof ExpoDatabaseDriver) { + $this->assertEquals(0, Interest::count()); + } + } + + /** @test */ + public function unsubscribeReturnsErrorResponseIfExceptionIsThrown() + { + $request = $this->mockRequest([]); + $request->shouldReceive('get')->with('expo_token')->andReturn([]); + + $expo = \Mockery::mock(Expo::class); + $expo->shouldReceive('unsubscribe')->andThrow(\Exception::class); + + /** @var Expo $expo */ + $response = (new ExpoController(new ExpoChannel($expo, new Dispatcher())))->unsubscribe($request); + $response = json_decode($response->content()); + + $this->assertEquals('failed', $response->status); + } + + /** + * Mocks a request for the ExpoController. + * + * @param $data + * + * @return \Mockery\MockInterface + */ + public function mockRequest($data) + { + $request = \Mockery::mock(Request::class); + $request->shouldReceive('all')->andReturn($data); + + return $request; + } + + /** + * @param bool $fails + * + * @return \Mockery\MockInterface + */ + public function mockValidator(bool $fails) + { + $validator = \Mockery::mock(\Illuminate\Validation\Validator::class); + + $validation = \Mockery::mock(Factory::class); + + $validation->shouldReceive('make')->once()->andReturn($validator); + + $validator->shouldReceive('fails')->once()->andReturn($fails); + + Validator::swap($validation); + + return $validator; + } +} + +class User +{ + public function getKey() + { + return 1; + } +} diff --git a/tests/MessageTest.php b/tests/MessageTest.php new file mode 100644 index 00000000..ba9d1213 --- /dev/null +++ b/tests/MessageTest.php @@ -0,0 +1,108 @@ +message = new ExpoMessage(); + } + + /** @test */ + public function itProvidesACreateMethod() + { + $message = ExpoMessage::create('myMessage'); + + $this->assertEquals('myMessage', Arr::get($message->toArray(), 'body')); + } + + /** @test */ + public function itCanAcceptsABodyWhenConstructingAMessage() + { + $message = new ExpoMessage('myMessage'); + + $this->assertEquals('myMessage', Arr::get($message->toArray(), 'body')); + } + + /** @test */ + public function itProvidesACreateMethodThatAcceptsAMessageBody() + { + $message = new ExpoMessage('myMessage'); + + $this->assertEquals('myMessage', Arr::get($message->toArray(), 'body')); + } + + /** @test */ + public function itSetsABodyToTheMessage() + { + $this->message->body('myMessage'); + + $this->assertEquals('myMessage', Arr::get($this->message->toArray(), 'body')); + } + + /** @test */ + public function itSetsADefaultSound() + { + $this->assertEquals('default', Arr::get($this->message->toArray(), 'sound')); + } + + /** @test */ + public function itCanMuteSound() + { + $this->message->disableSound(); + + $this->assertNull(Arr::get($this->message->toArray(), 'sound')); + } + + /** @test */ + public function itCanEnableSound() + { + $this->message->disableSound(); + $this->message->enableSound(); + + $this->assertNotNull(Arr::get($this->message->toArray(), 'sound')); + } + + /** @test */ + public function itCanSetTheBadge() + { + $this->message->badge(5); + + $this->assertEquals(5, Arr::get($this->message->toArray(), 'badge')); + } + + /** @test */ + public function itCanSetTimeToLive() + { + $this->message->setTtl(60); + + $this->assertEquals(60, Arr::get($this->message->toArray(), 'ttl')); + } + + /** @test */ + public function itCanSetChannelId() + { + $this->message->setChannelId('some-channel'); + + $this->assertEquals('some-channel', Arr::get($this->message->toArray(), 'channelId')); + } + + /** @test */ + public function itCanSetJSONData() + { + $this->message->setJsonData('{"name":"Aly"}'); + + $this->assertEquals('{"name":"Aly"}', Arr::get($this->message->toArray(), 'data')); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..a84a49a6 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,92 @@ +set('database.default', 'sqlite'); + + $app['config']->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => $this->getDatabaseDirectory().'/database.sqlite', + 'prefix' => '', + ]); + + $app['config']->set('auth.providers.users.model', User::class); + } + + /** + * Sets up the database. + * + * @return void + */ + protected function setUpDatabase() + { + $this->resetDatabase(); + + $this->createExponentPushNotificationInterestsTable(); + } + + /** + * Drops the database. + * + * @return void + */ + protected function resetDatabase() + { + file_put_contents(__DIR__.'/temp'.'/database.sqlite', null); + } + + /** + * Creates the interests table. + * + * @return void + */ + protected function createExponentPushNotificationInterestsTable() + { + include_once '__DIR__'.'/../migrations/create_exponent_push_notification_interests_table.php.stub'; + + (new \CreateExponentPushNotificationInterestsTable())->up(); + } + + /** + * Gets the directory path for the testing database. + * + * @return string + */ + public function getDatabaseDirectory(): string + { + return __DIR__.'/temp'; + } +} diff --git a/tests/temp/.gitignore b/tests/temp/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/tests/temp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file