diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..2e70379 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + push: + branches: [ "3.x" ] + pull_request: + branches: [ "3.x" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test diff --git a/.gitignore b/.gitignore index 6131d86..0dfc74f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /vendor composer.lock .phpunit.result.cache +.php-cs-fixer.cache testbench/ /coverage .DS_Store \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..0557c53 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,13 @@ +setRules([ + '@PSR12' => true, + '@PHP80Migration' => true, + 'yoda_style' => true, + 'no_unused_imports' => true, + 'ordered_imports' => true, + 'ordered_class_elements' => true, + 'ordered_types' => true, + ]) + ->setFinder((new PhpCsFixer\Finder())->in(__DIR__ . '/src')); \ No newline at end of file diff --git a/changelog.md b/changelog.md index 4776739..29d7dfb 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,27 @@ All notable changes to `laravel Ussd` will be documented in this file. ## [Unreleased] +## [v3.0.0-beta.1] - 2024-01-21 +### Removed +- Removed machine in favor of USSD facade. + +### Changed +- Changed state interface. +- Changed record implementation and public apis. +- Changed config variables + +### Added +- Added `Transition`, `Paginate`, `Truncate` and `Terminate` Attributes. +- Added Custom Exception Handling. +- Added command to create responses, exception handlers and decisions. +- Added decision classes for navigating USSD menus. +- Added testing utility to Ussd Facade. +- Added pagination utility. +- Added resumability of timed-out sessions. +- Added interfaces for decision, exception handler, response, initial state and initial action. +- Added support for dependency injection. +- Added USSD context. + ## [v2.5.0] - 2022-06-19 ### Added - Add configuring USSDs using decorator pattern. @@ -17,13 +38,11 @@ All notable changes to `laravel Ussd` will be documented in this file. - Clean up ## [v2.4.0] - 2022-02-22 - ### Added - Add Laravel 9 support - Add PHP 8.1 support ## [v2.3.1] - 2021-10-15 - ### Fixed - Coding style @@ -50,6 +69,7 @@ All notable changes to `laravel Ussd` will be documented in this file. - Artisan command to create action class - increment method to records - decrement method to records + ### Changed - config file class namespace split to action and state namespace - Updated changelog @@ -82,7 +102,8 @@ All notable changes to `laravel Ussd` will be documented in this file. - Ussd config to allow developers customize behaviour - Ussd service Provider class to allow laravel know how to integrate the package -[Unreleased]: ../../compare/v2.5.0...HEAD +[Unreleased]: ../../compare/v3.0.0-beta.1...HEAD +[v3.0.0-beta.1]: ../../compare/v2.5.0...v3.0.0-beta.1 [v2.5.0]: ../../compare/v2.4.2...v2.5.0 [v2.4.2]: ../../compare/v2.4.1...v2.4.2 [v2.4.1]: ../../compare/v2.4.0...v2.4.1 diff --git a/composer.json b/composer.json index 0213684..570e711 100644 --- a/composer.json +++ b/composer.json @@ -4,21 +4,26 @@ "license": "MIT", "authors": [ { - "name": "Sparors Inc", + "name": "Isaac Sai", "email": "isaacsai030@gmail.com", "homepage": "https://sparors.github.io" + }, + { + "name": "Benjamin Manford", + "email": "benjaminmanford@gmail.com", + "homepage": "https://sparors.github.io" } ], "homepage": "https://github.com/sparors/laravel-ussd", "keywords": ["Laravel", "Ussd"], "require": { - "illuminate/support": "~5|~6|~7|~8|~9|~10" + "php": "^8.0", + "illuminate/support": "~8|~9|~10" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "mockery/mockery": "^1.1", - "orchestra/testbench": "~3|~4|~5|~6|~7|~8", - "sempro/phpunit-pretty-print": "^1.0" + "phpunit/phpunit": "^9.6", + "orchestra/testbench": "~6|~7|~8", + "friendsofphp/php-cs-fixer": "^3.41" }, "autoload": { "psr-4": { @@ -34,10 +39,11 @@ "laravel": { "providers": [ "Sparors\\Ussd\\UssdServiceProvider" - ], - "aliases": { - "Ussd": "Sparors\\Ussd\\Facades\\Ussd" - } + ] } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "format": "vendor/bin/php-cs-fixer fix" } } diff --git a/config/ussd.php b/config/ussd.php index f7c24dd..785d696 100644 --- a/config/ussd.php +++ b/config/ussd.php @@ -4,62 +4,25 @@ /* |-------------------------------------------------------------------------- - | State Class Namespace + | USSD Namespace |-------------------------------------------------------------------------- | - | This value sets the root namespace for Ussd State component classes in - | your application. + | This sets the root namespace for USSD component classes. | */ - 'state_namespace' => env('USSD_STATE_NS', 'App\\Http\\Ussd\\States'), + 'namespace' => env('USSD_NAMESPACE', 'App\Ussd'), - /* - |-------------------------------------------------------------------------- - | Action Class Namespace - |-------------------------------------------------------------------------- - | - | This value sets the root namespace for Ussd Action component classes in - | your application. - | - */ - - 'action_namespace' => env('USSD_ACTION_NS', 'App\\Http\\Ussd\\Actions'), /* |-------------------------------------------------------------------------- - | Store + | Record Store |-------------------------------------------------------------------------- | - | This value sets the default store to use for the ussd record. - | The store can be found in your cache stores config + | This sets the cache store to be used by USSD Record. | */ - 'cache_store' => env('USSD_STORE', null), - - - /* - |-------------------------------------------------------------------------- - | Time to live - |-------------------------------------------------------------------------- - | - | This value sets the default for how long the record values are to - | be cached in your application when not specified. - | - */ - - 'cache_ttl' => env('USSD_TTL', null), - - /* - |-------------------------------------------------------------------------- - | Default value - |-------------------------------------------------------------------------- - | - | This value return the default store value when a given cache key - | is not found - | - */ + 'record_store' => env('USSD_STORE'), - 'cache_default' => env('USSD_DEFAULT_VALUE', null), ]; diff --git a/contributing.md b/contributing.md index 0802c88..c452b78 100644 --- a/contributing.md +++ b/contributing.md @@ -6,7 +6,6 @@ Contributions are accepted via Pull Requests on [Github](https://github.com/spar # Things you could do If you want to contribute but do not know where to start, this list provides some starting points. -- Set up TravisCI - Write a comprehensive ReadMe ## Pull Requests diff --git a/phpunit.xml b/phpunit.xml index 2d770b8..d4553f4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,24 +2,22 @@ + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"> src/ - - ./tests/ + + ./tests/Unit/ + + + ./tests/Integration/ diff --git a/readme.md b/readme.md index 900c14b..e155d5a 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ [![Latest Version on Packagist][ico-version]][link-packagist] [![Total Downloads][ico-downloads]][link-downloads] -[![Build Status][ico-travis]][link-travis] +[![Build Status][ico-github]][link-github] Build Ussd (Unstructured Supplementary Service Data) applications with laravel without breaking a sweat. @@ -11,261 +11,224 @@ Build Ussd (Unstructured Supplementary Service Data) applications with laravel w You can install the package via composer: ``` bash -composer require sparors/laravel-ussd +composer require sparors/laravel-ussd:^3 ``` -Laravel Ussd provides zero configuration out of the box. To publish the config, run the vendor publish command: +For older version use ``` bash -php artisan vendor:publish --provider="Sparors\Ussd\UssdServiceProvider" --tag=ussd-config +composer require sparors/laravel-ussd:^2 ``` -## Usage - -### Creating States - -We provide a ussd artisan command which allows you to quickly create new states. +Laravel Ussd provides zero configuration out of the box. To publish the config, run the vendor publish command: ``` bash -php artisan ussd:state Welcome +php artisan vendor:publish --provider="Sparors\Ussd\UssdServiceProvider" --tag=ussd-config ``` -### Creating Nested States - -Linux/Unix +## Usage -``` bash -php artisan ussd:state Airtime/Welcome -``` +For older version look here: [V2 README](./v2.readme.md) -Windows +### Creating USSD menus -``` bash -php artisan ussd:state Airtime\Welcome -``` - -Welcome state class generated - -``` php +```php line('Banc') + ->listing($this->getItems(), page: $this->currentPage(), perPage: $this->perPage()) + ->when($this->hasPreviousPage(), fn (Menu $menu) => $menu->line('0. Previous')) + ->when($this->hasNextPage(), fn (Menu $menu) => $menu->line('#. Next')) + ->text('Powered by Sparors'); } -} -``` - -### Creating Actions -> Available from **v2.0.0** - -We provide a ussd artisan command which allows you to quickly create new actions. - -``` bash -php artisan ussd:action MakePayment -``` - -MakePayment action class generated - -``` php -input() ? 'deposit' : 'withdraw'; -namespace App\Http\Ussd\Actions; + $record->set('transfer_type', $transferType); + } -use Sparors\Ussd\Action; + public function getItems(): array + { + return [ + 'Transfer', + 'Deposit', + 'Withdraw', + 'New Account', + 'Helpline', + ]; + } -class MakePayment extends Action -{ - public function run(): string + public function perPage(): int { - return ''; // The state after this + return 3; } } ``` -Run your logic and return the next state's fully qualified class name +An example of a final state ``` php $this->record->phoneNumber - ]); - - if ($response->ok()) { - return PaymentSuccess::class; - } - - return PaymentError::class; + return Menu::build()->text('Bye bye ' . $record->get('name')); } } ``` -### Creating Menus - -Add your menu to the beforeRendering method +Due to some limitation with PHP 8.0, you can not pass class instance to attributes. So to overcome this limitation, you can pass an array with the full class path as the first element and the rest should be argument required. eg. ``` php record->name; - - $this->menu->text('Welcome To Laravel USSD') - ->lineBreak(2) - ->line('Select an option') - ->listing([ - 'Airtime Topup', - 'Data Bundle', - 'TV Subscription', - 'ECG/GWCL', - 'Talk To Us' - ]) - ->lineBreak(2) - ->text('Powered by Sparors'); - } - - protected function afterRendering(string $argument): void - { - // + return Menu::build()->text('Welcome'); } } ``` -### Linking States with Decisions - -Add your decision to the afterRendering method and link them with states +### Building USSD -``` php +```php menu->text('Welcome To Laravel Ussd') - ->lineBreak(2) - ->line('Select an option') - ->listing([ - 'Airtime Topup', - 'Data Bundle', - 'TV Subscription', - 'ECG/GWCL', - 'Talk To Us' - ]) - ->lineBreak(2) - ->text('Powered by Sparors'); - } + $lastText = $request->input('text') ?? ''; - protected function afterRendering(string $argument): void - { - // If input is equal to 1, 2, 3, 4 or 5, render the appropriate state - $this->decision->equal('1', GetRecipientNumber::class) - ->between(2, 5, MaintenanceMode::class) - ->any(Error::class); + if (strlen($lastText) > 0) { + $lastText = explode('*', $lastText); + $lastText = end($lastText); + } + + return Ussd::build( + Context::create( + $request->input('sessionId'), + $request->input('phoneNumber'), + $lastText + ) + ->with(['phone_number' => $request->input('phoneNumber')]) + ) + ->useInitialState(MenuAction::class) + ->useContinuingState(ContinuingMode::CONFIRM, now()->addMinute(), WouldYouLikeToContinueState::class) + ->useResponse(AfricasTalkingResponse::class) + ->run(); } } ``` -### Setting Initial State +### Conditional Branching -Import the welcome state class and pass it to the setInitialState method +Use USSD action to conditional decide which state should be the next. ``` php setFromRequest([ - 'network', - 'phone_number' => 'msisdn', - 'sessionId' => 'UserSessionID', - 'input' => 'msg' - ]) - ->setInitialState(Welcome::class) - ->setResponse(function (string $message, string $action) { - return [ - 'USSDResp' => [ - 'action' => $action, - 'menus' => '', - 'title' => $message - ] - ]; - }); - - return response()->json($ussd->run()); + $response = Http::post('/payment', [ + 'phone_number' => $record->phoneNumber + ]); + + if ($response->ok()) { + return PaymentSuccessState::class; + } + + return PaymentErrorState::class; } } ``` -### Simplifying machine with configurator +### Group logic with USSD configurator -> Available from **v2.5.0** +You can use configurator to simplify repetitive parts of your application so they can be shared easily. -You can use configurator to simplify repetitive parts of your application so they can be shared easily. Just implement and `Sparors\Ussd\Contracts\Configurator` interface and use it in your machine. ```php setFromRequest([ - 'network', - 'phone_number' => 'msisdn', - 'sessionId' => 'UserSessionID', - 'input' => 'msg' - ])->setResponse(function (string $message, string $action) { + $ussd->setResponse(function (string $message, int $terminating) { return [ 'USSDResp' => [ - 'action' => $action, + 'action' => $termination ? 'prompt': 'input', 'menus' => '', 'title' => $message ] @@ -275,80 +238,68 @@ class Nsano implements Configurator } ?> ``` -```php + +### Testing + +You can easily test how your ussd application with our testing utilities + +``` php useConfigurator(Nsano::class) - ->setInitialState(Welcome::class); - - return response()->json($ussd->run()); + Ussd::test(WelcomeState::class) + ->additional(['network' => 'MTN', 'phone_number' => '123123123']) + ->actingAs('isaac') + ->start() + ->assertSee('Welcome...') + ->assertContextHas('network', 'MTN') + ->assertContextHas('phone_number') + ->assertContextMissing('name') + ->input('1') + ->assertSee('Now see the magic...') + ->assertRecordHas('choice'); } } -?> -``` - - -### Running the application - -You can use the development server the ships with Laravel by running, from the project root: - -``` bash -php artisan serve ``` -You can visit [http://localhost:8000](http://localhost:8000) to see the application in action. - -Enjoy!!! -### Documentation +## Documentation -You'll find the documentation on [https://sparors.github.io/ussd-docs](https://sparors.github.io/ussd-docs/). - - -### Testing - -``` bash -$ vendor/bin/phpunit -``` +You'll find the documentation on [https://github.com/sparors/laravel-ussd/wiki](https://github.com/sparors/laravel-ussd/wiki) for V3 and [https://sparors.github.io/ussd-docs](https://sparors.github.io/ussd-docs/) for V2. -### Change log +## Change log Please see the [changelog](changelog.md) for more information on what has changed recently. -### Contributing +## Contributing Please see [contributing.md](contributing.md) for details and a todolist. -### Security +## Security If you discover any security related issues, please email isaacsai030@gmail.com instead of using the issue tracker. -### Credits +## Credits - [Sparors Inc][link-author] - [All Contributors][link-contributors] -### License +## License MIT. Please see the [license file](LICENSE) for more information. [ico-version]: https://img.shields.io/packagist/v/sparors/laravel-ussd.svg?style=flat-square [ico-downloads]: https://img.shields.io/packagist/dt/sparors/laravel-ussd.svg?style=flat-square -[ico-travis]: https://img.shields.io/travis/sparors/laravel-ussd/master.svg?style=flat-square +[ico-github]: https://img.shields.io/github/actions/workflow/status/sparors/laravel-ussd/php.yml?style=flat-square [link-packagist]: https://packagist.org/packages/sparors/laravel-ussd [link-downloads]: https://packagist.org/packages/sparors/laravel-ussd -[link-travis]: https://travis-ci.com/sparors/laravel-ussd +[link-github]: https://github.com/sparors/laravel-ussd/actions/workflows/php.yml [link-author]: https://github.com/sparors [link-contributors]: ../../contributors diff --git a/src/Action.php b/src/Action.php deleted file mode 100644 index 32619b1..0000000 --- a/src/Action.php +++ /dev/null @@ -1,22 +0,0 @@ -record = $record; - - return $this; - } -} diff --git a/src/Attributes/Paginate.php b/src/Attributes/Paginate.php new file mode 100644 index 0000000..3bdc124 --- /dev/null +++ b/src/Attributes/Paginate.php @@ -0,0 +1,17 @@ +argument('name'); + protected $signature = 'ussd:action + {name : The name of the USSD Action} + {--init : Create the class as the initial USSD action} + {--force : Create the class even if USSD action already exists}'; - if (! File::exists($this->pathFromNamespace($namespace, $name))) { - $content = preg_replace_array( - ['/\[namespace\]/', '/\[class\]/'], - [$this->classNamespace($namespace, $name), $this->className($name)], - file_get_contents(__DIR__.'/action.stub') - ); + protected function getStub() + { + if ($this->option('init')) { + return __DIR__.'/../../stubs/action.init.stub'; + } - $this->ensureDirectoryExists($namespace, $name); - File::put($this->pathFromNamespace($namespace, $name), $content); + return __DIR__.'/../../stubs/action.stub'; + } - $this->info($this->className($name).' action created successfully'); - } else { - $this->error('File already exists !'); - } + protected function getDefaultNamespace($rootNamespace) + { + return $this->extendNamespace('Actions'); } } diff --git a/src/Commands/ActionMakeCommand.php b/src/Commands/ActionMakeCommand.php new file mode 100644 index 0000000..0ad3db5 --- /dev/null +++ b/src/Commands/ActionMakeCommand.php @@ -0,0 +1,11 @@ +extendNamespace('Configurators'); + } +} diff --git a/src/Commands/ConfiguratorMakeCommand.php b/src/Commands/ConfiguratorMakeCommand.php new file mode 100644 index 0000000..219b14d --- /dev/null +++ b/src/Commands/ConfiguratorMakeCommand.php @@ -0,0 +1,10 @@ +extendNamespace('Decisions'); + } +} diff --git a/src/Commands/DecisionMakeCommand.php b/src/Commands/DecisionMakeCommand.php new file mode 100644 index 0000000..4672247 --- /dev/null +++ b/src/Commands/DecisionMakeCommand.php @@ -0,0 +1,10 @@ +extendNamespace(''); + } +} diff --git a/src/Commands/ExceptionHandlerMakeCommand.php b/src/Commands/ExceptionHandlerMakeCommand.php new file mode 100644 index 0000000..2ed041f --- /dev/null +++ b/src/Commands/ExceptionHandlerMakeCommand.php @@ -0,0 +1,10 @@ +getNamespace(), '', $namespace); - $path = $base_path.DIRECTORY_SEPARATOR.$extended_path.'.php'; - - return app('path').'/'.str_replace('\\', '/', $path); - } - - protected function classNamespace($namespace, $relativePath) - { - $path = array_map(function ($value) { - return ucfirst($value); - }, explode(DIRECTORY_SEPARATOR, $relativePath)); - - array_pop($path); - - return rtrim($namespace.'\\'.implode('\\', $path), '\\'); - } - - protected function className($relativePath) - { - $path = explode(DIRECTORY_SEPARATOR, $relativePath); - - return ucfirst(array_pop($path)); - } - - protected function ensureDirectoryExists($namespace, $relativePath) - { - $path = $this->pathFromNamespace($namespace, $relativePath); - - if (! File::isDirectory(dirname($path))) { - File::makeDirectory(dirname($path), 0777, $recursive = true, $force = true); - } - } -} diff --git a/src/Commands/GeneratorCommand.php b/src/Commands/GeneratorCommand.php new file mode 100644 index 0000000..07fab6e --- /dev/null +++ b/src/Commands/GeneratorCommand.php @@ -0,0 +1,15 @@ +extendNamespace('Responses'); + } +} diff --git a/src/Commands/ResponseMakeCommand.php b/src/Commands/ResponseMakeCommand.php new file mode 100644 index 0000000..354784a --- /dev/null +++ b/src/Commands/ResponseMakeCommand.php @@ -0,0 +1,10 @@ +argument('name'); - - if (! File::exists($this->pathFromNamespace($namespace, $name))) { - $content = preg_replace_array( - ['/\[namespace\]/', '/\[class\]/'], - [$this->classNamespace($namespace, $name), $this->className($name)], - file_get_contents(__DIR__.'/state.stub') - ); - - $this->ensureDirectoryExists($namespace, $name); - File::put($this->pathFromNamespace($namespace, $name), $content); - - $this->info($this->className($name).' state created successfully'); - } else { - $this->error('File already exists !'); + if ($this->option('init')) { + return __DIR__.'/../../stubs/state.init.stub'; + } + + if ($this->option('cont')) { + return __DIR__.'/../../stubs/state.cont.stub'; } + + return __DIR__.'/../../stubs/state.stub'; + } + + protected function getDefaultNamespace($rootNamespace) + { + return $this->extendNamespace('States'); } } diff --git a/src/Commands/StateMakeCommand.php b/src/Commands/StateMakeCommand.php new file mode 100644 index 0000000..614bdba --- /dev/null +++ b/src/Commands/StateMakeCommand.php @@ -0,0 +1,12 @@ +bag = []; + } + + public static function create(string $uid, string $sid, string $input): static + { + return new static($uid, $sid, $input); + } + + public function with(array $parameters): static + { + $this->bag = $parameters; + + return $this; + } + + public function uid(): string + { + return $this->uid; + } + + public function gid(): string + { + return $this->gid; + } + + public function input(): string + { + return $this->input; + } + + public function get(string $key): mixed + { + return $this->bag[$key] ?? null; + } +} diff --git a/src/ContinuingMode.php b/src/ContinuingMode.php new file mode 100644 index 0000000..404c565 --- /dev/null +++ b/src/ContinuingMode.php @@ -0,0 +1,10 @@ +decided = false; - $this->argument = $argument; - $this->output = null; - } - - protected function guardAgainstReDeciding(): bool - { - return !$this->decided; - } - - protected function setOutput($output): void - { - $this->output = $output; - $this->decided = true; - } - - protected function setOutputForCondition($condition, $output): self - { - if ($this->guardAgainstReDeciding()) { - if ($condition()) { - $this->setOutput($output); - } - } - - return $this; - } - - public function outcome(): ?string - { - return $this->output; - } - - public function equal($argument, string $output, bool $strict = false): self - { - return $this->setOutputForCondition( - function () use ($argument, $strict) { - if ($strict) { - return $argument === $this->argument; - } - return $argument == $this->argument; - }, - $output - ); - } - - public function numeric(string $output): self - { - return $this->setOutputForCondition( - function () { - return is_numeric($this->argument); - }, - $output - ); - } - - public function integer(string $output): self - { - return $this->setOutputForCondition( - function () { - return is_integer($this->argument); - }, - $output - ); - } - - public function amount(string $output): self - { - return $this->setOutputForCondition( - function () { - return preg_match( - "/^[0-9]+(?:\.[0-9]{1,2})?$/", - $this->argument - ); - }, - $output - ); - } - - public function length($argument, string $output): self - { - return $this->setOutputForCondition( - function () use ($argument) { - return strlen($this->argument) === $argument; - }, - $output - ); - } - - public function phoneNumber(string $output): self - { - return $this->setOutputForCondition( - function () { - return preg_match("/^[0][0-9]{9}$/", $this->argument); - }, - $output - ); - } - - public function between(int $start, int $end, string $output): self - { - return $this->setOutputForCondition( - function () use ($start, $end) { - return $this->argument >= $start && $this->argument <= $end; - }, - $output - ); - } - - public function in(array $array, string $output, bool $strict = false): self - { - return $this->setOutputForCondition( - function () use ($array, $strict) { - return in_array($this->argument, $array, $strict); - }, - $output - ); - } - - public function custom(callable $function, string $output): self - { - $func = function () use ($function) { - return $function($this->argument); - }; - - return $this->setOutputForCondition($func, $output); - } - - public function any(string $output): self - { - return $this->setOutputForCondition( - function () { - return true; - }, - $output - ); - } -} diff --git a/src/Decisions/Between.php b/src/Decisions/Between.php new file mode 100644 index 0000000..47dd764 --- /dev/null +++ b/src/Decisions/Between.php @@ -0,0 +1,19 @@ += $this->start && $actual <= $this->end; + } +} diff --git a/src/Decisions/Equal.php b/src/Decisions/Equal.php new file mode 100644 index 0000000..1a2664a --- /dev/null +++ b/src/Decisions/Equal.php @@ -0,0 +1,18 @@ +expected; + } +} diff --git a/src/Decisions/Fallback.php b/src/Decisions/Fallback.php new file mode 100644 index 0000000..7753a72 --- /dev/null +++ b/src/Decisions/Fallback.php @@ -0,0 +1,13 @@ + $this->expected; + } +} diff --git a/src/Decisions/GreaterThanOrEqualTo.php b/src/Decisions/GreaterThanOrEqualTo.php new file mode 100644 index 0000000..c467ed8 --- /dev/null +++ b/src/Decisions/GreaterThanOrEqualTo.php @@ -0,0 +1,18 @@ += $this->expected; + } +} diff --git a/src/Decisions/In.php b/src/Decisions/In.php new file mode 100644 index 0000000..e699f3b --- /dev/null +++ b/src/Decisions/In.php @@ -0,0 +1,20 @@ +values = $values; + } + + public function decide(string $actual): bool + { + return in_array($actual, $this->values); + } +} diff --git a/src/Decisions/IsNumeric.php b/src/Decisions/IsNumeric.php new file mode 100644 index 0000000..b290d49 --- /dev/null +++ b/src/Decisions/IsNumeric.php @@ -0,0 +1,13 @@ +length; + } +} diff --git a/src/Decisions/LessThan.php b/src/Decisions/LessThan.php new file mode 100644 index 0000000..49aa197 --- /dev/null +++ b/src/Decisions/LessThan.php @@ -0,0 +1,18 @@ +expected; + } +} diff --git a/src/Decisions/LessThanOrEqualTo.php b/src/Decisions/LessThanOrEqualTo.php new file mode 100644 index 0000000..6c2fe78 --- /dev/null +++ b/src/Decisions/LessThanOrEqualTo.php @@ -0,0 +1,18 @@ +expected; + } +} diff --git a/src/Decisions/NotBetween.php b/src/Decisions/NotBetween.php new file mode 100644 index 0000000..dc1e648 --- /dev/null +++ b/src/Decisions/NotBetween.php @@ -0,0 +1,19 @@ +start || $actual > $this->end; + } +} diff --git a/src/Decisions/NotEqual.php b/src/Decisions/NotEqual.php new file mode 100644 index 0000000..f36a639 --- /dev/null +++ b/src/Decisions/NotEqual.php @@ -0,0 +1,18 @@ +expected; + } +} diff --git a/src/Decisions/NotIn.php b/src/Decisions/NotIn.php new file mode 100644 index 0000000..da9e2e1 --- /dev/null +++ b/src/Decisions/NotIn.php @@ -0,0 +1,20 @@ +values = $values; + } + + public function decide(string $actual): bool + { + return !in_array($actual, $this->values); + } +} diff --git a/src/Decisions/Regex.php b/src/Decisions/Regex.php new file mode 100644 index 0000000..35a4136 --- /dev/null +++ b/src/Decisions/Regex.php @@ -0,0 +1,18 @@ +pattern, $actual); + } +} diff --git a/src/Exceptions/ActiveStateNotFoundException.php b/src/Exceptions/ActiveStateNotFoundException.php new file mode 100644 index 0000000..a311f75 --- /dev/null +++ b/src/Exceptions/ActiveStateNotFoundException.php @@ -0,0 +1,11 @@ +sessionId = $sessionId; - - return $this; - } - - public function setSessionIdFromRequest(string $key) - { - $this->sessionId = request($key); - - return $this; - } - - public function setPhoneNumber(?string $phoneNumber) - { - $this->phoneNumber = $phoneNumber; - - return $this; - } - - public function setPhoneNumberFromRequest(string $key) - { - $this->phoneNumber = request($key); - - return $this; - } - - public function setNetwork(?string $network) - { - $this->network = $network; - - return $this; - } - - public function setNetworkFromRequest(string $key) - { - $this->network = request($key); - return $this; - } - - public function setInput(?string $input) - { - $this->input = $input; - - return $this; - } - - public function setInputFromRequest(string $key) - { - $this->input = request($key); - - return $this; - } - - public function setStore(?string $store) - { - $this->store = $store; - - return $this; - } - - public function set(array $parameters) - { - foreach ($parameters as $property => $value) { - $property = Str::camel($property); - if (property_exists($this, $property)) { - $this->$property = $value; - } - } - - return $this; - } - - public function setFromRequest(array $parameters) - { - foreach ($parameters as $property => $key) { - $property = Str::camel($property); - if (property_exists($this, $property)) { - $this->$property = request($key); - } elseif (property_exists($this, Str::camel($key))) { - $this->{Str::camel($key)} = request($key); - } - } - - return $this; - } - - public function setInitialState($state) - { - if (is_object($state) && (!$state instanceof Closure)) { - $this->initialState = get_class($state); - } elseif (is_string($state) && class_exists($state)) { - $this->initialState = $state; - } elseif (is_callable($state)) { - $this->initialState = $state; - } else { - $this->initialState = null; - } - - return $this; - } - - public function setResponse(callable $response) - { - $this->response = $response; - - return $this; - } -} diff --git a/src/Machine.php b/src/Machine.php deleted file mode 100644 index 3fbb364..0000000 --- a/src/Machine.php +++ /dev/null @@ -1,192 +0,0 @@ -sessionId = null; - $this->phoneNumber = null; - $this->network = null; - $this->input = null; - $this->store = config('ussd.cache_store', null); - $this->response = function (string $message, string $action) { - return [ - 'message' => $message, - 'action' => $action, - ]; - }; - } - - public function run() - { - $this->ensureSessionIdIsSet($this->sessionId); - - $this->record = new Record( - Cache::store($this->store), - $this->sessionId - ); - - $this->saveParameters(); - - if ($this->record->has('__init')) { - $active = $this->record->get('__active'); - - $this->ensureClassExist( - $active, - 'Active State Class needs to be set before ussd machine can ' - . 'run. It may be that your session has ended.' - ); - - $activeClass = new $active(); - $activeClass->setRecord($this->record); - - $state = $activeClass->next($this->input); - - $this->processAction( - $stateClass, - $state, - 'Continuing State Class needs to be set before ussd ' - . 'machine can run. It may be that your session has ended.' - ); - - $this->record->set('__active', $state); - } else { - $this->processInitialState(); - - $state = $this->initialState; - - $this->processAction( - $stateClass, - $state, - 'Initial State Class needs to be set before ' - . 'ussd machine can run.' - ); - - $this->record->set('__active', $state); - $this->record->set('__init', true); - } - - return ($this->response)($stateClass->render(), $stateClass->getAction()); - } - - /** @param Configurator|string $configurator */ - public function useConfigurator($configurator): self - { - if (is_string($configurator) && class_exists($configurator)) { - $configurator = new $configurator(); - } - - throw_if( - !$configurator instanceof Configurator, - Exception::class, - "configurator does not implement Sparors\Ussd\Contracts\Configurator interface." - ); - - $configurator->configure($this); - - return $this; - } - - protected function saveParameter(string $key, $value) - { - if (!is_null($value)) { - $this->record->set($key, $value); - } - } - - protected function saveParameters() - { - $this->saveParameter('sessionId', $this->sessionId); - $this->saveParameter('phoneNumber', $this->phoneNumber); - $this->saveParameter('network', $this->network); - $this->saveParameter('input', $this->input); - } - - /** - * @throws Exception - */ - protected function ensureClassExist(?string $class, string $message): void - { - throw_if( - !class_exists($class), - Exception::class, - $message - ); - } - - /** - * @throws Exception - */ - protected function ensureSessionIdIsSet(?string $session): void - { - throw_if( - is_null($session), - Exception::class, - 'SessionId needs to be set before ussd machine can run.' - ); - } - - - protected function processInitialState(): void - { - if (is_callable($this->initialState)) { - $this->initialState = ($this->initialState)(); - } - } - - protected function processAction(&$stateClass, &$state, $exception): void - { - $this->ensureClassExist( - $state, - $exception - ); - - $stateClass = new $state(); - $stateClass->setRecord($this->record); - - if (is_subclass_of($stateClass, Action::class)) { - $state = $stateClass->run(); - - $this->ensureClassExist( - $state, - 'Ussd Action Class needs to return next State Class' - ); - - $stateClass = new $state(); - $stateClass->setRecord($this->record); - } - } -} diff --git a/src/Menu.php b/src/Menu.php index a1a2e74..56e9062 100644 --- a/src/Menu.php +++ b/src/Menu.php @@ -2,156 +2,53 @@ namespace Sparors\Ussd; -class Menu -{ - public const NUMBERING_ALPHABETIC_LOWER = 'alphabetic_lower'; - public const NUMBERING_ALPHABETIC_UPPER = 'alphabetic_upper'; - public const NUMBERING_EMPTY = 'empty'; - public const NUMBERING_NUMERIC = 'numeric'; - - public const ITEMS_SEPARATOR_NO_LINE_BREAK = ""; - public const ITEMS_SEPARATOR_LINE_BREAK = PHP_EOL; - public const ITEMS_SEPARATOR_DOUBLE_LINE_BREAK = PHP_EOL.PHP_EOL; +use Sparors\Ussd\Traits\Conditionable; +use Sparors\Ussd\Traits\MenuBuilder; +use Stringable; - public const NUMBERING_SEPARATOR_NO_SPACE = ""; - public const NUMBERING_SEPARATOR_SPACE = " "; - public const NUMBERING_SEPARATOR_DOUBLE_SPACE = " "; - public const NUMBERING_SEPARATOR_DOT = "."; - public const NUMBERING_SEPARATOR_DOT_PLUS_SPACE = ". "; - public const NUMBERING_SEPARATOR_DOT_PLUS_DOUBLE_SPACE = ". "; - public const NUMBERING_SEPARATOR_BRACKET = ")"; - public const NUMBERING_SEPARATOR_BRACKET_PLUS_SPACE = ") "; - public const NUMBERING_SEPARATOR_BRACKET_PLUS_DOUBLE_SPACE = ") "; - - /** @var string */ - protected $menu; +class Menu implements Stringable +{ + use Conditionable; + use MenuBuilder; - public function __construct($menu = '') - { - $this->menu = $menu; + public function __construct( + private string $content + ) { } - protected function numberingFor(int $index, string $numbering): string + public function __toString(): string { - if ($numbering === self::NUMBERING_ALPHABETIC_LOWER) { - return range('a', 'z')[$index]; - } - if ($numbering === self::NUMBERING_ALPHABETIC_UPPER) { - return range('A', 'Z')[$index]; - } - if ($numbering === self::NUMBERING_NUMERIC) { - return (string) $index + 1; - } - return ''; + return $this->content; } - protected function isLastPage( - int $page, - int $numberPerPage, - array $items - ): bool { - return $page * $numberPerPage >= count($items); - } - - - protected function pageStartIndex(int $page, int $numberPerPage): int + public static function build(): static { - return $page * $numberPerPage - $numberPerPage; + return new static(''); } - protected function pageLimit(int $page, int $numberPerPage, array $items): int + public function append(callable|self $menu): static { - return ( - $this->isLastPage($page, $numberPerPage, $items) - ? count($items) - $this->pageStartIndex($page, $numberPerPage) - : $numberPerPage - ); - } + if (is_callable($menu)) { + $menu($append = new static('')); - private function listParser( - array $items, - int $page, - int $numberPerPage, - string $numberingSeparator, - string $itemsSeparator, - string $numbering - ): void { - $startIndex = $this->pageStartIndex($page, $numberPerPage); - $limit = $this->pageLimit($page, $numberPerPage, $items); - for ($i = 0; $i < $limit; $i++) { - $this->menu .= "{$this->numberingFor($i + $startIndex, $numbering)}{$numberingSeparator}{$items[$i + $startIndex]}"; - if ($i !== $limit - 1) { - $this->menu .= $itemsSeparator; - } + $menu = $append; } - } - - public function lineBreak(int $number = 1): self - { - $this->menu .= str_repeat(PHP_EOL, $number); - - return $this; - } - public function line(string $text): self - { - $this->menu .= "$text".PHP_EOL; + $this->content .= (string) $menu; return $this; } - public function text(string $text): self + public function prepend(callable|self $menu): static { - $this->menu .= $text; - - return $this; - } + if (is_callable($menu)) { + $menu($append = new static('')); - public function listing( - array $items, - string $numberingSeparator = self::NUMBERING_SEPARATOR_DOT, - string $itemsSeparator = self::ITEMS_SEPARATOR_LINE_BREAK, - string $numbering = self::NUMBERING_NUMERIC - ): self { - $this->listParser( - $items, - 1, - count($items), - $numberingSeparator, - $itemsSeparator, - $numbering - ); - - return $this; - } + $menu = $append; + } - public function paginateListing( - array $items, - int $page = 1, - int $numberPerPage = 5, - string $numberingSeparator = self::NUMBERING_SEPARATOR_DOT, - string $itemsSeparator = self::ITEMS_SEPARATOR_LINE_BREAK, - string $numbering = self::NUMBERING_NUMERIC - ): self { - $this->listParser( - $items, - $page, - $numberPerPage, - $numberingSeparator, - $itemsSeparator, - $numbering - ); + $this->content = (string) $menu . $this->content; return $this; } - - public function toString(): string - { - return $this->menu; - } - - public function __toString() - { - return $this->menu; - } } diff --git a/src/Record.php b/src/Record.php index 09039ee..fc843b5 100644 --- a/src/Record.php +++ b/src/Record.php @@ -2,227 +2,121 @@ namespace Sparors\Ussd; -use Illuminate\Contracts\Cache\Repository as Cache; +use DateInterval; +use DateTimeInterface; +use Illuminate\Contracts\Cache\Repository; +use Illuminate\Support\Facades\Cache; class Record { - /** @var Cache */ - protected $cache; + private Repository $repository; - /** @var string */ - protected $id; - - public function __construct(Cache $cache, $id) - { - $this->cache = $cache; - $this->id = $id; + public function __construct( + ?string $store, + private string $uid, + private string $gid + ) { + $this->repository = Cache::store($store); } - /** - * @param string $key - * @return string - */ - protected function getKey($key) + public function __set(string $name, mixed $value): void { - return "ussd_$this->id.$key"; + $this->set($name, $value); } - /** - * @param int $ttl - * @return \DateTimeInterface|\DateInterval|int|null - */ - protected function getTtl($ttl) + public function __get(string $name): mixed { - return $ttl ?? config('ussd.cache_ttl'); + return $this->get($name); } - /** - * @param string $default - * @return mixed - */ - protected function getDefault($default) + public function __isset(string $name): bool { - return $default ?? config('ussd.cache_default'); + return $this->has($name); } - /** - * @param array $keys - * @return array - */ - protected function getKeys($keys) + public function __unset(string $name): void { - return array_map( - function ($key) { - return $this->getKey($key); - }, - $keys - ); + $this->forget($name); } - /** - * @param array $values - * @return array - */ - protected function getValues($values) + public function __invoke(array|string $argument): mixed { - $newValues = []; - foreach ($values as $key => $value) { - $newValues[$this->getKey($key)] = $value; + if (is_string($argument)) { + return $this->get($argument); } - return $newValues; + if (is_array($argument)) { + return $this->setMany($argument); + } } - /** - * Determine if an item exists in the cache. - * - * @param string $key - * @return bool - */ - public function has($key) - { - return $this->cache->has($this->getKey($key)); - } - - /** - * Store an item in the record. - * - * @param string $key - * @param mixed $value - * @param \DateTimeInterface|\DateInterval|int|null $ttl - * @return bool - */ - public function set($key, $value, $ttl = null) + public function has(string $key, bool $public = false): bool { - return $this->cache->set($this->getKey($key), $value, $this->getTtl($ttl)); + return $this->repository->has($this->id($key, $public)); } - /** - * Store multiple items in the cache for a given number of seconds. - * - * @param array $values - * @param \DateTimeInterface|\DateInterval|int|null $ttl - * @return bool - */ - public function setMultiple($values, $ttl = null) - { - return $this->cache->setMultiple($this->getValues($values), $this->getTtl($ttl)); + public function set( + string $key, + mixed $value, + null|DateInterval|DateTimeInterface|int $ttl = null, + bool $public = false + ): bool { + return $this->repository->set($this->id($key, $public), $value, $ttl); } - /** - * Retrieve an item from the cache by key. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function get($key, $default = null) - { - return $this->cache->get($this->getKey($key), $this->getDefault($default)); - } - - /** - * Retrieve multiple items from the cache by key. - * - * Items not found in the cache will have a null value. - * - * @param array $keys - * @param string $default - * @return array - */ - public function getMultiple($keys, $default = null) - { - return array_values( - (array) $this->cache->getMultiple($this->getKeys($keys), $this->getDefault($default)) - ); + public function setMany( + array $values, + null|DateInterval|DateTimeInterface|int $ttl = null, + bool $public = false + ): bool { + $newValues = []; + + foreach ($values as $key => $value) { + $newValues[$this->id($key, $public)] = $value; + } + + return $this->repository->setMultiple($newValues, $ttl); } - /** - * Remove an item from the cache. - * - * @param string $key - * @return bool - */ - public function delete($key) + public function get(string $key, mixed $default = null, bool $public = false): mixed { - return $this->cache->delete($this->getKey($key)); + return $this->repository->get($this->id($key, $public), $default); } - /** - * Remove an item from the cache - * - * @param string $key - * @return bool - */ - public function deleteMultiple($keys) - { - return $this->cache->deleteMultiple($this->getKeys($keys)); - } - - /** - * Increment the value of an item in the cache. - * - * @since v2.0.0 - * @param string $key - * @param mixed $value - * @return int|bool - */ - public function increment($key, $value = 1) + public function getMany(array $keys, mixed $default = null, bool $public = false): array { - return $this->cache->increment($this->getKey($key), $value); - } - - /** - * Decrement the value of an item in the cache. - * - * @since v2.0.0 - * @param string $key - * @param mixed $value - * @return int|bool - */ - public function decrement($key, $value = 1) - { - return $this->cache->decrement($this->getKey($key), $value); + return array_values( + (array) $this->repository->getMultiple($this->ids($keys, $public), $default) + ); } - /** - * Remove all items from the cache. - * - * @return bool - */ - public function flush() + public function increment(string $key, mixed $value = 1, bool $public = false): bool|int { - return $this->cache->clear(); + return $this->repository->increment($this->id($key, $public), $value); } - public function __set($name, $value) + public function decrement(string $key, mixed $value = 1, bool $public = false): bool|int { - $this->set($name, $value, config('ussd.cache_ttl')); + return $this->repository->decrement($this->id($key, $public), $value); } - public function __get($name) + public function forget(string $key, bool $public = false): bool { - return $this->get($name, config('ussd.cache_default')); + return $this->repository->delete($this->id($key, $public)); } - public function __isset($name) + public function forgetMany(array $keys, bool $public = false): bool { - return $this->has($name); + return $this->repository->deleteMultiple($this->ids($keys, $public)); } - public function __unset($name) + private function id(string $key, bool $public): string { - return $this->delete($name); + return 'ussd:'.($public ? $this->gid : $this->uid).":{$key}"; } - public function __invoke($argument) + private function ids(array $keys, bool $public): array { - if (is_string($argument)) { - return $this->get($argument, config('ussd.cache_default')); - } - - if (is_array($argument)) { - $this->setMultiple($argument, config('ussd.cache_ttl')); - } + return array_map(fn ($key) => $this->id($key, $public), $keys); } } diff --git a/src/State.php b/src/State.php deleted file mode 100644 index 11838d2..0000000 --- a/src/State.php +++ /dev/null @@ -1,76 +0,0 @@ -menu = new Menu(); - $this->beforeRendering(); - - return $this->menu->toString(); - } - - /** - * The function to run after the rendering - * - * @param string $argument - */ - abstract protected function afterRendering(string $argument): void; - - /** - * The new State full path - */ - public function next(?string $input): ?string - { - $this->decision = new Decision($input); - $this->afterRendering($input); - - return $this->decision->outcome(); - } - - /** - * @return string - */ - public function getAction() - { - return $this->action; - } - - /** - * @param Record $record - */ - public function setRecord(Record $record) - { - $this->record = $record; - } -} diff --git a/src/Tests/PendingTest.php b/src/Tests/PendingTest.php new file mode 100644 index 0000000..80f5ccb --- /dev/null +++ b/src/Tests/PendingTest.php @@ -0,0 +1,78 @@ +uses = []; + $this->additional = []; + $this->storeName = null; + $this->actor = Str::random(8); + } + + public function additional(array $additional): static + { + $this->additional = $additional; + + return $this; + } + + public function use(Closure|Configurator|ExceptionHandler|Response|string $use): static + { + $this->uses[] = $use; + + return $this; + } + + public function useStore(string $storeName): static + { + $this->storeName = $storeName; + + return $this; + } + + public function actingAs(string $key): static + { + $this->actor = $key; + + return $this; + } + + public function start(string $input = ''): Testing + { + return new Testing( + $this->initialState, + $this->continuingMode, + $this->continuingTtl, + $this->continuingState, + $this->storeName, + $this->additional, + $this->uses, + $this->actor, + $input + ); + } +} diff --git a/src/Tests/Testing.php b/src/Tests/Testing.php new file mode 100644 index 0000000..1067cb6 --- /dev/null +++ b/src/Tests/Testing.php @@ -0,0 +1,247 @@ +actors[$actor] = [Str::random(), Str::random(), '']; + $this->dispatch($startInput); + } + + public function assertContextHas(string $key, $value = null): static + { + $this->preventStaleAssertion(); + + $item = App::get(Context::class)->get($key); + + if (is_null($value)) { + Assert::assertTrue( + isset($item), + "Context is missing expected key [{$key}]." + ); + } elseif ($value instanceof Closure) { + Assert::assertTrue($value($item)); + } else { + Assert::assertEquals($value, $item); + } + + return $this; + } + + public function assertContextMissing(string $key): static + { + $this->preventStaleAssertion(); + + $item = App::get(Context::class)->get($key); + + Assert::assertFalse( + isset($item), + "Context has unexpected key [{$key}]." + ); + + return $this; + } + + public function assertRecordHas(string $key, $value = null): static + { + $this->preventStaleAssertion(); + + $item = App::get(Record::class)->get($key); + + if (is_null($value)) { + Assert::assertTrue( + isset($item), + "Record is missing expected key [{$key}]." + ); + } elseif ($value instanceof Closure) { + Assert::assertTrue($value($item)); + } else { + Assert::assertEquals($value, $item); + } + + return $this; + } + + public function assertRecordMissing(string $key): static + { + $this->preventStaleAssertion(); + + $item = App::get(Record::class)->get($key); + + Assert::assertFalse( + isset($item), + "Record has unexpected key [{$key}]." + ); + + return $this; + } + + public function assertSee(string $value): static + { + $item = $this->actors[$this->actor][2]; + + if (is_object($item) || is_array($item) || $item instanceof JsonSerializable) { + $item = json_encode($item); + } + + if ($item instanceof HttpResponse && is_string($item->getContent())) { + $item = $item->getContent(); + } + + if (is_string($item)) { + Assert::assertStringContainsString($value, $item); + } else { + Assert::fail('Result for ussd could not be converted to string.'); + } + + return $this; + } + + public function actingAs(string $key): static + { + $this->actor = $key; + + if (!isset($this->actors[$key])) { + $this->actors[$key] = [Str::random(), Str::random(), '']; + $this->dispatch(''); + } else { + $this->switched = true; + } + + return $this; + } + + public function wait(DateInterval|DateTimeInterface|int $ttl): static + { + if ($ttl instanceof DateTimeInterface) { + $ttl = Carbon::now()->diffInSeconds($ttl); + } + + if ($ttl instanceof DateInterval) { + $ttl = Carbon::now()->diffInSeconds(Carbon::now()->add($ttl)); + } + + Carbon::setTestNow(Carbon::now()->addSeconds($ttl)); + + return $this; + } + + public function timeout(null|DateInterval|DateTimeInterface|int $ttl = null, ?string $input = null): static + { + if ($ttl) { + $this->wait($ttl); + } + + $this->actors[$this->actor][0] = Str::random(); + + $this->dispatch($input ?? $this->startInput); + + return $this; + } + + public function input(string $input): static + { + $this->dispatch($input); + + return $this; + } + + private function dispatch(string $input): void + { + [$uid, $gid] = $this->actors[$this->actor]; + + $context = Context::create($uid, $gid, $input)->with($this->additional); + $ussd = Ussd::build($context) + ->useInitialState($this->initialState) + ->useContinuingState($this->continuingMode, $this->continuingTtl, $this->continuingState); + + $this->applyUses($ussd); + + $this->actors[$this->actor][2] = $ussd->run(); + + $this->switched = false; + } + + private function applyUses(Ussd $ussd): void + { + foreach ($this->uses as $use) { + if (is_string($use)) { + $use = App::make($use); + } + + if ($use instanceof Configurator) { + $ussd->useConfigurator($use); + continue; + } + + if ($use instanceof Response) { + $ussd->useResponse($use); + continue; + } + + if ($use instanceof ExceptionHandler) { + $ussd->useExceptionHandler($use); + continue; + } + + if ($use instanceof Closure) { + $parameters = (new ReflectionFunction($use))->getNumberOfParameters(); + + if (1 === $parameters) { + $ussd->useExceptionHandler($use); + continue; + } + + if (2 === $parameters) { + $ussd->useResponse($use); + continue; + } + } + + throw new InvalidArgumentException('Invalid use provided.'); + } + } + + private function preventStaleAssertion(): void + { + if ($this->switched) { + $caller = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; + Assert::fail("Call 'input' before '{$caller}' after 'actingAs', or assert before switching to previous users."); + } + } +} diff --git a/src/Traits/Conditionable.php b/src/Traits/Conditionable.php new file mode 100644 index 0000000..334fd5c --- /dev/null +++ b/src/Traits/Conditionable.php @@ -0,0 +1,22 @@ +when(!$value, $callback, $fallback); + } +} diff --git a/src/Traits/MenuBuilder.php b/src/Traits/MenuBuilder.php new file mode 100644 index 0000000..4281188 --- /dev/null +++ b/src/Traits/MenuBuilder.php @@ -0,0 +1,53 @@ +content .= $text; + + return $this; + } + + public function line(string $text): static + { + $this->content .= $text . PHP_EOL; + + return $this; + } + + public function lineBreak(int $times = 1): static + { + $this->content .= str_repeat(PHP_EOL, $times); + + return $this; + } + + public function listing( + array $items, + ?callable $numbering = null, + string $spacer = '.', + string $divider = PHP_EOL, + int $page = 1, + ?int $perPage = null + ): static { + $numbering ??= fn (int $index) => $index + 1; + $offset = max(0, ($page - 1) * ($perPage ?? 0)); + $items = array_slice($items, $offset, $perPage); + + foreach ($items as $index => $item) { + $this->content .= "{$numbering($offset + $index)}{$spacer}{$item}{$divider}"; + } + + return $this; + } + + public function format(string $format, array ...$values): static + { + $this->content .= sprintf($format, ...$values); + + return $this; + } +} diff --git a/src/Traits/UssdBuilder.php b/src/Traits/UssdBuilder.php new file mode 100644 index 0000000..ea8bb52 --- /dev/null +++ b/src/Traits/UssdBuilder.php @@ -0,0 +1,138 @@ +context = $context; + + return $this; + } + + public function useConfigurator(Configurator|string $configurator): static + { + if (is_string($configurator) && class_exists($configurator)) { + $configurator = App::make($configurator); + } + + throw_unless( + $configurator instanceof Configurator, + InvalidConfiguratorException::class, + $configurator::class + ); + + $configurator->configure($this); + + return $this; + } + + public function useInitialState(InitialAction|InitialState|string $initialState): static + { + if (is_string($initialState) && class_exists($initialState)) { + $initialState = App::make($initialState); + } + + throw_unless( + $initialState instanceof InitialState || $initialState instanceof InitialAction, + InvalidInitialStateException::class, + $initialState::class + ); + + $this->initialState = $initialState; + + return $this; + } + + public function useContinuingState( + int $continuingMode, + null|DateInterval|DateTimeInterface|int $continuingTtl, + null|ContinueState|string $continuingState = null + ): static { + if (is_string($continuingState) && class_exists($continuingState)) { + $continuingState = App::make($continuingState); + } + + throw_unless( + in_array($continuingMode, [ContinuingMode::START, ContinuingMode::CONTINUE, ContinuingMode::CONFIRM], true), + InvalidContinuingModeException::class, + $continuingMode + ); + + $this->continuingMode = $continuingMode; + $this->continuingTtl = $continuingTtl; + + if (ContinuingMode::CONFIRM === $continuingMode) { + throw_unless( + $continuingState instanceof ContinueState, + InvalidContinueStateException::class, + isset($continuingState) ? $continuingState::class : null + ); + } + + $this->continuingState = $continuingState; + + return $this; + } + + public function useResponse(Closure|Response|string $response): static + { + if (is_string($response) && class_exists($response)) { + $response = App::make($response); + } + + throw_unless( + $response instanceof Response || $response instanceof Closure, + InvalidResponseException::class, + $response::class + ); + + $this->response = $response; + + return $this; + } + + public function useExceptionHandler(Closure|ExceptionHandler|string $exceptionHandler): static + { + if (is_string($exceptionHandler) && class_exists($exceptionHandler)) { + $exceptionHandler = App::make($exceptionHandler); + } + + throw_unless( + $exceptionHandler instanceof ExceptionHandler || $exceptionHandler instanceof Closure, + InvalidExceptionHandlerException::class, + $exceptionHandler::class + ); + + $this->exceptionHandler = $exceptionHandler; + + return $this; + } + + public function useStore(string $storeName): static + { + $this->storeName = $storeName; + + return $this; + } +} diff --git a/src/Traits/WithPagination.php b/src/Traits/WithPagination.php new file mode 100644 index 0000000..3a6ce7f --- /dev/null +++ b/src/Traits/WithPagination.php @@ -0,0 +1,66 @@ +replace('\\', '')->snake()->append('_page')->value(); + $page = $record->get($pageId, 1); + + return static::$cache[$name] = $page; + } + + public function lastPage(): int + { + return ceil(count($this->getItems()) / $this->perPage()); + } + + public function isFirstPage(): int + { + return 1 === $this->currentPage(); + } + + public function isLastPage(): int + { + return $this->currentPage() === $this->lastPage(); + } + + public function hasNextPage(): bool + { + return $this->currentPage() < $this->lastPage(); + } + + public function hasPreviousPage(): bool + { + return $this->currentPage() > 1; + } + + abstract public function getItems(): array; + + abstract public function perPage(): int; +} diff --git a/src/Ussd.php b/src/Ussd.php index efe84a0..f8882e1 100644 --- a/src/Ussd.php +++ b/src/Ussd.php @@ -2,25 +2,405 @@ namespace Sparors\Ussd; +use Closure; +use DateInterval; +use DateTimeInterface; +use Exception; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Str; +use ReflectionClass; +use Sparors\Ussd\Attributes\Paginate; +use Sparors\Ussd\Attributes\Terminate; +use Sparors\Ussd\Attributes\Transition; +use Sparors\Ussd\Attributes\Truncate; +use Sparors\Ussd\Contracts\Action; +use Sparors\Ussd\Contracts\ContinueState; +use Sparors\Ussd\Contracts\ExceptionHandler; +use Sparors\Ussd\Contracts\InitialAction; +use Sparors\Ussd\Contracts\InitialState; +use Sparors\Ussd\Contracts\Response; +use Sparors\Ussd\Contracts\State; +use Sparors\Ussd\Exceptions\ActiveStateNotFoundException; +use Sparors\Ussd\Exceptions\InvalidContinueStateException; +use Sparors\Ussd\Exceptions\InvalidStateException; +use Sparors\Ussd\Exceptions\NextStateNotFoundException; +use Sparors\Ussd\Exceptions\NoInitialStateProvided; +use Sparors\Ussd\Tests\PendingTest; +use Sparors\Ussd\Traits\Conditionable; +use Sparors\Ussd\Traits\UssdBuilder; +use Sparors\Ussd\Traits\WithPagination; + class Ussd { - /** - * An instance on Application - * - * @var \Illuminate\Contracts\Foundation\Application - */ - protected $app; + use Conditionable; + use UssdBuilder; + + private const INIT = '__init__'; + private const HEAL = '__heal__'; + private const SPUR = '__spur__'; + private const HALT = '__halt__'; + private const CURB = '__curb__'; + private const LIVE = '__live__'; - public function __construct($app) + private Context $context; + private ?string $storeName; + private int $continuingMode; + private Closure|Response $response; + private ?ContinueState $continuingState; + private InitialAction|InitialState $initialState; + private null|DateInterval|DateTimeInterface|int $continuingTtl; + private Closure|ExceptionHandler $exceptionHandler; + + public function __construct(?Context $context) { - $this->app = $app; + if ($context) { + $this->context = $context; + } + + $this->continuingMode = ContinuingMode::START; + $this->continuingTtl = null; + $this->continuingState = null; + + $this->storeName = null; + $this->response = fn (string $message, bool $terminating) => [ + 'message' => $message, + 'terminating' => $terminating, + ]; + $this->exceptionHandler = fn (Exception $exception) => $exception->getMessage(); } - /** - * @return Machine - */ - public function machine() + public static function build(?Context $context = null): static { - return new Machine(); + return new static($context); + } + + public static function test( + InitialState|string $initialState, + int $continuingMode = ContinuingMode::START, + null|DateInterval|DateTimeInterface|int $continuingTtl = null, + null|ContinueState|string $continuingState = null + ): PendingTest { + return new PendingTest($initialState, $continuingMode, $continuingTtl, $continuingState); + } + + public function run(): mixed + { + try { + [$message, $terminating] = $this->operate(); + } catch (Exception $exception) { + [$message, $terminating] = $this->bail($exception); + } + + if (ContinuingMode::START !== $this->continuingMode) { + /** @var Record */ $record = App::make(Record::class); + if ($terminating) { + $record->forget(static::SPUR, true); + } elseif ($spur = $record->get(static::SPUR, public: true)) { + $record->set(static::SPUR, $spur, $this->continuingTtl, true); + } + } + + return $this->response instanceof Response + ? ($this->response)->respond($message, $terminating) + : ($this->response)($message, $terminating); + } + + /** @return array{0: string, 1: bool} */ + private function operate(): array + { + App::instance($this->context::class, $this->context); + + $record = new Record($this->storeName, $this->context->uid(), $this->context->gid()); + + if (ContinuingMode::CONTINUE === $this->continuingMode && !$record->has(static::CURB) && $spur = $record->get(static::SPUR, public: true)) { + $record->setMany([static::HEAL => $spur, static::CURB => true]); + } elseif (ContinuingMode::CONFIRM === $this->continuingMode && $record->has(static::HALT) && $spur = $record->get(static::SPUR, public: true)) { + throw_unless( + $this->continuingState instanceof ContinueState, + InvalidContinueStateException::class, + isset($this->continuingState) ? $this->continuingState::class : null + ); + + $record->forget(static::HALT); + $record->set(static::CURB, true); + + if ($this->continuingState->confirm()->decide($this->context->input())) { + $record->set(static::HEAL, $spur); + } + } + + if ($rid = $record->get(static::HEAL)) { + $record = new Record($this->storeName, $rid, $this->context->gid()); + } + + App::instance($record::class, $record); + + if ($record->has(static::INIT)) { + $nextState = $record->get(static::LIVE); + + throw_unless( + $nextState, + ActiveStateNotFoundException::class + ); + + $nextState = App::make($nextState); + + $nextState = $this->next($nextState); + + $nextState = $this->actionable($nextState); + + $record->set(static::LIVE, $nextState); + } elseif (ContinuingMode::CONFIRM === $this->continuingMode && !$record->has(static::CURB) && $record->has(static::SPUR, true)) { + $nextState = $this->continuingState::class; + + $record->set(static::HALT, true); + } else { + throw_unless( + isset($this->initialState), + NoInitialStateProvided::class + ); + + $nextState = $this->initialState::class; + + $nextState = $this->actionable($nextState); + + $record->setMany([ + static::LIVE => $nextState, + static::INIT => true, + ]); + + if (ContinuingMode::START !== $this->continuingMode) { + $record->set(static::SPUR, $this->context->uid(), $this->continuingTtl, true); + } + } + + $state = App::make($nextState); + + /** @var Menu */ $menu = App::call([$state, 'render']); + + [$content, $more] = $this->limit($state, $menu); + + return [trim($content), $this->terminating($state, $more)]; + } + + private function next(State $state): string + { + $reflected = new ReflectionClass($state); + + $attributes = $reflected->getAttributes(Truncate::class); + + foreach ($attributes as $attribute) { + $content = (string) App::call([$state, 'render']); + + $limitContent = $attribute->newInstance(); + + if ($limitContent->limit > strlen($content)) { + continue; + } + + if (is_array($limitContent->more)) { + $limitContent->more = new $limitContent->more[0](...array_slice($limitContent->more, 1)); + } elseif (is_string($limitContent->more)) { + $limitContent->more = new $limitContent->more(); + } + + if ($limitContent->more->decide($this->context->input())) { + $items = preg_split( + "/ÙÛÚ/", + wordwrap( + $content, + $limitContent->limit - (strlen($limitContent->end) + 1), + "ÙÛÚ", + true + ) + ); + + /** @var Record */ $record = App::make(Record::class); + $limitId = Str::of($state::class)->replace('\\', '')->snake()->append('_limit')->value(); + $limit = $record->get($limitId, 1); + + if (count($items) > $limit) { + $record->set($limitId, $limit + 1); + } else { + continue; + } + + return $state::class; + } + } + + $attributes = $reflected->getAttributes(Paginate::class); + + foreach ($attributes as $attribute) { + $paginate = $attribute->newInstance(); + + foreach(['next', 'previous'] as $key) { + if (is_array($paginate->{$key})) { + $paginate->{$key} = new $paginate->{$key}[0](...array_slice($paginate->{$key}, 1)); + } elseif (is_string($paginate->{$key})) { + $paginate->{$key} = new $paginate->{$key}(); + } elseif (is_null($paginate->{$key})) { + continue; + } + + if ($paginate->{$key}->decide($this->context->input())) { + if (class_uses($state)[WithPagination::class] ?? null) { + /** @var WithPagination $state */ + /** @var Record */ $record = App::make(Record::class); + $pageId = Str::of($state::class)->replace('\\', '')->snake()->append('_page')->value(); + $page = $record->get($pageId, 1); + + if ('next' === $key && $state->hasNextPage()) { + $record->set($pageId, $page + 1); + } elseif ('previous' === $key && $state->hasPreviousPage()) { + $record->set($pageId, $page - 1); + } else { + continue; + } + + $reflected = new ReflectionClass($state); + + $attributes = $reflected->getAttributes(Truncate::class); + + if (count($attributes) > 0) { + $limitId = Str::of($state::class)->replace('\\', '')->snake()->append('_limit')->value(); + $limit = $record->get($limitId, 1); + + if ($limit > 1) { + $record->set($limitId, 1); + } + } + } + + if ($paginate->callback) { + if (is_array($paginate->callback) && is_string($paginate->callback[0])) { + $paginate->callback[0] = App::make($paginate->callback[0]); + } + + App::call($paginate->callback); + } + + return $state::class; + } + } + } + + $attributes = $reflected->getAttributes(Transition::class); + + foreach($attributes as $attribute) { + $transition = $attribute->newInstance(); + + if (is_array($transition->match)) { + $transition->match = new $transition->match[0](...array_slice($transition->match, 1)); + } elseif (is_string($transition->match)) { + $transition->match = new $transition->match(); + } + + if ($transition->match->decide($this->context->input())) { + if ($transition->callback) { + if (is_array($transition->callback) && is_string($transition->callback[0])) { + $transition->callback[0] = App::make($transition->callback[0]); + } + + App::call($transition->callback); + } + + return $transition->to; + } + } + + throw new NextStateNotFoundException($state::class); + } + + private function actionable(string $class): string + { + $instance = App::make($class); + + while ($instance instanceof Action) { + $next = App::call([$instance, 'execute']); + + $instance = App::make($next); + } + + throw_unless( + $instance instanceof State, + InvalidStateException::class, + $instance::class + ); + + return $instance::class; + } + + /** @return array{0: string, 1: bool} */ + private function limit(State $state, Menu $menu): array + { + $content = (string) $menu; + + $reflected = new ReflectionClass($state); + + $attributes = $reflected->getAttributes(Truncate::class); + + foreach ($attributes as $attribute) { + $limitContent = $attribute->newInstance(); + + if ($limitContent->limit > strlen($content)) { + continue; + } + + $items = preg_split( + "/ÙÛÚ/", + wordwrap( + $content, + $limitContent->limit - (strlen($limitContent->end) + 1), + "ÙÛÚ", + true + ) + ); + + /** @var Record */ $record = App::make(Record::class); + $limitId = Str::of($state::class)->replace('\\', '')->snake()->append('_limit')->value(); + $limit = $record->get($limitId, 1); + + return [ + count($items) > $limit + ? sprintf("%s\n%s", $items[$limit - 1], $limitContent->end) + : $items[$limit - 1], + count($items) > $limit, + ]; + } + + return [$content, false]; + } + + private function terminating(State $state, bool $more): bool + { + if ($more) { + return false; + } + + if (class_uses($state)[WithPagination::class] ?? null) { + /** @var WithPagination $state */ + if ($state->hasNextPage()) { + return false; + } + } + + $reflected = new ReflectionClass($state); + + $attributes = $reflected->getAttributes(Terminate::class); + + return 0 !== count($attributes); + } + + /** @return array{0: string, 1: bool} */ + private function bail(Exception $exception): array + { + report($exception); + + $message = $this->exceptionHandler instanceof ExceptionHandler + ? ($this->exceptionHandler)->handle($exception) + : ($this->exceptionHandler)($exception); + + return [$message, true]; } } diff --git a/src/UssdServiceProvider.php b/src/UssdServiceProvider.php index e44c983..c993482 100644 --- a/src/UssdServiceProvider.php +++ b/src/UssdServiceProvider.php @@ -2,65 +2,62 @@ namespace Sparors\Ussd; +use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Support\ServiceProvider; use Sparors\Ussd\Commands\ActionCommand; +use Sparors\Ussd\Commands\ActionMakeCommand; +use Sparors\Ussd\Commands\ConfiguratorCommand; +use Sparors\Ussd\Commands\ConfiguratorMakeCommand; +use Sparors\Ussd\Commands\DecisionCommand; +use Sparors\Ussd\Commands\DecisionMakeCommand; +use Sparors\Ussd\Commands\ExceptionHandlerCommand; +use Sparors\Ussd\Commands\ExceptionHandlerMakeCommand; +use Sparors\Ussd\Commands\ResponseCommand; +use Sparors\Ussd\Commands\ResponseMakeCommand; use Sparors\Ussd\Commands\StateCommand; +use Sparors\Ussd\Commands\StateMakeCommand; class UssdServiceProvider extends ServiceProvider { - /** - * Perform post-registration booting of services. - * - * @return void - */ + /** @return void */ public function boot() { - // Publishing is only necessary when using the CLI. if ($this->app->runningInConsole()) { $this->bootForConsole(); } } - /** - * Register any package services. - * - * @return void - */ + /** @return void */ public function register() { $this->mergeConfigFrom(__DIR__.'/../config/ussd.php', 'ussd'); - // Register the service the package provides. - $this->app->singleton('ussd', function ($app) { - return new Ussd($app); - }); } - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() - { - return ['ussd']; - } - - /** - * Console-specific booting. - * - * @return void - */ + /** @return void */ protected function bootForConsole() { - // Publishing the configuration file. $this->publishes([ __DIR__.'/../config/ussd.php' => config_path('ussd.php'), ], 'ussd-config'); - // Registering package commands. $this->commands([ StateCommand::class, + StateMakeCommand::class, ActionCommand::class, + ActionMakeCommand::class, + ResponseCommand::class, + ResponseMakeCommand::class, + DecisionCommand::class, + DecisionMakeCommand::class, + ConfiguratorCommand::class, + ConfiguratorMakeCommand::class, + ExceptionHandlerCommand::class, + ExceptionHandlerMakeCommand::class, + ]); + + AboutCommand::add('USSD', [ + 'Namespace' => config('ussd.namespace'), + 'Record Store' => config('ussd.record_store') ?? config('cache.default'), ]); } } diff --git a/stubs/action.init.stub b/stubs/action.init.stub new file mode 100644 index 0000000..0eb283e --- /dev/null +++ b/stubs/action.init.stub @@ -0,0 +1,13 @@ +getMessage(); + } +} diff --git a/stubs/response.stub b/stubs/response.stub new file mode 100644 index 0000000..8522d90 --- /dev/null +++ b/stubs/response.stub @@ -0,0 +1,13 @@ +line('1.Continue'); + } + + public function confirm(): Decision + { + return new Equal(1); + } +} diff --git a/stubs/state.init.stub b/stubs/state.init.stub new file mode 100644 index 0000000..7c13982 --- /dev/null +++ b/stubs/state.init.stub @@ -0,0 +1,14 @@ +text(''); + } +} diff --git a/stubs/state.stub b/stubs/state.stub new file mode 100644 index 0000000..1c30cd7 --- /dev/null +++ b/stubs/state.stub @@ -0,0 +1,14 @@ +text(''); + } +} diff --git a/tests/ActionTest.php b/tests/ActionTest.php deleted file mode 100644 index fe974b5..0000000 --- a/tests/ActionTest.php +++ /dev/null @@ -1,14 +0,0 @@ -assertEquals(ByeState::class, $action->run()); - } -} diff --git a/tests/ByeState.php b/tests/ByeState.php deleted file mode 100644 index 3caf6e9..0000000 --- a/tests/ByeState.php +++ /dev/null @@ -1,20 +0,0 @@ -menu->text('Bye World'); - } - - public function afterRendering(string $argument): void - { - } -} diff --git a/tests/CogConfigurator.php b/tests/CogConfigurator.php deleted file mode 100644 index 27f2a3e..0000000 --- a/tests/CogConfigurator.php +++ /dev/null @@ -1,27 +0,0 @@ -response = function (string $message, string $action) use ($operator) { - return ['action' => $action, 'operator' => $operator, 'message' => $message]; - }; - } - - public function configure(Machine $machine): void - { - $machine->setFromRequest([ - 'phone_number' => 'phoneNumber', - 'network' => 'serviceCode', - 'session_id' => 'sessionId' - ])->setResponse($this->response); - } -} diff --git a/tests/Commands/ActionCommandTest.php b/tests/Commands/ActionCommandTest.php deleted file mode 100644 index db9649c..0000000 --- a/tests/Commands/ActionCommandTest.php +++ /dev/null @@ -1,28 +0,0 @@ -once()->andReturn(false); - File::shouldReceive('isDirectory')->once(); - File::shouldReceive('makeDirectory')->once(); - File::shouldReceive('put')->once()->andReturn(true); - $this->artisan('ussd:action', ['name' => 'save']) - ->expectsOutput('Save action created successfully') - ->assertExitCode(0); - } - - public function test_it_print_out_error_when_class_exists() - { - File::shouldReceive('exists')->once()->andReturn(true); - $this->artisan('ussd:action', ['name' => 'save']) - ->expectsOutput('File already exists !') - ->assertExitCode(0); - } -} diff --git a/tests/Commands/StateCommandTest.php b/tests/Commands/StateCommandTest.php deleted file mode 100644 index 9844f9a..0000000 --- a/tests/Commands/StateCommandTest.php +++ /dev/null @@ -1,28 +0,0 @@ -once()->andReturn(false); - File::shouldReceive('isDirectory')->once(); - File::shouldReceive('makeDirectory')->once(); - File::shouldReceive('put')->once()->andReturn(true); - $this->artisan('ussd:state', ['name' => 'welcome']) - ->expectsOutput('Welcome state created successfully') - ->assertExitCode(0); - } - - public function test_it_print_out_error_when_class_exists() - { - File::shouldReceive('exists')->once()->andReturn(true); - $this->artisan('ussd:state', ['name' => 'welcome']) - ->expectsOutput('File already exists !') - ->assertExitCode(0); - } -} diff --git a/tests/DecisionTest.php b/tests/DecisionTest.php deleted file mode 100644 index 113bf95..0000000 --- a/tests/DecisionTest.php +++ /dev/null @@ -1,115 +0,0 @@ -assertNull($decision->outcome()); - } - - public function test_it_can_compare_argument_using_equal() - { - $decision = new Decision(1); - $this->assertEquals('True', $decision->equal('1', 'True') - ->outcome()); - } - - public function test_it_can_compare_argument_using_strict_equal() - { - $decision = new Decision(1); - $this->assertNull($decision - ->equal('1', 'True', true)->outcome()); - } - - public function test_it_can_compare_argument_using_numeric() - { - $decision = new Decision('1'); - $this->assertEquals('True', $decision->numeric('True')->outcome()); - } - - public function test_it_can_compare_argument_using_integer() - { - $decision = new Decision(1); - $this->assertEquals('True', $decision->integer('True')->outcome()); - } - - public function test_it_can_compare_argument_using_amount() - { - $decision = new Decision(11.2); - $this->assertEquals('True', $decision->amount('True')->outcome()); - } - - public function test_it_can_compare_argument_using_length() - { - $decision = new Decision('one'); - $this->assertEquals('True', $decision->length(3, 'True') - ->outcome()); - } - - public function test_it_can_compare_argument_using_phone_number() - { - $decision = new Decision('0241212123'); - $this->assertEquals('True', $decision->phoneNumber('True') - ->outcome()); - } - - public function test_it_can_use_your_custom_conditional_logic() - { - $decision = new Decision(['active' => true]); - $this->assertEquals('Custom', $decision - ->custom(function ($argument) { - return $argument['active']; - }, 'Custom')->outcome()); - } - - public function test_it_can_compare_argument_between_two_numbers() - { - $decision = new Decision(3); - $this->assertEquals('True', $decision->between(1, 10, 'True')->outcome()); - } - - public function test_it_can_compare_argument_with_values_in_an_array() - { - $decision = new Decision('second'); - $this->assertEquals('True', $decision->in(['first', 'second', 'third'], 'True')->outcome()); - } - - public function test_it_can_compare_argument_with_values_in_an_array_strictly() - { - $decision = new Decision(2); - $this->assertEquals('True', $decision->in(['1', 2, '3'], 'True', true)->outcome()); - } - - public function test_it_can_use_any_wild_cards() - { - $decision = new Decision('5'); - $this->assertEquals('True', $decision->any('True')->outcome()); - } - - public function test_decision_can_be_chain() - { - $decision = new Decision('45'); - $this->assertEquals('True', $decision - ->phoneNumber('Phone')->any('True')->outcome()); - } - - public function test_it_ignores_following_decision_when_condition_is_met() - { - $decision = new Decision('1234'); - $this->assertEquals('First', $decision - ->numeric('First')->any('Second')->outcome()); - } - - public function test_it_returns_null_when_no_condition_is_met() - { - $decision = new Decision('super'); - $this->assertNull($decision->numeric('Numeric') - ->phoneNumber('Phone')->equal('ama', 'True')->outcome()); - } -} diff --git a/tests/Dummy/BeginningState.php b/tests/Dummy/BeginningState.php new file mode 100644 index 0000000..fd89447 --- /dev/null +++ b/tests/Dummy/BeginningState.php @@ -0,0 +1,26 @@ +text('In the beginning...'); + } + + public function callback(Record $record, Context $context) + { + $record->set('wow', $context->input()); + } +} diff --git a/tests/Dummy/CogConfigurator.php b/tests/Dummy/CogConfigurator.php new file mode 100644 index 0000000..1731f59 --- /dev/null +++ b/tests/Dummy/CogConfigurator.php @@ -0,0 +1,20 @@ +useResponse(function (string $message, string $terminating) { + return ['action' => $terminating ? 'prompt' : 'input', 'operator' => $this->operator, 'message' => $message]; + }); + } +} diff --git a/tests/Dummy/ContinuingState.php b/tests/Dummy/ContinuingState.php new file mode 100644 index 0000000..15d1221 --- /dev/null +++ b/tests/Dummy/ContinuingState.php @@ -0,0 +1,24 @@ +line('Wanna continue?') + ->listing(['Yes']) + ->text('Any to start'); + } + + public function confirm(): Decision + { + return new Equal(1); + } +} diff --git a/tests/Dummy/DoTheThing.php b/tests/Dummy/DoTheThing.php new file mode 100644 index 0000000..7b18f99 --- /dev/null +++ b/tests/Dummy/DoTheThing.php @@ -0,0 +1,13 @@ +set('pop', 'Hurray!!!!!'); + } +} diff --git a/tests/Dummy/FinishingState.php b/tests/Dummy/FinishingState.php new file mode 100644 index 0000000..417e526 --- /dev/null +++ b/tests/Dummy/FinishingState.php @@ -0,0 +1,19 @@ +getMany(['magic', 'pop']); + + return Menu::build()->line('Tadaa...')->text($magic)->lineBreak()->text($pop); + } +} diff --git a/tests/Dummy/GrandAction.php b/tests/Dummy/GrandAction.php new file mode 100644 index 0000000..caf7c73 --- /dev/null +++ b/tests/Dummy/GrandAction.php @@ -0,0 +1,16 @@ +set('magic', 'abracadabra'); + + return FinishingState::class; + } +} diff --git a/tests/Dummy/IntermediateState.php b/tests/Dummy/IntermediateState.php new file mode 100644 index 0000000..aff6320 --- /dev/null +++ b/tests/Dummy/IntermediateState.php @@ -0,0 +1,38 @@ +text('Pick one...') + ->when($record->has('wow'), function (Menu $menu) { + $menu->line('Booooom!'); + }) + ->listing($this->getItems(), page: $this->currentPage(), perPage: $this->perPage()); + } + + public function getItems(): array + { + return ['Foo', 'Bar', 'Baz']; + } + + public function perPage(): int + { + return 2; + } +} diff --git a/tests/Dummy/PetitAction.php b/tests/Dummy/PetitAction.php new file mode 100644 index 0000000..6a68907 --- /dev/null +++ b/tests/Dummy/PetitAction.php @@ -0,0 +1,13 @@ +line('In the sophisticated world') + ->listing($this->getItems(), page: $this->currentPage(), perPage: $this->perPage()) + ->unless($this->isLastPage(), fn (Menu $menu) => $menu->text('#.Next')); + } + + public function getItems(): array + { + return [ + 'The quick brown fox jumps over the lazy dog.', + 'A journey of a thousand miles begins with a single step.', + 'Success is not final, failure is not fatal: It is the courage to continue that counts.', + 'In the middle of difficulty lies opportunity.', + 'The only way to do great work is to love what you do.', + 'Believe you can and you\'re halfway there.', + 'The future belongs to those who believe in the beauty of their dreams.', + 'Don\'t watch the clock; do what it does. Keep going.', + 'The best way to predict the future is to create it.', + 'Dream big and dare to fail.', + ]; + } + + public function perPage(): int + { + return 3; + } +} diff --git a/tests/Facades/UssdTest.php b/tests/Facades/UssdTest.php deleted file mode 100644 index 329a876..0000000 --- a/tests/Facades/UssdTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertInstanceOf(Machine::class, UssdFacade::machine()); - } - - public function test_it_injects_machine_using_the_service_container() - { - $this->assertInstanceOf(Ussd::class, app('ussd')); - } -} diff --git a/tests/HasManipulatorsTest.php b/tests/HasManipulatorsTest.php deleted file mode 100644 index fc7b5ce..0000000 --- a/tests/HasManipulatorsTest.php +++ /dev/null @@ -1,175 +0,0 @@ -getMockForTrait(HasManipulators::class); - $manipulator->setSessionId('1234'); - $this->assertEquals('1234', $manipulator->sessionId); - } - - public function test_it_sets_session_id_from_request() - { - request()->merge(['session_id' => '1234']); - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setSessionIdFromRequest('session_id'); - $this->assertEquals('1234', $manipulator->sessionId); - } - - public function test_it_sets_phone_number() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setPhoneNumber('0545112466'); - $this->assertEquals('0545112466', $manipulator->phoneNumber); - } - - public function test_it_sets_phone_number_from_request() - { - request()->merge(['phone_number' => '0545112466']); - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setPhoneNumberFromRequest('phone_number'); - $this->assertEquals('0545112466', $manipulator->phoneNumber); - } - - public function test_it_sets_network() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setNetwork('MTN'); - $this->assertEquals('MTN', $manipulator->network); - } - - public function test_it_sets_network_from_request() - { - request()->merge(['network' => 'MTN']); - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setNetworkFromRequest('network'); - $this->assertEquals('MTN', $manipulator->network); - } - - public function test_it_sets_input() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setInput('1'); - $this->assertEquals('1', $manipulator->input); - } - - public function test_it_sets_input_from_request() - { - request()->merge(['input' => '1']); - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setInputFromRequest('input'); - $this->assertEquals('1', $manipulator->input); - } - - public function test_it_sets_store() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setStore('redis'); - $this->assertEquals('redis', $manipulator->store); - } - - public function test_set_multiple_values_at_once() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - // Ensure the property exist so it can be set - $manipulator->network = null; - $manipulator->sessionId = null; - $manipulator->phoneNumber = null; - $manipulator->set([ - 'network' => 'MTN', - 'session_id' => '1234', - 'phoneNumber' => '0545112466', - ]); - $this->assertEquals('MTN', $manipulator->network); - $this->assertEquals('1234', $manipulator->sessionId); - $this->assertEquals('0545112466', $manipulator->phoneNumber); - } - - public function test_set_multiple_values_at_once_from_request() - { - request()->merge([ - 'op_network' => 'MTN', - 'session_id' => '1234', - 'phoneNumber' => '0545112466' - ]); - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - // Ensure the property exist so it can be set - $manipulator->network = null; - $manipulator->sessionId = null; - $manipulator->phoneNumber = null; - $manipulator->setFromRequest([ - 'network' => 'op_network', - 'session_id', - 'phoneNumber', - ]); - $this->assertEquals('MTN', $manipulator->network); - $this->assertEquals('1234', $manipulator->sessionId); - $this->assertEquals('0545112466', $manipulator->phoneNumber); - } - - public function test_it_sets_initial_state_with_string_of_classname() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setInitialState(Record::class); - $this->assertEquals('Sparors\Ussd\Record', $manipulator->initialState); - } - - public function test_it_sets_initial_state_with_class_instance() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $decision = new Decision(); - $manipulator->setInitialState($decision); - $this->assertEquals('Sparors\Ussd\Decision', $manipulator->initialState); - } - - public function test_it_sets_initial_state_to_null_if_state_is_invalid() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setInitialState(1); - $this->assertNull($manipulator->initialState); - } - - public function test_it_set_response_formatting() - { - /** @var \Sparors\Ussd\HasManipulators */ - $manipulator = $this->getMockForTrait(HasManipulators::class); - $manipulator->setResponse(function (string $message, int $code) { - return [ - 'message' => $message, - 'state' => $code === 1 ? 'START' : 'END' - ]; - }); - $this->assertIsCallable($manipulator->response); - $this->assertEquals( - function (string $message, int $code) { - return [ - 'message' => $message, - 'state' => $code === 1 ? 'START' : 'END' - ]; - }, - $manipulator->response - ); - } -} diff --git a/tests/HelloState.php b/tests/HelloState.php deleted file mode 100644 index 943a6f4..0000000 --- a/tests/HelloState.php +++ /dev/null @@ -1,19 +0,0 @@ -menu->text('Hello World'); - } - - public function afterRendering(string $argument): void - { - $this->decision->any(RunAction::class); - } -} diff --git a/tests/Integration/ActionTest.php b/tests/Integration/ActionTest.php new file mode 100644 index 0000000..fbc1372 --- /dev/null +++ b/tests/Integration/ActionTest.php @@ -0,0 +1,19 @@ +assertEquals(FinishingState::class, $action->execute($record)); + } +} diff --git a/tests/Integration/AssestionTest.php b/tests/Integration/AssestionTest.php new file mode 100644 index 0000000..abb4b40 --- /dev/null +++ b/tests/Integration/AssestionTest.php @@ -0,0 +1,68 @@ +additional(['foo' => 'bar']) + ->actingAs('isaac') + ->start() + ->assertSee('In the') + ->assertContextHas('foo') + ->assertContextHas('foo', 'bar') + ->assertContextHas('foo', fn ($value) => $value === 'bar') + ->assertContextMissing('baz') + ->assertRecordMissing('wow') + ->input('#') + ->assertSee('beginning..') + ->input('1') + ->assertSee('Pick one...') + ->assertSee('Foo') + ->assertRecordHas('wow') + ->input('#') + ->assertSee('Pick one...') + ->assertSee('Baz') + ->actingAs('benjamin') + ->assertSee('In the') + ->input('1') + ->assertSee('Pick one...') + ->actingAs('isaac') + ->input('1') + ->assertSee('Tadaa...') + ->actingAs('benjamin') + ->input('1') + ->assertRecordHas('wow') + ->assertSee('Tadaa...') + ->timeout() + ->assertSee('In the') + ->input('1') + ->assertSee('Pick one...') + ->actingAs('isaac'); + } + + public function test_ussd_assestion_can_wait_for_some_time_to_pass() + { + Ussd::test(BeginningState::class, ContinuingMode::CONFIRM, 5, ContinuingState::class) + ->actingAs('isaac') + ->start() + ->input('1') + ->timeout(6) + ->assertSee('In the') + ->timeout(3) + ->assertSee('Wanna continue') + ->input('1') + ->input('1') + ->assertSee('Tadaa...') + ->timeout() + ->assertSee('In the'); + } +} diff --git a/tests/Integration/CommandTest.php b/tests/Integration/CommandTest.php new file mode 100644 index 0000000..b0734d4 --- /dev/null +++ b/tests/Integration/CommandTest.php @@ -0,0 +1,49 @@ +once(); + File::shouldReceive('isDirectory')->once(); + File::shouldReceive('makeDirectory')->once(); + File::shouldReceive('get')->once(); + File::shouldReceive('put')->once(); + + $this->artisan($command, ['name' => $class]) + ->expectsOutputToContain($class) + ->assertExitCode(0); + } + + /** @dataProvider data_available_make_commands */ + public function test_action_command_print_out_error_when_class_exists($command, $class) + { + File::shouldReceive('exists')->once()->andReturn(true); + + $this->artisan($command, ['name' => $class]) + ->expectsOutputToContain('already exists.') + ->assertExitCode(0); + } + + public static function data_available_make_commands() + { + return [ + ['ussd:state', 'WelcomeState'], + ['make:ussd-state', 'WelcomeState'], + ['ussd:action', 'MenuAction'], + ['make:ussd-action', 'MenuAction'], + ['ussd:decision', 'StrictEqual'], + ['make:ussd-decision', 'StrictEqual'], + ['ussd:configurator', 'DynamicConfigurator'], + ['make:ussd-configurator', 'DynamicConfigurator'], + ['ussd:exception-handler', 'CatchExceptionHandler'], + ['make:ussd-exception-handler', 'CatchExceptionHandler'], + ]; + } +} diff --git a/tests/Integration/RecordTest.php b/tests/Integration/RecordTest.php new file mode 100644 index 0000000..ebea0f0 --- /dev/null +++ b/tests/Integration/RecordTest.php @@ -0,0 +1,63 @@ +set('name', 'ussd'); + $record->set('author', 'Isaac Sai'); + $record->version = '1.0'; + $this->assertEquals('ussd', $record->name); + $this->assertEquals('Isaac Sai', $record('author')); + $this->assertEquals('1.0', $record->get('version')); + } + + public function test_record_can_set_and_get_many_values() + { + $record = new Record('array', '1234', 'abcd'); + $record->setMany(['name' => 'ussd', 'version' => '1.0']); + $record(['author' => 'Isaac Sai',]); + $this->assertEquals(['ussd', '1.0', 'Isaac Sai'], $record->getMany(['name', 'version', 'author'])); + } + + public function test_record_can_forget_a_value() + { + $record = new Record('array', '1234', 'abcd'); + $record->setMany(['name' => 'ussd', 'version' => '1.0', 'author' => 'Isaac Sai']); + $record->forget('name'); + unset($record->author); + $this->assertEquals([null, '1.0', null], $record->getMany(['name', 'version', 'author'])); + } + + public function test_record_can_forget_multiple_values() + { + $record = new Record('array', '1234', 'abcd'); + $record->setMany(['name' => 'ussd', 'version' => '1.0']); + $record->forgetMany(['name', 'version']); + $this->assertEquals([null, null], $record->getMany(['name', 'version'])); + } + + public function test_record_can_verify_if_cache_has_value() + { + $record = new Record('array', '1234', 'abcd'); + $record->set('name', 'ussd'); + $record->set('author', 'Isaac Sai'); + $this->assertTrue($record->has('name')); + $this->assertFalse($record->has('version')); + $this->assertTrue(isset($record->name)); + } + + public function test_record_can_increment_or_decrement_a_numeric_value() + { + $record = new Record('array', '1234', 'abcd'); + $record->set('age', 17); + $this->assertEquals(18, $record->increment('age')); + $this->assertEquals(17, $record->decrement('age')); + } +} diff --git a/tests/Integration/UssdTest.php b/tests/Integration/UssdTest.php new file mode 100644 index 0000000..7dcf3af --- /dev/null +++ b/tests/Integration/UssdTest.php @@ -0,0 +1,681 @@ +assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Tadaa...\nabracadabra\nHurray!!!!!", + 'terminating' => true + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + } + + public function test_ussd_can_paginate() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n3.Baz", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + } + + public function test_ussd_can_detect_end_of_paginate() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n3.Baz", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $res = Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run(); + + $this->assertTrue($res['terminating']); + } + + public function test_ussd_can_limit_content() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "beginning..\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + } + + public function test_ussd_can_detect_end_of_limit_content() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "beginning..\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => ".", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run() + ); + + $res = Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(BeginningState::class) + ->run(); + + $this->assertTrue($res['terminating']); + } + + public function test_ussd_can_automatically_continue_from_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Tadaa...\nabracadabra\nHurray!!!!!", + 'terminating' => true + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_automatically_start_from_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::START, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::START, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::START, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_manually_continue_from_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Wanna continue?\n1.Yes\nAny to start", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Tadaa...\nabracadabra\nHurray!!!!!", + 'terminating' => true + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_manually_start_from_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Wanna continue?\n1.Yes\nAny to start", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '2') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_automatically_continue_from_multiple_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Tadaa...\nabracadabra\nHurray!!!!!", + 'terminating' => true + ], + Ussd::build( + Context::create('6565', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONTINUE, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_manually_continue_from_multiple_old_session() + { + $this->assertEquals( + [ + 'message' => "In the\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Wanna continue?\n1.Yes\nAny to start", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Pick one...Booooom!\n1.Foo\n2.Bar", + 'terminating' => false + ], + Ussd::build( + Context::create('5656', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Wanna continue?\n1.Yes\nAny to start", + 'terminating' => false + ], + Ussd::build( + Context::create('6565', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Tadaa...\nabracadabra\nHurray!!!!!", + 'terminating' => true + ], + Ussd::build( + Context::create('6565', '7890', '1') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, ContinuingState::class) + ->run() + ); + } + + public function test_ussd_can_limit_content_and_paginate() + { + $this->assertEquals( + [ + 'message' => "In the sophisticated world\n1.The quick brown fox jumps over the lazy dog.\n2.A journey of a thousand miles begins with a single step.\n3.Success\n#.More", + 'terminating' => false, + ], + Ussd::build( + Context::create('1234', '7890', '1') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "is not final, failure is not fatal: It is the courage to continue that counts.\n#.Next", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "In the sophisticated world\n4.In the middle of difficulty lies opportunity.\n5.The only way to do great work is to love what you do.\n6.Believe\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "you can and you're halfway there.\n#.Next", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "In the sophisticated world\n7.The future belongs to those who believe in the beauty of their dreams.\n8.Don't watch the clock; do what it does.\n#.More", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "Keep going.\n9.The best way to predict the future is to create it.\n#.Next", + 'terminating' => false + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + + $this->assertEquals( + [ + 'message' => "In the sophisticated world\n10.Dream big and dare to fail.", + 'terminating' => true + ], + Ussd::build( + Context::create('1234', '7890', '#') + ) + ->useInitialState(SophisticatedState::class) + ->run() + ); + } + + /** @dataProvider configurator_as */ + public function test_it_can_make_use_of_a_configurator($operator, $configurator) + { + $this->assertEquals( + [ + 'action' => 'input', + 'message' => "In the\n#.More", + 'operator' => $operator, + ], + Ussd::build( + Context::create('1234', '7890', '2') + ) + ->useInitialState(BeginningState::class) + ->useConfigurator($configurator) + ->run() + ); + } + + public function test_invalid_initial_state_throws_an_exception() + { + $this->expectException(Exception::class); + + Ussd::build( + Context::create('1234', '7890', '2') + ) + ->useInitialState(FinishingState::class) + ->run(); + } + + public function test_invalid_continuing_state_throws_an_exception() + { + $this->expectException(Exception::class); + + Ussd::build( + Context::create('1234', '7890', '2') + ) + ->useInitialState(BeginningState::class) + ->useContinuingState(ContinuingMode::CONFIRM, 3600, FinishingState::class) + ->run(); + } + + public function test_invalid_exception_handler_throws_an_exception() + { + $this->expectException(Exception::class); + + Ussd::build( + Context::create('1234', '7890', '2') + ) + ->useInitialState(BeginningState::class) + ->useExceptionHandler(FinishingState::class) + ->run(); + } + + public function test_invalid_configurator_throws_an_exception() + { + $this->expectException(Exception::class); + + Ussd::build( + Context::create('1234', '7890', '2') + ) + ->useInitialState(BeginningState::class) + ->useConfigurator(FinishingState::class) + ->run(); + } + + public static function configurator_as() + { + return [ + ['Default', CogConfigurator::class], + ['Dynamic', new CogConfigurator('Dynamic')], + ]; + } +} diff --git a/tests/MachineTest.php b/tests/MachineTest.php deleted file mode 100644 index 0b76ff4..0000000 --- a/tests/MachineTest.php +++ /dev/null @@ -1,91 +0,0 @@ -setSessionId('1234') - ->setInput('1') - ->setInitialState(HelloState::class) - ->setStore('array'); - - $this->assertEquals( - [ - 'message' => 'Hello World', - 'action' => 'input' - ], - $machine->run() - ); - - $machine->setInput('2'); - - $this->assertEquals( - [ - 'message' => 'Bye World', - 'action' => 'prompt' - ], - $machine->run() - ); - } - - public function test_initial_state_can_be_a_callable() - { - $machine = (new Machine())->setSessionId('1234') - ->setInput('1') - ->setInitialState(function () { - return HelloState::class; - }) - ->setStore('array'); - - $this->assertEquals( - [ - 'message' => 'Hello World', - 'action' => 'input' - ], - $machine->run() - ); - } - - /** @dataProvider pass_configurator_as */ - public function test_it_can_make_use_of_a_configurator($operator, $configurator) - { - $machine = UssdFacade::machine() - ->useConfigurator($configurator) - ->setSessionId('1234') - ->setInitialState(HelloState::class); - - $this->assertEquals( - [ - 'action' => 'input', - 'message' => 'Hello World', - 'operator' => $operator, - ], - $machine->run() - ); - } - - public function pass_configurator_as() - { - return [ - ['Default', CogConfigurator::class], - ['Dynamic', new CogConfigurator('Dynamic')], - ]; - } - - public function test_invalid_configurator_throws_an_exception() - { - $this->expectException(Exception::class); - - UssdFacade::machine() - ->useConfigurator(ByeState::class) - ->setSessionId('1234') - ->setInitialState(HelloState::class) - ->run(); - } -} diff --git a/tests/MenuTest.php b/tests/MenuTest.php deleted file mode 100644 index 100a1d6..0000000 --- a/tests/MenuTest.php +++ /dev/null @@ -1,114 +0,0 @@ -assertEquals('Hello Ussd', $menu); - } - - public function test_it_can_be_converted_to_string_explicitly() - { - $menu = new Menu('Hello Ussd'); - $this->assertEquals('Hello Ussd', $menu->toString()); - } - - public function test_it_can_have_line_break() - { - $menu = new Menu(); - $this->assertEquals("\n", $menu->lineBreak()); - } - - public function test_it_can_have_double_line_break() - { - $menu = new Menu(); - $this->assertEquals("\n\n", $menu->lineBreak(2)); - } - - public function test_it_can_have_text_with_line_break() - { - $menu = new Menu(); - $this->assertEquals("Hello Ussd\n", $menu->line("Hello Ussd")); - } - - public function test_it_can_have_text_with_no_line_break() - { - $menu = new Menu(); - $this->assertEquals("Hello Ussd", $menu->text("Hello Ussd")); - } - - public function test_it_can_parse_a_list_to_string() - { - $menu = new Menu(); - $this->assertEquals( - "1.New Gen\n2.Old Gen", - $menu->listing(['New Gen', 'Old Gen']) - ); - } - - public function test_it_can_paginate_and_parse_a_list_to_string() - { - $menu = new Menu(); - $this->assertEquals( - "3.Extra", - $menu->paginateListing(['New Gen', 'Old Gen', 'Extra'], 2, 2) - ); - } - - public function test_it_can_parse_a_list_to_string_with_alphabets_lower_for_numbering() - { - $menu = new Menu(); - $this->assertEquals( - "a.New Gen\nb.Old Gen", - $menu->listing( - ['New Gen', 'Old Gen'], - Menu::NUMBERING_SEPARATOR_DOT, - Menu::ITEMS_SEPARATOR_LINE_BREAK, - Menu::NUMBERING_ALPHABETIC_LOWER - ) - ); - } - - public function test_it_can_parse_a_list_to_string_with_alphabets_upper_for_numbering() - { - $menu = new Menu(); - $this->assertEquals( - "A.New Gen\nB.Old Gen", - $menu->listing( - ['New Gen', 'Old Gen'], - Menu::NUMBERING_SEPARATOR_DOT, - Menu::ITEMS_SEPARATOR_LINE_BREAK, - Menu::NUMBERING_ALPHABETIC_UPPER - ) - ); - } - - public function test_it_can_parse_a_list_to_string_with_empty_string_for_numbering() - { - $menu = new Menu(); - $this->assertEquals( - ".New Gen\n.Old Gen", - $menu->listing( - ['New Gen', 'Old Gen'], - Menu::NUMBERING_SEPARATOR_DOT, - Menu::ITEMS_SEPARATOR_LINE_BREAK, - Menu::NUMBERING_EMPTY - ) - ); - } - - public function test_method_can_be_chained() - { - $menu = new Menu(); - $this->assertEquals( - "Hello Ussd\n1.Ok\n2.Fine\nBye", - $menu->line('Hello Ussd')->listing(['Ok', 'Fine'])->lineBreak()->text('Bye') - ); - } -} diff --git a/tests/RecordTest.php b/tests/RecordTest.php deleted file mode 100644 index 5eee5e9..0000000 --- a/tests/RecordTest.php +++ /dev/null @@ -1,73 +0,0 @@ -set('name', 'ussd'); - $record->set('author', 'Isaac Sai'); - $record->version = '1.0'; - $this->assertEquals('ussd', $record->name); - $this->assertEquals('Isaac Sai', $record('author')); - $this->assertEquals('1.0', $record->get('version')); - } - - public function test_it_can_set_and_get_multiple_values() - { - $record = new Record(Cache::store('array'), '1'); - $record->setMultiple(['name' => 'ussd', 'version' => '1.0']); - $record(['author' => 'Isaac Sai',]); - $this->assertEquals(['ussd', '1.0', 'Isaac Sai'], $record->getMultiple(['name', 'version', 'author'])); - } - - public function test_it_can_delete_a_value() - { - $record = new Record(Cache::store('array'), '1'); - $record->setMultiple(['name' => 'ussd', 'version' => '1.0', 'author' => 'Isaac Sai']); - $record->delete('name'); - unset($record->author); - $this->assertEquals([null, '1.0', null], $record->getMultiple(['name', 'version', 'author'])); - } - - public function test_it_can_delete_multiple_values() - { - $record = new Record(Cache::store('array'), '1'); - $record->setMultiple(['name' => 'ussd', 'version' => '1.0']); - $record->deleteMultiple(['name', 'version']); - $this->assertEquals([null, null], $record->getMultiple(['name', 'version'])); - } - - public function test_it_can_verify_if_cache_has_value() - { - $record = new Record(Cache::store('array'), '1'); - $record->set('name', 'ussd'); - $record->set('author', 'Isaac Sai'); - $this->assertTrue($record->has('name')); - $this->assertFalse($record->has('version')); - $this->assertTrue(isset($record->name)); - } - - public function test_it_can_increment_or_decrement_a_numeric_value() - { - $record = new Record(Cache::store('array'), '1'); - $record->set('age', 17); - $this->assertEquals(18, $record->increment('age')); - $this->assertEquals(17, $record->decrement('age')); - } - - public function test_it_can_delete_all_values() - { - $record = new Record(Cache::store('array'), '1'); - $record->setMultiple(['name' => 'ussd', 'version' => '1.0']); - $record->flush(); - $this->assertNull($record->get('name')); - $this->assertNull($record->get('version')); - } -} diff --git a/tests/RunAction.php b/tests/RunAction.php deleted file mode 100644 index 0f027e3..0000000 --- a/tests/RunAction.php +++ /dev/null @@ -1,14 +0,0 @@ -assertEquals('Hello World', $hello->render()); - - $this->assertEquals(RunAction::class, $hello->next('1')); - } -} diff --git a/tests/Unit/ActionTest.php b/tests/Unit/ActionTest.php new file mode 100644 index 0000000..cab5a3f --- /dev/null +++ b/tests/Unit/ActionTest.php @@ -0,0 +1,20 @@ +assertEquals(IntermediateState::class, $action->execute()); + } +} diff --git a/tests/Unit/DecisionTest.php b/tests/Unit/DecisionTest.php new file mode 100644 index 0000000..b19a5ca --- /dev/null +++ b/tests/Unit/DecisionTest.php @@ -0,0 +1,28 @@ +assertEquals($decision->decide($value), $bool); + } + + public static function data_between_decision() + { + return [ + [5, true], + [10, true], + [7, true], + [1, false], + [11, false], + ]; + } +} diff --git a/tests/Unit/MenuTest.php b/tests/Unit/MenuTest.php new file mode 100644 index 0000000..1321803 --- /dev/null +++ b/tests/Unit/MenuTest.php @@ -0,0 +1,130 @@ +assertEquals('Hello Ussd', $menu); + } + + public function test_menu_can_be_converted_to_string_explicitly() + { + $menu = new Menu('Hello Ussd'); + $this->assertEquals('Hello Ussd', (string) $menu); + } + + public function test_menu_can_have_line_break() + { + $this->assertEquals("\n", Menu::build()->lineBreak()); + } + + public function test_menu_can_have_double_line_break() + { + $this->assertEquals("\n\n", Menu::build()->lineBreak(2)); + } + + public function test_menu_can_have_text_with_line_break() + { + $this->assertEquals("Hello Ussd\n", Menu::build()->line("Hello Ussd")); + } + + public function test_menu_can_have_text_with_no_line_break() + { + $this->assertEquals("Hello Ussd", Menu::build()->text("Hello Ussd")); + } + + public function test_menu_can_parse_a_list_to_string() + { + $this->assertEquals( + "1.New Gen\n2.Old Gen\n", + Menu::build()->listing(['New Gen', 'Old Gen']) + ); + } + + public function test_menu_can_paginate_and_parse_a_list_to_string() + { + $this->assertEquals( + "3.Extra\n", + Menu::build()->listing(['New Gen', 'Old Gen', 'Extra'], page: 2, perPage: 2) + ); + } + + public function test_menu_can_parse_a_list_to_string_with_custom_numbering() + { + $this->assertEquals( + "a.New Gen\nb.Old Gen\n", + Menu::build()->listing( + ['New Gen', 'Old Gen'], + fn (int $index) => range('a', 'z')[$index] + ) + ); + } + + public function test_menu_method_can_be_chained() + { + $this->assertEquals( + "Hello Ussd\n1.Ok\n2.Fine\n\nBye", + Menu::build()->line('Hello Ussd')->listing(['Ok', 'Fine'])->lineBreak()->text('Bye') + ); + } + + public function test_menu_can_be_appended() + { + $this->assertEquals( + "First Here\nAppended", + Menu::build()->line("First Here")->append(fn (Menu $menu) => $menu->text("Appended")) + ); + } + + public function test_menu_can_be_prepened() + { + $this->assertEquals( + "Prepended\nFirst Here", + Menu::build()->text("First Here")->prepend(fn (Menu $menu) => $menu->line("Prepended")) + ); + } + + /** @dataProvider data_true_or_false */ + public function test_menu_can_conditionally_adjusted_with_when($expected, $condition) + { + $this->assertEquals( + "That...\n{$expected}", + Menu::build() + ->line("That...") + ->when( + $condition, + fn (Menu $menu) => $menu->text("So True"), + fn (Menu $menu) => $menu->text('Whatever') + ) + ); + } + + /** @dataProvider data_true_or_false */ + public function test_menu_can_conditionally_adjusted_with_unless($expected, $condition) + { + $this->assertEquals( + "That...\n{$expected}", + Menu::build() + ->line("That...") + ->unless( + $condition, + fn (Menu $menu) => $menu->text('Whatever'), + fn (Menu $menu) => $menu->text("So True") + ) + ); + } + + public static function data_true_or_false() + { + return [ + ['So True', true], + ['Whatever', false], + ]; + } +} diff --git a/tests/Unit/StateTest.php b/tests/Unit/StateTest.php new file mode 100644 index 0000000..050a414 --- /dev/null +++ b/tests/Unit/StateTest.php @@ -0,0 +1,16 @@ +assertEquals('In the beginning...', $state->render()); + } +} diff --git a/v2.readme.md b/v2.readme.md new file mode 100644 index 0000000..8daf602 --- /dev/null +++ b/v2.readme.md @@ -0,0 +1,354 @@ +# Laravel Ussd + +[![Latest Version on Packagist][ico-version]][link-packagist] +[![Total Downloads][ico-downloads]][link-downloads] +[![Build Status][ico-travis]][link-travis] + +Build Ussd (Unstructured Supplementary Service Data) applications with laravel without breaking a sweat. + +## Installation + +You can install the package via composer: + +``` bash +composer require sparors/laravel-ussd:^2.0 +``` + +Laravel Ussd provides zero configuration out of the box. To publish the config, run the vendor publish command: + +``` bash +php artisan vendor:publish --provider="Sparors\Ussd\UssdServiceProvider" --tag=ussd-config +``` + +## Usage + +### Creating States + +We provide a ussd artisan command which allows you to quickly create new states. + +``` bash +php artisan ussd:state Welcome +``` + +### Creating Nested States + +Linux/Unix + +``` bash +php artisan ussd:state Airtime/Welcome +``` + +Windows + +``` bash +php artisan ussd:state Airtime\Welcome +``` + +Welcome state class generated + +``` php + Available from **v2.0.0** + +We provide a ussd artisan command which allows you to quickly create new actions. + +``` bash +php artisan ussd:action MakePayment +``` + +MakePayment action class generated + +``` php + $this->record->phoneNumber + ]); + + if ($response->ok()) { + return PaymentSuccess::class; + } + + return PaymentError::class; + } +} +``` + +### Creating Menus + +Add your menu to the beforeRendering method + +``` php +record->name; + + $this->menu->text('Welcome To Laravel USSD') + ->lineBreak(2) + ->line('Select an option') + ->listing([ + 'Airtime Topup', + 'Data Bundle', + 'TV Subscription', + 'ECG/GWCL', + 'Talk To Us' + ]) + ->lineBreak(2) + ->text('Powered by Sparors'); + } + + protected function afterRendering(string $argument): void + { + // + } +} +``` + +### Linking States with Decisions + +Add your decision to the afterRendering method and link them with states + +``` php +menu->text('Welcome To Laravel Ussd') + ->lineBreak(2) + ->line('Select an option') + ->listing([ + 'Airtime Topup', + 'Data Bundle', + 'TV Subscription', + 'ECG/GWCL', + 'Talk To Us' + ]) + ->lineBreak(2) + ->text('Powered by Sparors'); + } + + protected function afterRendering(string $argument): void + { + // If input is equal to 1, 2, 3, 4 or 5, render the appropriate state + $this->decision->equal('1', GetRecipientNumber::class) + ->between(2, 5, MaintenanceMode::class) + ->any(Error::class); + } +} +``` + +### Setting Initial State + +Import the welcome state class and pass it to the setInitialState method + +``` php +setFromRequest([ + 'network', + 'phone_number' => 'msisdn', + 'sessionId' => 'UserSessionID', + 'input' => 'msg' + ]) + ->setInitialState(Welcome::class) + ->setResponse(function (string $message, string $action) { + return [ + 'USSDResp' => [ + 'action' => $action, + 'menus' => '', + 'title' => $message + ] + ]; + }); + + return response()->json($ussd->run()); + } +} +``` + +### Simplifying machine with configurator + +> Available from **v2.5.0** + +You can use configurator to simplify repetitive parts of your application so they can be shared easily. Just implement and `Sparors\Ussd\Contracts\Configurator` interface and use it in your machine. +```php +setFromRequest([ + 'network', + 'phone_number' => 'msisdn', + 'sessionId' => 'UserSessionID', + 'input' => 'msg' + ])->setResponse(function (string $message, string $action) { + return [ + 'USSDResp' => [ + 'action' => $action, + 'menus' => '', + 'title' => $message + ] + ]; + }); + } +} +?> +``` +```php +useConfigurator(Nsano::class) + ->setInitialState(Welcome::class); + + return response()->json($ussd->run()); + } +} +?> +``` + + +### Running the application + +You can use the development server the ships with Laravel by running, from the project root: + +``` bash +php artisan serve +``` +You can visit [http://localhost:8000](http://localhost:8000) to see the application in action. + +Enjoy!!! + +### Documentation + +You'll find the documentation on [https://sparors.github.io/ussd-docs](https://sparors.github.io/ussd-docs/). + + +### Testing + +``` bash +$ vendor/bin/phpunit +``` + +### Change log + +Please see the [changelog](changelog.md) for more information on what has changed recently. + +### Contributing + +Please see [contributing.md](contributing.md) for details and a todolist. + +### Security + +If you discover any security related issues, please email isaacsai030@gmail.com instead of using the issue tracker. + +### Credits + +- [Sparors Inc][link-author] +- [All Contributors][link-contributors] + +### License + +MIT. Please see the [license file](LICENSE) for more information. + +[ico-version]: https://img.shields.io/packagist/v/sparors/laravel-ussd.svg?style=flat-square +[ico-downloads]: https://img.shields.io/packagist/dt/sparors/laravel-ussd.svg?style=flat-square +[ico-travis]: https://img.shields.io/travis/sparors/laravel-ussd/master.svg?style=flat-square + +[link-packagist]: https://packagist.org/packages/sparors/laravel-ussd +[link-downloads]: https://packagist.org/packages/sparors/laravel-ussd +[link-travis]: https://travis-ci.com/sparors/laravel-ussd +[link-author]: https://github.com/sparors +[link-contributors]: ../../contributors