├── .codeclimate.yml ├── .gitignore ├── .overcommit.yml ├── .travis.yml ├── LICENSE ├── README.md ├── bin └── specter ├── composer.json ├── examples └── Slim3Route.php ├── phpunit.xml ├── src ├── Helpers │ └── helpers.php ├── Middleware │ ├── SpecterIlluminate.php │ └── SpecterPsr7.php ├── Provider │ ├── Avatar.php │ └── RelatedElement.php ├── Specter.php └── Testing │ └── SpecterTestTrait.php └── tests ├── bootstrap.php ├── constants.php ├── fixture ├── customer.json ├── numbers.json ├── random-element.json ├── related-element.json └── todo.json ├── generators └── todo.php └── src ├── ExampleTest.php ├── Helpers ├── FakerFactory.php ├── IlluminateHttpFactory.php └── PSR7HttpFactory.php ├── IlluminateMiddlewareTest.php ├── Provider ├── AvatarTest.php └── RelatedElementTest.php ├── Psr7MiddlewareTest.php ├── SpecterMiddlewareTestInterface.php ├── SpecterTest.php └── SpecterTestTraitTest.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - php 8 | fixme: 9 | enabled: true 10 | phpmd: 11 | enabled: true 12 | 13 | ratings: 14 | paths: 15 | - "**.php" 16 | 17 | exclude_paths: 18 | - tests/ 19 | - src/Helpers/helpers.php 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/composer,phpstorm,osx 2 | 3 | ### Specter testing files ### 4 | examples/fixtures 5 | 6 | ### PHPUnit ### 7 | build/logs 8 | build/coverage 9 | 10 | 11 | ### Composer ### 12 | composer.phar 13 | composer.lock 14 | /vendor/ 15 | 16 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 17 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 18 | # composer.lock 19 | 20 | 21 | ### PhpStorm ### 22 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 23 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 24 | 25 | # User-specific stuff: 26 | .idea 27 | .idea/workspace.xml 28 | .idea/tasks.xml 29 | .idea/dictionaries 30 | .idea/vcs.xml 31 | .idea/jsLibraryMappings.xml 32 | 33 | # Sensitive or high-churn files: 34 | .idea/dataSources.ids 35 | .idea/dataSources.xml 36 | .idea/dataSources.local.xml 37 | .idea/sqlDataSources.xml 38 | .idea/dynamic.xml 39 | .idea/uiDesigner.xml 40 | 41 | # Gradle: 42 | .idea/gradle.xml 43 | .idea/libraries 44 | 45 | # Mongo Explorer plugin: 46 | .idea/mongoSettings.xml 47 | 48 | ## File-based project format: 49 | *.iws 50 | 51 | ## Plugin-specific files: 52 | 53 | # IntelliJ 54 | /out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | 69 | ### OSX ### 70 | .DS_Store 71 | .AppleDouble 72 | .LSOverride 73 | 74 | # Icon must end with two \r 75 | Icon 76 | 77 | 78 | # Thumbnails 79 | ._* 80 | 81 | # Files that might appear in the root of a volume 82 | .DocumentRevisions-V100 83 | .fseventsd 84 | .Spotlight-V100 85 | .TemporaryItems 86 | .Trashes 87 | .VolumeIcon.icns 88 | 89 | # Directories potentially created on remote AFP share 90 | .AppleDB 91 | .AppleDesktop 92 | Network Trash Folder 93 | Temporary Items 94 | .apdisk 95 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Help Scout PHP Project Commit Hooks 3 | # 4 | # This will extend the default configuration defined in: 5 | # https://github.com/brigade/overcommit/blob/master/config/default.yml 6 | # 7 | # At the topmost level of this YAML file is a key representing type of hook 8 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 9 | # customize each hook, such as whether to only run it on certain files (via 10 | # `include`), whether to only display output if it fails (via `quiet`), etc. 11 | # 12 | # For a complete list of hooks, see: 13 | # https://github.com/brigade/overcommit/tree/master/lib/overcommit/hook 14 | # 15 | # For a complete list of options that you can use to customize hooks, see: 16 | # https://github.com/brigade/overcommit#configuration 17 | # 18 | 19 | # During plugin development, this will prevent signatures from blocking 20 | # execution of those under development 21 | verify_signatures: false 22 | 23 | PrePush: 24 | ProtectedBranches: 25 | enabled: true 26 | branches: ['master'] 27 | 28 | PHPUnit: 29 | enabled: false 30 | command: './vendor/bin/phpunit' 31 | include: '**/*.php' 32 | description: 'Running tests [PHP]' 33 | 34 | PreCommit: 35 | ALL: 36 | problem_on_unmodified_line: ignore 37 | requires_files: true 38 | required: true 39 | quiet: false 40 | exclude: &default_excludes 41 | - 'build/**/*' 42 | - 'vendor/**/*' 43 | 44 | AuthorEmail: 45 | enabled: true 46 | 47 | AuthorName: 48 | enabled: true 49 | 50 | BrokenSymlinks: 51 | enabled: true 52 | 53 | CaseConflicts: 54 | enabled: true 55 | 56 | MergeConflicts: 57 | enabled: true 58 | 59 | TrailingWhitespace: 60 | enabled: false 61 | 62 | FixMe: 63 | enabled: true 64 | 65 | PhpLint: 66 | enabled: true 67 | problem_on_unmodified_line: report 68 | 69 | PhpCs: 70 | enabled: true 71 | problem_on_unmodified_line: ignore 72 | flags: ['--standard=vendor/helpscout/php-standards/HelpScout', '--report=csv', '--warning-severity=0'] 73 | 74 | PostCheckout: 75 | SubmoduleStatus: 76 | enabled: true 77 | 78 | CommitMsg: 79 | CapitalizedSubject: 80 | enabled: true 81 | 82 | EmptyMessage: 83 | enabled: true 84 | 85 | GerritChangeId: 86 | enabled: false 87 | 88 | HardTabs: 89 | enabled: true 90 | 91 | RussianNovel: 92 | enabled: false 93 | 94 | SingleLineSubject: 95 | enabled: true 96 | 97 | SpellCheck: 98 | enabled: false 99 | 100 | TextWidth: 101 | enabled: true 102 | max_subject_width: 50 103 | max_body_width: 72 104 | 105 | TrailingPeriod: 106 | enabled: false 107 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | matrix: 4 | include: 5 | - php: 7.0 6 | - php: 7.1 7 | 8 | before_script: 9 | - composer self-update 10 | - composer install --prefer-source --no-interaction --dev 11 | 12 | script: 13 | - composer test 14 | 15 | after_script: 16 | - vendor/bin/test-reporter --stdout > codeclimate.json 17 | - "curl -X POST -d @codeclimate.json -H 'Content-Type: application/json' -H 'User-Agent: Code Climate (PHP Test Reporter v0.1.1)' https://codeclimate.com/test_reports" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Help Scout 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Specter [![Build Status](https://travis-ci.org/helpscout/specter-php.svg?branch=master)](https://travis-ci.org/helpscout/specter-php) [![Code Climate](https://codeclimate.com/github/helpscout/specter-php/badges/gpa.svg)](https://codeclimate.com/github/helpscout/specter-php) [![Test Coverage](https://codeclimate.com/github/helpscout/specter-php/badges/coverage.svg)](https://codeclimate.com/github/helpscout/specter-php/coverage) 2 | ================================================================================ 3 | > __Mocking and Testing for PHP__ 4 | > Use a single JSON file to generate mock data and as an integration test assertion 5 | 6 | Modern development is complicated. This project decouples front end and back end 7 | development by providing fixture data and a testing spec with a single file. 8 | 9 | 1. Client and Server teams build [a JSON spec file][spec] together 10 | 2. Mock the endpoint, and have it return that spec file and add the 11 | [Specter Middleware][middleware] to convert that spec file into a response 12 | filled with random, but sane, data 13 | 3. The client team can begin development with this endpoint, and iterate over 14 | any changes with the JSON spec. The endpoint delivers real data, and they 15 | can set a `SpecterSeed` header to get repeatable results. 16 | 4. The server team can then implement the actual endpoint to meet that spec at 17 | their own pace, perhaps in the next sprint. They can use the **same** spec 18 | file to drive an PHPUnit integration test by handing the spec file to the 19 | [SpecterTestTrait][testtrait] 20 | 21 | This lets the teams rapidly create an endpoint specification together, the 22 | front end team uses the data from it, and the platform team tests with it. 23 | 24 | ## Installation 25 | 26 | This is available through composer as `helpscout/specter-php`. 27 | 28 | ## Contributing 29 | 1. `git clone` 30 | 2. `composer install` 31 | 3. It will prompt you to please install our commit hooks driven by 32 | [pre-commit][pre-commit]. 33 | 34 | ## Demonstration 35 | 36 | Work together among your development teams to spec a new endpoint and create a 37 | Specter JSON file that defines your new endpoint. This is a Specter JSON file: 38 | ```json 39 | { 40 | "__specter": "Sample customer record", 41 | "id": "@randomDigitNotNull@", 42 | "fname": "@firstName@", 43 | "lname": "@lastName@", 44 | "company": "@company@", 45 | "jobTitle": "@jobTitle@", 46 | "background": "@catchPhrase@", 47 | "address": { 48 | "city": "@city@", 49 | "state": "@stateAbbr@", 50 | "zip": "@postcode@", 51 | "country": "@country@" 52 | }, 53 | "emails": ["@companyEmail@", "@freeEmail@", "@email@" ] 54 | } 55 | ``` 56 | 57 | Add a route to return it and use `SpecterMiddleware` to process it: 58 | ```php 59 | $app->get('/api/v1/customer/{id}', function ($request, $response, $args) { 60 | return $response->withJson(getFixture('customer')); 61 | })->add(new \HelpScout\Specter\SpecterMiddleware); 62 | ``` 63 | 64 | Receive random data from your endpoint that fulfills the JSON and use it to 65 | build out your interface: 66 | ```json 67 | { 68 | "__specter":"Sample customer record", 69 | "id":6, 70 | "fname":"Glenda", 71 | "lname":"Trantow", 72 | "company":"Kerluke, Rodriguez and Wisoky", 73 | "jobTitle":"Power Generating Plant Operator", 74 | "background":"Configurable multi-state standardization", 75 | "address":{ 76 | "city":"Georgiannachester", 77 | "state":"TX", 78 | "zip":"89501", 79 | "country":"Afghanistan" 80 | }, 81 | "emails":[ 82 | "dward@friesen.org", 83 | "nwisozk@gmail.com", 84 | "juliet.dooley@yahoo.com" 85 | ] 86 | } 87 | ``` 88 | 89 | Write a unit test for the endpoint to confirm that it's meeting the spec, and 90 | then implement the endpoint for real: 91 | ```php 92 | use SpecterTestTrait; 93 | 94 | public function testCustomerRouteMeetsSpec() 95 | { 96 | self::assertResponseContent( 97 | $this->client->get('/api/v1/customer/37'), 98 | 'customer' 99 | ); 100 | } 101 | ``` 102 | 103 | ## Custom Formatters 104 | 105 | In addition to the Faker library, Specter provides a few 106 | [other fomatters](https://github.com/helpscout/specter/tree/master/src/Provider) 107 | that offer some useful mocking. 108 | 109 | * `randomRobotAvatar` 110 | * `randomGravatar` 111 | * `relatedElement` 112 | 113 | [spec]: https://raw.githubusercontent.com/helpscout/specter/master/tests/fixture/customer.json 114 | [middleware]: https://github.com/helpscout/specter/blob/master/src/SpecterMiddleware.php 115 | [testtrait]: https://github.com/helpscout/specter/blob/master/src/SpecterTestTrait.php 116 | [pre-commit]: http://pre-commit.com/ 117 | -------------------------------------------------------------------------------- /bin/specter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | Path to the Specter JSON specification file 25 | * - outputFile => Path where the generated fixture file will be saved 26 | * - seed => (nullable) A seed to allow the fixture data to persist 27 | * across regeneration 28 | * - postProcess => (nullable) A callback that can be used to transform the 29 | * output of the fixture before being saved. Note that the 30 | * fixture has been cast to an object, and so it's passed by 31 | * reference. 32 | * 33 | * @author Platform Team 34 | * @copyright 2016 Help Scout 35 | */ 36 | 37 | use HelpScout\Specter\Specter; 38 | 39 | if (version_compare('7.0.0', PHP_VERSION, '>')) { 40 | fwrite( 41 | STDERR, 42 | sprintf( 43 | 'This version of Specter is supported on PHP 7.0 and PHP 7.1.' . PHP_EOL . 44 | 'You are using PHP %s (%s).' . PHP_EOL, 45 | PHP_VERSION, 46 | PHP_BINARY 47 | ) 48 | ); 49 | exit(1); 50 | } 51 | 52 | if (!ini_get('date.timezone')) { 53 | ini_set('date.timezone', 'UTC'); 54 | } 55 | 56 | function includeIfExists($file) 57 | { 58 | if (file_exists($file)) { 59 | return include $file; 60 | } 61 | } 62 | 63 | if ((!$loader = includeIfExists(__DIR__.'/../vendor/autoload.php')) && (!$loader = includeIfExists(__DIR__.'/../../../autoload.php'))) { 64 | fwrite(STDERR, 65 | 'You must set up the project dependencies, run the following commands:'.PHP_EOL. 66 | 'curl -s http://getcomposer.org/installer | php'.PHP_EOL. 67 | 'php composer.phar install'.PHP_EOL 68 | ); 69 | exit(1); 70 | } 71 | 72 | $options = getopt('', ["config::"]); 73 | $configDir = $options['config'] ?? realpath(__DIR__ . '/../tests/generators'); 74 | 75 | foreach (glob($configDir.'/*.php') as $filename) { 76 | echo 'Processing ',basename($filename),"\n"; 77 | $config = include $filename; 78 | $generator = new FixtureGenerator($config); 79 | $generator($config); 80 | } 81 | 82 | class FixtureGenerator 83 | { 84 | private $outputDirectory; 85 | private $specterDirectory; 86 | 87 | public function __construct($config) { 88 | $this->outputDirectory = $config['outputDirectory']; 89 | $this->specterDirectory = $config['specterDirectory']; 90 | } 91 | 92 | public function __invoke($config) 93 | { 94 | $this->processFixtureConfig($config['specs']); 95 | } 96 | 97 | /** 98 | * Create a fixture file for each record in a config array 99 | * 100 | * @param array $specs 101 | */ 102 | private function processFixtureConfig($specs) 103 | { 104 | foreach ($specs as $fixture) { 105 | $this->checkRequiredSettings($fixture); 106 | $json = $this->generateFixture($fixture); 107 | $this->outputSpecFile($fixture['outputFile'], $json); 108 | } 109 | } 110 | 111 | /** 112 | * Generate a fixture file based on a config array. 113 | * 114 | * @param [array] $fixture 115 | * @return object 116 | * @throws Exception 117 | */ 118 | public function generateFixture($fixture) 119 | { 120 | if (isset($fixture['process'])) { 121 | $processFunc = $fixture['process']; 122 | $vars = isset($fixture['vars']) ? $fixture['vars'] : []; 123 | return call_user_func_array($processFunc, [$this, $vars]); 124 | } 125 | 126 | // TODO: Perhaps this should be a default process method 127 | $seed = array_get($fixture, 'seed', null); 128 | $callback = array_get($fixture, 'postProcess'); 129 | $description = array_get($fixture, 'description'); 130 | $processor = new Specter($seed); 131 | $specFile = sprintf( 132 | '%s/%s', 133 | $this->specterDirectory, 134 | $fixture['specterFile'] 135 | ); 136 | 137 | if (!file_exists($specFile)) { 138 | throw new \Exception('Invalid spec file: '.$specFile); 139 | } 140 | 141 | $spec = file_get_contents($specFile); 142 | $spec = json_decode($spec, true); 143 | $spec = $processor->substituteMockData($spec); 144 | 145 | // cast to object 146 | $spec = json_decode(json_encode($spec), false); 147 | 148 | if (is_callable($callback)) { 149 | call_user_func($callback, $spec); 150 | } 151 | 152 | $spec->__specter = $description; 153 | 154 | return $spec; 155 | } 156 | 157 | /** 158 | * Confirm that the config entry has the required elements 159 | * 160 | * @param $fixture 161 | */ 162 | public function checkRequiredSettings($fixture) 163 | { 164 | if (!array_get($fixture, 'specterFile') && !array_get($fixture, 'process')) { 165 | throw new LogicException('Must specify either a specterFile or a process function'); 166 | } 167 | if (!array_get($fixture, 'outputFile')) { 168 | throw new LogicException('An output file path is required'); 169 | } 170 | } 171 | 172 | /** 173 | * @param String $outputFile 174 | * @param object $json 175 | */ 176 | public function outputSpecFile($outputFile, $json) 177 | { 178 | $outputPath = sprintf( 179 | '%s/%s', 180 | $this->outputDirectory, 181 | $outputFile 182 | ); 183 | 184 | $dirname = dirname($outputPath); 185 | if (!file_exists($dirname)) { 186 | mkdir($dirname, 0777, true); 187 | } 188 | 189 | file_put_contents( 190 | $outputPath, 191 | json_encode($json, JSON_PRETTY_PRINT) 192 | ); 193 | 194 | echo ' ',$outputPath, "\n"; 195 | } 196 | } 197 | 198 | /* End of file specter */ 199 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helpscout/specter-php", 3 | "description": "JSON API Mocking and Testing for PHP", 4 | "type": "library", 5 | "license": "MIT", 6 | "minimum-stability": "dev", 7 | "keywords": [ 8 | "psr7", 9 | "middleware", 10 | "fixture", 11 | "Help Scout", 12 | "Laravel", 13 | "Illuminate" 14 | ], 15 | "authors": [ 16 | { 17 | "name": "Platform Group", 18 | "email": "developer@helpscout.net" 19 | } 20 | ], 21 | "support": { 22 | "issues": "https://github.com/helpscout/specter-php/issues" 23 | }, 24 | "autoload": { 25 | "files": [ 26 | "src/Helpers/helpers.php" 27 | ], 28 | "psr-4": { 29 | "HelpScout\\Specter\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "HelpScout\\Specter\\Tests\\": "tests/src", 35 | "HelpScout\\Specter\\Tests\\Helpers\\": "tests/src/helpers" 36 | } 37 | }, 38 | "require": { 39 | "php": "^7.0", 40 | "coduo/php-matcher": "^2.0", 41 | "fzaninotto/faker": "^1.6", 42 | "guzzlehttp/psr7": "^1.3", 43 | "phpspec/php-diff": "^1.1", 44 | "psr/http-message": "^1.0" 45 | }, 46 | "require-dev": { 47 | "codeclimate/php-test-reporter": "dev-master", 48 | "helpscout/php-standards": "^1.0", 49 | "illuminate/http": "5.*", 50 | "jakub-onderka/php-console-highlighter": "^0.3.2", 51 | "jakub-onderka/php-parallel-lint": "^0.9.2", 52 | "phpunit/phpunit": "^6.1", 53 | "squizlabs/php_codesniffer": "^3.0" 54 | }, 55 | "bin": [ 56 | "bin/specter" 57 | ], 58 | "scripts": { 59 | "test": [ 60 | "@lint", 61 | "@sniff", 62 | "@phpunit" 63 | ], 64 | "lint": "vendor/bin/parallel-lint --exclude app --exclude vendor .", 65 | "sniff": "vendor/bin/phpcs --standard=vendor/helpscout/php-standards/HelpScout --warning-severity=0 --extensions=php src tests", 66 | "strict": "vendor/bin/phpcs --standard=vendor/helpscout/php-standards/HelpScout --extensions=php src tests", 67 | "format": "vendor/bin/phpcbf --standard=vendor/helpscout/php-standards/HelpScout --extensions=php src tests", 68 | "phpunit": "vendor/bin/phpunit --verbose", 69 | "phpunit-example": "vendor/bin/phpunit --verbose tests/src/ExampleTest.php", 70 | "coverage": [ 71 | "vendor/bin/phpunit --coverage-html build/coverage", 72 | "php -S localhost:8080 -t build/coverage" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/Slim3Route.php: -------------------------------------------------------------------------------- 1 | group('/api/v1', function () use ($app) { 32 | 33 | /** 34 | * An example customer endpoint, returning a random customer. 35 | */ 36 | $app->get('/customer/{id}', function ($request, $response, $args) { 37 | return $response->withJson(getFixture('customer')); 38 | }); 39 | 40 | })->add(new \HelpScout\Specter\Middleware\SpecterPsr7); 41 | 42 | /* End of file Slim3Route.php */ 43 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | tests/src/ExampleTest.php 16 | 17 | 18 | 19 | 20 | ./src/ 21 | 22 | src/Helpers/helpers.php 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Helpers/helpers.php: -------------------------------------------------------------------------------- 1 | $value) 22 | { 23 | if (is_numeric($key)) 24 | { 25 | $start++; 26 | 27 | $array[$start] = array_pull($array, $key); 28 | } 29 | } 30 | 31 | return $array; 32 | } 33 | } 34 | 35 | if ( ! function_exists('array_add')) 36 | { 37 | /** 38 | * Add an element to an array using "dot" notation if it doesn't exist. 39 | * 40 | * @param array $array 41 | * @param string $key 42 | * @param mixed $value 43 | * @return array 44 | */ 45 | function array_add($array, $key, $value) 46 | { 47 | if (is_null(get($array, $key))) 48 | { 49 | set($array, $key, $value); 50 | } 51 | 52 | return $array; 53 | } 54 | } 55 | 56 | if ( ! function_exists('array_build')) 57 | { 58 | /** 59 | * Build a new array using a callback. 60 | * 61 | * @param array $array 62 | * @param \Closure $callback 63 | * @return array 64 | */ 65 | function array_build($array, Closure $callback) 66 | { 67 | $results = array(); 68 | 69 | foreach ($array as $key => $value) 70 | { 71 | list($innerKey, $innerValue) = call_user_func($callback, $key, $value); 72 | 73 | $results[$innerKey] = $innerValue; 74 | } 75 | 76 | return $results; 77 | } 78 | } 79 | 80 | if ( ! function_exists('array_divide')) 81 | { 82 | /** 83 | * Divide an array into two arrays. One with keys and the other with values. 84 | * 85 | * @param array $array 86 | * @return array 87 | */ 88 | function array_divide($array) 89 | { 90 | return array(array_keys($array), array_values($array)); 91 | } 92 | } 93 | 94 | if ( ! function_exists('array_dot')) 95 | { 96 | /** 97 | * Flatten a multi-dimensional associative array with dots. 98 | * 99 | * @param array $array 100 | * @param string $prepend 101 | * @return array 102 | */ 103 | function array_dot($array, $prepend = '') 104 | { 105 | $results = array(); 106 | 107 | foreach ($array as $key => $value) 108 | { 109 | if (is_array($value)) 110 | { 111 | $results = array_merge($results, dot($value, $prepend.$key.'.')); 112 | } 113 | else 114 | { 115 | $results[$prepend.$key] = $value; 116 | } 117 | } 118 | 119 | return $results; 120 | } 121 | } 122 | 123 | if ( ! function_exists('array_except')) 124 | { 125 | /** 126 | * Get all of the given array except for a specified array of items. 127 | * 128 | * @param array $array 129 | * @param array|string $keys 130 | * @return array 131 | */ 132 | function array_except($array, $keys) 133 | { 134 | return array_diff_key($array, array_flip((array) $keys)); 135 | } 136 | } 137 | 138 | if ( ! function_exists('array_fetch')) 139 | { 140 | /** 141 | * Fetch a flattened array of a nested array element. 142 | * 143 | * @param array $array 144 | * @param string $key 145 | * @return array 146 | */ 147 | function array_fetch($array, $key) 148 | { 149 | $results = array(); 150 | 151 | foreach (explode('.', $key) as $segment) 152 | { 153 | foreach ($array as $value) 154 | { 155 | if (array_key_exists($segment, $value = (array) $value)) 156 | { 157 | $results[] = $value[$segment]; 158 | } 159 | } 160 | 161 | $array = array_values($results); 162 | } 163 | 164 | return array_values($results); 165 | } 166 | } 167 | 168 | if ( ! function_exists('array_first')) 169 | { 170 | /** 171 | * Return the first element in an array passing a given truth test. 172 | * 173 | * @param array $array 174 | * @param \Closure $callback 175 | * @param mixed $default 176 | * @return mixed 177 | */ 178 | function array_first($array, $callback, $default = null) 179 | { 180 | foreach ($array as $key => $value) 181 | { 182 | if (call_user_func($callback, $key, $value)) return $value; 183 | } 184 | 185 | return value($default); 186 | } 187 | } 188 | 189 | if ( ! function_exists('array_last')) 190 | { 191 | /** 192 | * Return the last element in an array passing a given truth test. 193 | * 194 | * @param array $array 195 | * @param \Closure $callback 196 | * @param mixed $default 197 | * @return mixed 198 | */ 199 | function array_last($array, $callback, $default = null) 200 | { 201 | return first(array_reverse($array), $callback, $default); 202 | } 203 | } 204 | 205 | if ( ! function_exists('array_flatten')) 206 | { 207 | /** 208 | * Flatten a multi-dimensional array into a single level. 209 | * 210 | * @param array $array 211 | * @return array 212 | */ 213 | function array_flatten($array) 214 | { 215 | $return = array(); 216 | 217 | array_walk_recursive($array, function($x) use (&$return) { $return[] = $x; }); 218 | 219 | return $return; 220 | } 221 | } 222 | 223 | if ( ! function_exists('array_forget')) 224 | { 225 | /** 226 | * Remove one or many array items from a given array using "dot" notation. 227 | * 228 | * @param array $array 229 | * @param array|string $keys 230 | * @return void 231 | */ 232 | function array_forget(&$array, $keys) 233 | { 234 | $original =& $array; 235 | 236 | foreach ((array) $keys as $key) 237 | { 238 | $parts = explode('.', $key); 239 | 240 | while (count($parts) > 1) 241 | { 242 | $part = array_shift($parts); 243 | 244 | if (isset($array[$part]) && is_array($array[$part])) 245 | { 246 | $array =& $array[$part]; 247 | } 248 | } 249 | 250 | unset($array[array_shift($parts)]); 251 | 252 | // clean up after each pass 253 | $array =& $original; 254 | } 255 | } 256 | } 257 | 258 | if ( ! function_exists('array_get')) 259 | { 260 | /** 261 | * Get an item from an array using "dot" notation. 262 | * 263 | * @param array $array 264 | * @param string $key 265 | * @param mixed $default 266 | * @return mixed 267 | */ 268 | function array_get($array, $key, $default = null) 269 | { 270 | if (is_null($key)) return $array; 271 | 272 | if (isset($array[$key])) return $array[$key]; 273 | 274 | foreach (explode('.', $key) as $segment) 275 | { 276 | if ( ! is_array($array) || ! array_key_exists($segment, $array)) 277 | { 278 | return value($default); 279 | } 280 | 281 | $array = $array[$segment]; 282 | } 283 | 284 | return $array; 285 | } 286 | } 287 | 288 | if ( ! function_exists('array_has')) 289 | { 290 | /** 291 | * Check if an item exists in an array using "dot" notation. 292 | * 293 | * @param array $array 294 | * @param string $key 295 | * @return bool 296 | */ 297 | function array_has($array, $key) 298 | { 299 | if (empty($array) || is_null($key)) return false; 300 | 301 | if (array_key_exists($key, $array)) return true; 302 | 303 | foreach (explode('.', $key) as $segment) 304 | { 305 | if ( ! is_array($array) || ! array_key_exists($segment, $array)) 306 | { 307 | return false; 308 | } 309 | 310 | $array = $array[$segment]; 311 | } 312 | 313 | return true; 314 | } 315 | } 316 | 317 | if ( ! function_exists('array_only')) 318 | { 319 | /** 320 | * Get a subset of the items from the given array. 321 | * 322 | * @param array $array 323 | * @param array|string $keys 324 | * @return array 325 | */ 326 | function array_only($array, $keys) 327 | { 328 | return array_intersect_key($array, array_flip((array) $keys)); 329 | } 330 | } 331 | 332 | if ( ! function_exists('array_pluck')) 333 | { 334 | /** 335 | * Pluck an array of values from an array. 336 | * 337 | * @param array $array 338 | * @param string $value 339 | * @param string $key 340 | * @return array 341 | */ 342 | function array_pluck($array, $value, $key = null) 343 | { 344 | $results = array(); 345 | 346 | foreach ($array as $item) 347 | { 348 | $itemValue = data_get($item, $value); 349 | 350 | // If the key is "null", we will just append the value to the array and keep 351 | // looping. Otherwise we will key the array using the value of the key we 352 | // received from the developer. Then we'll return the final array form. 353 | if (is_null($key)) 354 | { 355 | $results[] = $itemValue; 356 | } 357 | else 358 | { 359 | $itemKey = data_get($item, $key); 360 | 361 | $results[$itemKey] = $itemValue; 362 | } 363 | } 364 | 365 | return $results; 366 | } 367 | } 368 | 369 | if ( ! function_exists('array_pull')) 370 | { 371 | /** 372 | * Get a value from the array, and remove it. 373 | * 374 | * @param array $array 375 | * @param string $key 376 | * @param mixed $default 377 | * @return mixed 378 | */ 379 | function array_pull(&$array, $key, $default = null) 380 | { 381 | $value = get($array, $key, $default); 382 | 383 | forget($array, $key); 384 | 385 | return $value; 386 | } 387 | } 388 | 389 | if ( ! function_exists('array_set')) 390 | { 391 | /** 392 | * Set an array item to a given value using "dot" notation. 393 | * 394 | * If no key is given to the method, the entire array will be replaced. 395 | * 396 | * @param array $array 397 | * @param string $key 398 | * @param mixed $value 399 | * @return array 400 | */ 401 | function array_set(&$array, $key, $value) 402 | { 403 | if (is_null($key)) return $array = $value; 404 | 405 | $keys = explode('.', $key); 406 | 407 | while (count($keys) > 1) 408 | { 409 | $key = array_shift($keys); 410 | 411 | // If the key doesn't exist at this depth, we will just create an empty array 412 | // to hold the next value, allowing us to create the arrays to hold final 413 | // values at the correct depth. Then we'll keep digging into the array. 414 | if ( ! isset($array[$key]) || ! is_array($array[$key])) 415 | { 416 | $array[$key] = array(); 417 | } 418 | 419 | $array =& $array[$key]; 420 | } 421 | 422 | $array[array_shift($keys)] = $value; 423 | 424 | return $array; 425 | } 426 | } 427 | 428 | if ( ! function_exists('array_where')) 429 | { 430 | /** 431 | * Filter the array using the given Closure. 432 | * 433 | * @param array $array 434 | * @param \Closure $callback 435 | * @return array 436 | */ 437 | function array_where($array, Closure $callback) 438 | { 439 | $filtered = array(); 440 | 441 | foreach ($array as $key => $value) 442 | { 443 | if (call_user_func($callback, $key, $value)) $filtered[$key] = $value; 444 | } 445 | 446 | return $filtered; 447 | } 448 | } 449 | 450 | if ( ! function_exists('camel_case')) 451 | { 452 | /** 453 | * Convert a value to camel case. 454 | * 455 | * @param string $value 456 | * @return string 457 | */ 458 | function camel_case($value) 459 | { 460 | $camelCache = []; 461 | 462 | if (isset($camelCache[$value])) 463 | { 464 | return $camelCache[$value]; 465 | } 466 | 467 | return $camelCache[$value] = lcfirst(studly($value)); 468 | } 469 | } 470 | 471 | if ( ! function_exists('class_basename')) 472 | { 473 | /** 474 | * Get the class "basename" of the given object / class. 475 | * 476 | * @param string|object $class 477 | * @return string 478 | */ 479 | function class_basename($class) 480 | { 481 | $class = is_object($class) ? get_class($class) : $class; 482 | 483 | return basename(str_replace('\\', '/', $class)); 484 | } 485 | } 486 | 487 | if ( ! function_exists('class_uses_recursive')) 488 | { 489 | /** 490 | * Returns all traits used by a class, it's subclasses and trait of their traits 491 | * 492 | * @param string $class 493 | * @return array 494 | */ 495 | function class_uses_recursive($class) 496 | { 497 | $results = []; 498 | 499 | foreach (array_merge([$class => $class], class_parents($class)) as $class) 500 | { 501 | $results += trait_uses_recursive($class); 502 | } 503 | 504 | return array_unique($results); 505 | } 506 | } 507 | 508 | if ( ! function_exists('data_get')) 509 | { 510 | /** 511 | * Get an item from an array or object using "dot" notation. 512 | * 513 | * @param mixed $target 514 | * @param string $key 515 | * @param mixed $default 516 | * @return mixed 517 | */ 518 | function data_get($target, $key, $default = null) 519 | { 520 | if (is_null($key)) return $target; 521 | 522 | foreach (explode('.', $key) as $segment) 523 | { 524 | if (is_array($target)) 525 | { 526 | if ( ! array_key_exists($segment, $target)) 527 | { 528 | return value($default); 529 | } 530 | 531 | $target = $target[$segment]; 532 | } 533 | elseif ($target instanceof ArrayAccess) 534 | { 535 | if ( ! isset($target[$segment])) 536 | { 537 | return value($default); 538 | } 539 | 540 | $target = $target[$segment]; 541 | } 542 | elseif (is_object($target)) 543 | { 544 | if ( ! isset($target->{$segment})) 545 | { 546 | return value($default); 547 | } 548 | 549 | $target = $target->{$segment}; 550 | } 551 | else 552 | { 553 | return value($default); 554 | } 555 | } 556 | 557 | return $target; 558 | } 559 | } 560 | 561 | if ( ! function_exists('e')) 562 | { 563 | /** 564 | * Escape HTML entities in a string. 565 | * 566 | * @param string $value 567 | * @return string 568 | */ 569 | function e($value) 570 | { 571 | return htmlentities($value, ENT_QUOTES, 'UTF-8', false); 572 | } 573 | } 574 | 575 | if ( ! function_exists('ends_with')) 576 | { 577 | /** 578 | * Determine if a given string ends with a given substring. 579 | * 580 | * @param string $haystack 581 | * @param string|array $needles 582 | * @return bool 583 | */ 584 | function ends_with($haystack, $needles) 585 | { 586 | foreach ((array) $needles as $needle) 587 | { 588 | if ((string) $needle === substr($haystack, -strlen($needle))) return true; 589 | } 590 | 591 | return false; 592 | } 593 | } 594 | 595 | if ( ! function_exists('head')) 596 | { 597 | /** 598 | * Get the first element of an array. Useful for method chaining. 599 | * 600 | * @param array $array 601 | * @return mixed 602 | */ 603 | function head($array) 604 | { 605 | return reset($array); 606 | } 607 | } 608 | 609 | if ( ! function_exists('last')) 610 | { 611 | /** 612 | * Get the last element from an array. 613 | * 614 | * @param array $array 615 | * @return mixed 616 | */ 617 | function last($array) 618 | { 619 | return end($array); 620 | } 621 | } 622 | 623 | if ( ! function_exists('object_get')) 624 | { 625 | /** 626 | * Get an item from an object using "dot" notation. 627 | * 628 | * @param object $object 629 | * @param string $key 630 | * @param mixed $default 631 | * @return mixed 632 | */ 633 | function object_get($object, $key, $default = null) 634 | { 635 | if (is_null($key) || trim($key) == '') return $object; 636 | 637 | foreach (explode('.', $key) as $segment) 638 | { 639 | if ( ! is_object($object) || ! isset($object->{$segment})) 640 | { 641 | return value($default); 642 | } 643 | 644 | $object = $object->{$segment}; 645 | } 646 | 647 | return $object; 648 | } 649 | } 650 | 651 | if ( ! function_exists('preg_replace_sub')) 652 | { 653 | /** 654 | * Replace a given pattern with each value in the array in sequentially. 655 | * 656 | * @param string $pattern 657 | * @param array $replacements 658 | * @param string $subject 659 | * @return string 660 | */ 661 | function preg_replace_sub($pattern, &$replacements, $subject) 662 | { 663 | return preg_replace_callback($pattern, function() use (&$replacements) 664 | { 665 | return array_shift($replacements); 666 | 667 | }, $subject); 668 | } 669 | } 670 | 671 | if ( ! function_exists('snake_case')) 672 | { 673 | /** 674 | * Convert a string to snake case. 675 | * 676 | * @param string $value 677 | * @param string $delimiter 678 | * @return string 679 | */ 680 | function snake_case($value, $delimiter = '_') 681 | { 682 | $snakeCache = []; 683 | $key = $value.$delimiter; 684 | 685 | if (isset($snakeCache[$key])) 686 | { 687 | return $snakeCache[$key]; 688 | } 689 | 690 | if ( ! ctype_lower($value)) 691 | { 692 | $value = strtolower(preg_replace('/(.)(?=[A-Z])/', '$1'.$delimiter, $value)); 693 | } 694 | 695 | return $snakeCache[$key] = $value; 696 | } 697 | } 698 | 699 | if ( ! function_exists('starts_with')) 700 | { 701 | /** 702 | * Determine if a given string starts with a given substring. 703 | * 704 | * @param string $haystack 705 | * @param string|array $needles 706 | * @return bool 707 | */ 708 | function starts_with($haystack, $needles) 709 | { 710 | foreach ((array) $needles as $needle) 711 | { 712 | if ($needle != '' && strpos($haystack, $needle) === 0) return true; 713 | } 714 | 715 | return false; 716 | } 717 | } 718 | 719 | if ( ! function_exists('str_contains')) 720 | { 721 | /** 722 | * Determine if a given string contains a given substring. 723 | * 724 | * @param string $haystack 725 | * @param string|array $needles 726 | * @return bool 727 | */ 728 | function str_contains($haystack, $needles) 729 | { 730 | foreach ((array) $needles as $needle) 731 | { 732 | if ($needle != '' && strpos($haystack, $needle) !== false) return true; 733 | } 734 | 735 | return false; 736 | } 737 | } 738 | 739 | if ( ! function_exists('str_finish')) 740 | { 741 | /** 742 | * Cap a string with a single instance of a given value. 743 | * 744 | * @param string $value 745 | * @param string $cap 746 | * @return string 747 | */ 748 | function str_finish($value, $cap) 749 | { 750 | $quoted = preg_quote($cap, '/'); 751 | 752 | return preg_replace('/(?:'.$quoted.')+$/', '', $value).$cap; 753 | } 754 | } 755 | 756 | if ( ! function_exists('str_is')) 757 | { 758 | /** 759 | * Determine if a given string matches a given pattern. 760 | * 761 | * @param string $pattern 762 | * @param string $value 763 | * @return bool 764 | */ 765 | function str_is($pattern, $value) 766 | { 767 | if ($pattern == $value) return true; 768 | 769 | $pattern = preg_quote($pattern, '#'); 770 | 771 | // Asterisks are translated into zero-or-more regular expression wildcards 772 | // to make it convenient to check if the strings starts with the given 773 | // pattern such as "library/*", making any string check convenient. 774 | $pattern = str_replace('\*', '.*', $pattern).'\z'; 775 | 776 | return (bool) preg_match('#^'.$pattern.'#', $value); 777 | } 778 | } 779 | 780 | if ( ! function_exists('str_limit')) 781 | { 782 | /** 783 | * Limit the number of characters in a string. 784 | * 785 | * @param string $value 786 | * @param int $limit 787 | * @param string $end 788 | * @return string 789 | */ 790 | function str_limit($value, $limit = 100, $end = '...') 791 | { 792 | if (mb_strlen($value) <= $limit) return $value; 793 | 794 | return rtrim(mb_substr($value, 0, $limit, 'UTF-8')).$end; 795 | } 796 | } 797 | 798 | if ( ! function_exists('str_random')) 799 | { 800 | /** 801 | * Generate a more truly "random" alpha-numeric string. 802 | * 803 | * @param int $length 804 | * @return string 805 | * 806 | * @throws \RuntimeException 807 | */ 808 | function str_random($length = 16) 809 | { 810 | if ( ! function_exists('openssl_random_pseudo_bytes')) 811 | { 812 | throw new RuntimeException('OpenSSL extension is required.'); 813 | } 814 | 815 | $bytes = openssl_random_pseudo_bytes($length * 2); 816 | 817 | if ($bytes === false) 818 | { 819 | throw new RuntimeException('Unable to generate random string.'); 820 | } 821 | 822 | return substr(str_replace(array('/', '+', '='), '', base64_encode($bytes)), 0, $length); 823 | } 824 | } 825 | 826 | if ( ! function_exists('str_replace_array')) 827 | { 828 | /** 829 | * Replace a given value in the string sequentially with an array. 830 | * 831 | * @param string $search 832 | * @param array $replace 833 | * @param string $subject 834 | * @return string 835 | */ 836 | function str_replace_array($search, array $replace, $subject) 837 | { 838 | foreach ($replace as $value) 839 | { 840 | $subject = preg_replace('/'.$search.'/', $value, $subject, 1); 841 | } 842 | 843 | return $subject; 844 | } 845 | } 846 | 847 | if ( ! function_exists('studly_case')) 848 | { 849 | /** 850 | * Convert a value to studly caps case. 851 | * 852 | * @param string $value 853 | * @return string 854 | */ 855 | function studly_case($value) 856 | { 857 | $studlyCache = []; 858 | $key = $value; 859 | 860 | if (isset($studlyCache[$key])) 861 | { 862 | return $studlyCache[$key]; 863 | } 864 | 865 | $value = ucwords(str_replace(array('-', '_'), ' ', $value)); 866 | 867 | return $studlyCache[$key] = str_replace(' ', '', $value); 868 | } 869 | } 870 | 871 | if ( ! function_exists('trait_uses_recursive')) 872 | { 873 | /** 874 | * Returns all traits used by a trait and its traits 875 | * 876 | * @param string $trait 877 | * @return array 878 | */ 879 | function trait_uses_recursive($trait) 880 | { 881 | $traits = class_uses($trait); 882 | 883 | foreach ($traits as $trait) 884 | { 885 | $traits += trait_uses_recursive($trait); 886 | } 887 | 888 | return $traits; 889 | } 890 | } 891 | 892 | if ( ! function_exists('value')) 893 | { 894 | /** 895 | * Return the default value of the given value. 896 | * 897 | * @param mixed $value 898 | * @return mixed 899 | */ 900 | function value($value) 901 | { 902 | return $value instanceof Closure ? $value() : $value; 903 | } 904 | } 905 | 906 | if ( ! function_exists('with')) 907 | { 908 | /** 909 | * Return the given object. Useful for chaining. 910 | * 911 | * @param mixed $object 912 | * @return mixed 913 | */ 914 | function with($object) 915 | { 916 | return $object; 917 | } 918 | } 919 | 920 | /** 921 | * Helper functions for the helper functions, that can still be used standalone 922 | */ 923 | if ( ! function_exists('studly')) 924 | { 925 | /** 926 | * Convert a value to studly caps case. 927 | * 928 | * @param string $value 929 | * @return string 930 | */ 931 | function studly($value) 932 | { 933 | $studlyCache = []; 934 | $key = $value; 935 | 936 | if (isset($studlyCache[$key])) 937 | { 938 | return $studlyCache[$key]; 939 | } 940 | 941 | $value = ucwords(str_replace(array('-', '_'), ' ', $value)); 942 | 943 | return $studlyCache[$key] = str_replace(' ', '', $value); 944 | } 945 | } 946 | 947 | if ( ! function_exists('get')) 948 | { 949 | /** 950 | * Get an item from an array using "dot" notation. 951 | * 952 | * @param array $array 953 | * @param string $key 954 | * @param mixed $default 955 | * @return mixed 956 | */ 957 | function get($array, $key, $default = null) 958 | { 959 | if (is_null($key)) return $array; 960 | 961 | if (isset($array[$key])) return $array[$key]; 962 | 963 | foreach (explode('.', $key) as $segment) 964 | { 965 | if ( ! is_array($array) || ! array_key_exists($segment, $array)) 966 | { 967 | return value($default); 968 | } 969 | 970 | $array = $array[$segment]; 971 | } 972 | 973 | return $array; 974 | } 975 | } 976 | 977 | if ( ! function_exists('set')) 978 | { 979 | /** 980 | * Set an array item to a given value using "dot" notation. 981 | * 982 | * If no key is given to the method, the entire array will be replaced. 983 | * 984 | * @param array $array 985 | * @param string $key 986 | * @param mixed $value 987 | * @return array 988 | */ 989 | function set(&$array, $key, $value) 990 | { 991 | if (is_null($key)) return $array = $value; 992 | 993 | $keys = explode('.', $key); 994 | 995 | while (count($keys) > 1) 996 | { 997 | $key = array_shift($keys); 998 | 999 | // If the key doesn't exist at this depth, we will just create an empty array 1000 | // to hold the next value, allowing us to create the arrays to hold final 1001 | // values at the correct depth. Then we'll keep digging into the array. 1002 | if ( ! isset($array[$key]) || ! is_array($array[$key])) 1003 | { 1004 | $array[$key] = array(); 1005 | } 1006 | 1007 | $array =& $array[$key]; 1008 | } 1009 | 1010 | $array[array_shift($keys)] = $value; 1011 | 1012 | return $array; 1013 | } 1014 | } 1015 | 1016 | if ( ! function_exists('dot')) 1017 | { 1018 | /** 1019 | * Flatten a multi-dimensional associative array with dots. 1020 | * 1021 | * @param array $array 1022 | * @param string $prepend 1023 | * @return array 1024 | */ 1025 | function dot($array, $prepend = '') 1026 | { 1027 | $results = array(); 1028 | 1029 | foreach ($array as $key => $value) 1030 | { 1031 | if (is_array($value)) 1032 | { 1033 | $results = array_merge($results, dot($value, $prepend.$key.'.')); 1034 | } 1035 | else 1036 | { 1037 | $results[$prepend.$key] = $value; 1038 | } 1039 | } 1040 | 1041 | return $results; 1042 | } 1043 | } 1044 | 1045 | if ( ! function_exists('first')) 1046 | { 1047 | /** 1048 | * Return the first element in an array passing a given truth test. 1049 | * 1050 | * @param array $array 1051 | * @param \Closure $callback 1052 | * @param mixed $default 1053 | * @return mixed 1054 | */ 1055 | function first($array, $callback, $default = null) 1056 | { 1057 | foreach ($array as $key => $value) 1058 | { 1059 | if (call_user_func($callback, $key, $value)) return $value; 1060 | } 1061 | 1062 | return value($default); 1063 | } 1064 | } 1065 | 1066 | if ( ! function_exists('forget')) 1067 | { 1068 | /** 1069 | * Remove one or many array items from a given array using "dot" notation. 1070 | * 1071 | * @param array $array 1072 | * @param array|string $keys 1073 | * @return void 1074 | */ 1075 | function forget(&$array, $keys) 1076 | { 1077 | $original =& $array; 1078 | 1079 | foreach ((array) $keys as $key) 1080 | { 1081 | $parts = explode('.', $key); 1082 | 1083 | while (count($parts) > 1) 1084 | { 1085 | $part = array_shift($parts); 1086 | 1087 | if (isset($array[$part]) && is_array($array[$part])) 1088 | { 1089 | $array =& $array[$part]; 1090 | } 1091 | } 1092 | 1093 | unset($array[array_shift($parts)]); 1094 | 1095 | // clean up after each pass 1096 | $array =& $original; 1097 | } 1098 | } 1099 | } 1100 | 1101 | // @codingStandardsIgnoreEnd 1102 | -------------------------------------------------------------------------------- /src/Middleware/SpecterIlluminate.php: -------------------------------------------------------------------------------- 1 | getContent(), true); 36 | 37 | if (json_last_error() !== JSON_ERROR_NONE) { 38 | throw new LogicException( 39 | 'Failed to parse json string. Error: '.json_last_error_msg() 40 | ); 41 | } 42 | 43 | // We will not process files without the Specter trigger, and instead 44 | // return an unchanged response. 45 | if (!array_key_exists($this->specterTrigger, $fixture)) { 46 | return $response; 47 | } 48 | 49 | // Process the fixture data, using a seed in case the designer wants 50 | // a repeatable result. 51 | $seed = $request->header('SpecterSeed', 0); 52 | $specter = new Specter($seed); 53 | 54 | $json = $specter->substituteMockData($fixture); 55 | 56 | return $response->setContent(json_encode($json)); 57 | } 58 | } 59 | 60 | /* End of file SpecterIlluminate.php */ 61 | -------------------------------------------------------------------------------- /src/Middleware/SpecterPsr7.php: -------------------------------------------------------------------------------- 1 | 8 | * @copyright 2016 Help Scout 9 | */ 10 | namespace HelpScout\Specter\Middleware; 11 | 12 | use HelpScout\Specter\Specter; 13 | use InvalidArgumentException; 14 | use LogicException; 15 | use Psr\Http\Message\ResponseInterface; 16 | use Psr\Http\Message\RequestInterface; 17 | use GuzzleHttp\Psr7; 18 | 19 | /** 20 | * Class SpecterPsr7 21 | */ 22 | class SpecterPsr7 23 | { 24 | /** 25 | * JSON must have this property to trigger processing 26 | * 27 | * @var string 28 | */ 29 | private $specterTrigger = '__specter'; 30 | 31 | /** 32 | * Specter JSON Fake Data 33 | * 34 | * The route should return json data of Specter format, and this middleware 35 | * will substitute fake data into it. 36 | * 37 | * @param RequestInterface $request PSR7 request 38 | * @param ResponseInterface $response PSR7 response 39 | * @param callable $next Next middleware 40 | * 41 | * @return ResponseInterface 42 | * @throws InvalidArgumentException 43 | * @throws LogicException 44 | */ 45 | public function __invoke( 46 | RequestInterface $request, 47 | ResponseInterface $response, 48 | callable $next 49 | ) { 50 | /** 51 | * We are a post processor, and so we run the $next immediately 52 | * 53 | * @var ResponseInterface $response 54 | */ 55 | $response = $next($request, $response); 56 | 57 | // Decode the json returned by the route and prepare it for mock data 58 | // processing. 59 | $fixture = @json_decode($response->getBody()->getContents(), true); 60 | if (json_last_error() !== JSON_ERROR_NONE) { 61 | throw new LogicException( 62 | 'Failed to parse json string. Error: '.json_last_error_msg() 63 | ); 64 | } 65 | 66 | // We will not process files without the Specter trigger, and instead 67 | // return an unchanged response. 68 | if (!array_key_exists($this->specterTrigger, $fixture)) { 69 | return $response; 70 | } 71 | 72 | // Process the fixture data, using a seed in case the designer wants 73 | // a repeatable result. 74 | $seed = $request->getHeader('SpecterSeed'); 75 | $specter = new Specter(array_shift($seed)); 76 | $json = $specter->substituteMockData($fixture); 77 | 78 | // Prepare a fresh body stream 79 | $data = json_encode($json); 80 | $stream = Psr7\stream_for($data); 81 | 82 | // Return an immutable body in a cloned $request object 83 | return $response->withBody($stream); 84 | } 85 | } 86 | 87 | /* End of file SpecterPsr7.php */ 88 | -------------------------------------------------------------------------------- /src/Provider/Avatar.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | namespace HelpScout\Specter\Provider; 9 | 10 | use Faker\Provider\Base; 11 | 12 | /** 13 | * Class Specter 14 | * 15 | * @package HelpScout\Specter 16 | */ 17 | class Avatar extends Base 18 | { 19 | /** 20 | * Returns a random and cute robot avatar. 21 | * 22 | * @return string url to image 23 | */ 24 | public function randomRobotAvatar() 25 | { 26 | return sprintf( 27 | 'https://robohash.org/%s.png', 28 | $this->generator->uuid 29 | ); 30 | } 31 | 32 | /** 33 | * Return a randomly generate avatar from the Gravatar service. 34 | * 35 | * Valid types: 36 | * - mm: (mystery-man) a silhouetted outline of a person (does not vary by email hash) 37 | * - identicon: a geometric pattern based on an email hash 38 | * - monsterid: a generated 'monster' with different colors, faces, etc 39 | * - wavatar: generated faces with differing features and backgrounds 40 | * - retro: awesome generated, 8-bit arcade-style pixelated faces 41 | * 42 | * @param string|null $type Art type for the avatar 43 | * @param int|null $size Pixel width dimension 44 | * 45 | * @return string url to image 46 | */ 47 | public function randomGravatar($type = 'identicon', $size = 255) 48 | { 49 | // Gravatar style hash with a random email address 50 | $hash = md5(strtolower(trim($this->generator->companyEmail))); 51 | 52 | return sprintf( 53 | 'https://www.gravatar.com/avatar/%s?d=%s&f=y&s=%d', 54 | $hash, 55 | $type, 56 | $size 57 | ); 58 | } 59 | } 60 | 61 | /* End of file Avatar.php */ 62 | -------------------------------------------------------------------------------- /src/Provider/RelatedElement.php: -------------------------------------------------------------------------------- 1 | 19 | * @copyright 2016 Help Scout 20 | */ 21 | namespace HelpScout\Specter\Provider; 22 | 23 | use Faker\Generator; 24 | use Faker\Provider\Base; 25 | use InvalidArgumentException; 26 | 27 | /** 28 | * Class RelatedElement 29 | * 30 | * @package HelpScout\Specter\Provider 31 | */ 32 | class RelatedElement extends Base 33 | { 34 | /** 35 | * JSON fixture trigger to locate the faker producers. 36 | * 37 | * This should be set by the parent Specter class 38 | * 39 | * @var string 40 | */ 41 | protected $trigger; 42 | 43 | /** 44 | * @param Generator $generator 45 | * @param string|null $trigger 46 | */ 47 | public function __construct(Generator $generator, $trigger = '@') 48 | { 49 | parent::__construct($generator); 50 | $this->trigger = $trigger; 51 | } 52 | 53 | /** 54 | * Select the correct option based on a related element in the fixture 55 | * 56 | * This is a little awkward for Faker, as it has some extra options 57 | * 58 | * @param string $relatedTo Fixture key that this element is related to 59 | * @param array $fixture Current fixture data 60 | * @param array $options Available options 61 | * 62 | * @return string 63 | */ 64 | public function relatedElement( 65 | string $relatedTo, 66 | array $fixture, 67 | array $options 68 | ) { 69 | if (!array_key_exists($relatedTo, $fixture)) { 70 | return 'Invalid related to key: '.$relatedTo; 71 | } 72 | 73 | if (!array_key_exists($fixture[$relatedTo], $options)) { 74 | return sprintf( 75 | 'The related to value (%s) was not found in the options list', 76 | $fixture[$relatedTo] 77 | ); 78 | } 79 | 80 | $relatedToValue = $options[$fixture[$relatedTo]]; 81 | 82 | if (strpos($relatedToValue, $this->trigger) === 0) { 83 | $producer = trim($relatedToValue, $this->trigger); 84 | 85 | // Parameterized producers are not support here due to the 86 | // complexity of the syntax that it would cause. 87 | try { 88 | return $this->generator->$producer; 89 | } catch (InvalidArgumentException $e) { 90 | return 'Unsupported formatter: @'.$producer.'@'; 91 | } 92 | } 93 | 94 | return $relatedToValue; 95 | } 96 | } 97 | 98 | /* End of file RelatedElement.php */ 99 | -------------------------------------------------------------------------------- /src/Specter.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | namespace HelpScout\Specter; 9 | 10 | use Faker; 11 | use HelpScout\Specter\Provider\Avatar; 12 | use HelpScout\Specter\Provider\RelatedElement; 13 | use InvalidArgumentException; 14 | 15 | /** 16 | * Class Specter 17 | * 18 | * @package HelpScout\Specter 19 | */ 20 | class Specter 21 | { 22 | /** 23 | * JSON fixture trigger to locate the faker producers. 24 | * 25 | * Values of `@firstName@` will be processed by default 26 | * 27 | * @var string 28 | */ 29 | protected $trigger = '@'; 30 | 31 | /** 32 | * Used to generate the actual random data for the spec. 33 | * 34 | * @var Faker\Generator 35 | */ 36 | protected $faker; 37 | 38 | /** 39 | * Specter constructor. 40 | * 41 | * Initialize with a seed for repeatable fixture data 42 | * 43 | * @param int|null $seed Faker seed value 44 | * 45 | * @return Specter 46 | */ 47 | public function __construct($seed = 0) 48 | { 49 | $this->faker = Faker\Factory::create(); 50 | $this->faker->addProvider(new Avatar($this->faker)); 51 | $this->faker->addProvider(new RelatedElement($this->faker, $this->trigger)); 52 | if ($seed) { 53 | $this->faker->seed($seed); 54 | } 55 | } 56 | 57 | /** 58 | * Replace fixture patterns with Faker data 59 | * 60 | * @param array $fixture 61 | * 62 | * @return array json with random data inserted 63 | */ 64 | public function substituteMockData(array $fixture) 65 | { 66 | foreach ($fixture as $jsonKey => $jsonValue) { 67 | // Recurse into a json structure. 68 | if (is_array($jsonValue)) { 69 | $fixture[$jsonKey] = $this->substituteMockData($jsonValue); 70 | continue; 71 | } 72 | 73 | // Confirm that this property is meant to be mocked by starting 74 | // with our trigger 75 | if (strpos($jsonValue, $this->trigger) !== 0) { 76 | continue; 77 | } 78 | 79 | // Use the Faker producer for this data type if available. 80 | $jsonValue = trim($jsonValue, $this->trigger); 81 | $parameters = explode('|', $jsonValue); 82 | $producer = array_shift($parameters); 83 | 84 | // Transform any array-like parameters into arrays. 85 | foreach ($parameters as $index => $parameter) { 86 | if (strpos($parameter, ',')) { 87 | $parameters[$index] = explode(',', $parameter); 88 | } 89 | } 90 | 91 | // Related to has some special requirements in that it needs access 92 | // the the rest of the fixture data and the special related syntax. 93 | $relatedTrigger = 'relatedElement:'; 94 | if (stripos($producer, $relatedTrigger) === 0) { 95 | $relatedTo = str_replace($relatedTrigger, '', $producer); 96 | $producer = 'relatedElement'; 97 | $options = []; 98 | foreach ($parameters as $parameter) { 99 | list($key, $value) = explode(':', $parameter, 2); 100 | $options[$key] = $value; 101 | } 102 | $parameters = [$relatedTo, $fixture, $options]; 103 | } 104 | 105 | try { 106 | // Note that type conversion will take place here - a matcher 107 | // of `"@randomDigitNotNull@"` will be turned into an `int`. 108 | switch (count($parameters)) { 109 | case 0: 110 | $fixture[$jsonKey] = $this->faker->$producer; 111 | break; 112 | case 1: 113 | $fixture[$jsonKey] = $this->faker->$producer($parameters[0]); 114 | break; 115 | case 2: 116 | $fixture[$jsonKey] = $this->faker->$producer($parameters[0], $parameters[1]); 117 | break; 118 | case 3: 119 | $fixture[$jsonKey] = $this->faker->$producer($parameters[0], $parameters[1], $parameters[2]); 120 | break; 121 | default: 122 | $fixture[$jsonKey] = call_user_func_array(array($this->faker, $producer), $parameters); 123 | break; 124 | } 125 | } catch (InvalidArgumentException $e) { 126 | $fixture[$jsonKey] = 'Unsupported formatter: @'.$producer.'@'; 127 | } 128 | } 129 | 130 | return $fixture; 131 | } 132 | } 133 | 134 | /* End of file Specter.php */ 135 | -------------------------------------------------------------------------------- /src/Testing/SpecterTestTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2016 Help Scout 10 | */ 11 | namespace HelpScout\Specter\Testing; 12 | 13 | use Coduo\PHPMatcher\Factory\SimpleFactory; 14 | use Diff; 15 | use Diff_Renderer_Text_Unified; 16 | use Faker; 17 | use Faker\Generator; 18 | use InvalidArgumentException; 19 | use LogicException; 20 | use Psr\Http\Message\ResponseInterface; 21 | use RuntimeException; 22 | 23 | /** 24 | * Trait SpecterTestTrait 25 | * 26 | * ----------------------------------------------------------------------------- 27 | * Place a reference to PHPUnit assertions to quiet several IDE warnings. 28 | * ----------------------------------------------------------------------------- 29 | * @codingStandardsIgnoreStart 30 | * @method static void assertEquals() assertEquals($expected, $actual, $message = '', $delta = 0.0, $maxDepth = 10, $canonicalize = false, $ignoreCase = false) assert equals 31 | * @method static void assertTrue() assertTrue($condition, $message = '') assert true 32 | * @method static void fail() fail($other, $description = '') fail a test 33 | * @codingStandardsIgnoreEnd 34 | * 35 | * @package HelpScout\Specter 36 | */ 37 | trait SpecterTestTrait 38 | { 39 | /** 40 | * @var Generator 41 | */ 42 | static protected $faker; 43 | 44 | /** 45 | * A list of Faker methods and their Matcher test types 46 | * 47 | * @var array 48 | */ 49 | static private $fakerMatchers = [ 50 | 'freeEmail' => '@string@.isEmail()', 51 | 'email' => '@string@.isEmail()', 52 | 'companyEmail' => '@string@.isEmail()', 53 | 'url' => '@string@.isUrl()', 54 | // inArray($value) 55 | // oneOf(...$expanders) - example usage "@string@.oneOf(contains('foo'), contains('bar'), contains('baz'))" 56 | ]; 57 | 58 | /** 59 | * JSON fixture trigger 60 | * 61 | * Values of `@firstName@` will be processed by default 62 | * 63 | * @var string 64 | */ 65 | static protected $trigger = '@'; 66 | 67 | /** 68 | * Full path to the json fixture data files 69 | * 70 | * @var string 71 | */ 72 | static protected $fixtureFolder = ''; 73 | 74 | /** 75 | * Assert that a response matches a spec file and status code 76 | * 77 | * For more about the PSR7 interfaces, please see: 78 | * https://git.io/psr7_ResponseInterface 79 | * 80 | * @param ResponseInterface $response psr7 response object 81 | * @param string $filename path to fixture file (no extension) 82 | * @param string|null $mimeType fixture file extension and mime type 83 | * @param int|null $statusCode expected status code of response 84 | * 85 | * @return void 86 | * @throws RuntimeException 87 | * @throws LogicException 88 | */ 89 | static public function assertResponse( 90 | ResponseInterface $response, 91 | string $filename, 92 | $mimeType = 'json', 93 | $statusCode = 200 94 | ) { 95 | self::assertResponseCode($response, $statusCode); 96 | self::assertResponseContent($response, $filename, $mimeType); 97 | } 98 | 99 | /** 100 | * Assert an api response http code 101 | * 102 | * @param ResponseInterface $response psr7 response object 103 | * @param int $statusCode expected status code 104 | * 105 | * @return void 106 | */ 107 | static public function assertResponseCode( 108 | ResponseInterface $response, 109 | int $statusCode 110 | ) { 111 | self::assertEquals( 112 | $statusCode, 113 | $response->getStatusCode(), 114 | 'Incorrect status code' 115 | ); 116 | } 117 | 118 | /** 119 | * Assert that a response matches fixture data (with wildcard patterns) 120 | * 121 | * This uses the matcher format for json files. See the project at 122 | * https://github.com/coduo/php-matcher for more information. 123 | * 124 | * Construct a JSON fixture file with the php-matcher patterns and you can 125 | * assert that a server response matches the spec, even if the data is 126 | * changing. 127 | * 128 | * @param ResponseInterface $response psr7 response object 129 | * @param string|resource $filename path within `$fixtureFolder` 130 | * @param string|null $mimeType file extension and mime type 131 | * 132 | * @throws LogicException 133 | * @throws RuntimeException 134 | * @return void 135 | */ 136 | static public function assertResponseContent( 137 | ResponseInterface $response, 138 | $filename, 139 | $mimeType = 'json' 140 | ) { 141 | self::$faker = Faker\Factory::create(); 142 | $spec = self::getFixtureText($filename, $mimeType); 143 | $actual = $response->getBody()->getContents(); 144 | 145 | // Check that the spec and actual are both valid json 146 | $test = json_decode($spec); 147 | if ($test === null && json_last_error() !== JSON_ERROR_NONE) { 148 | self::fail('Invalid Specter File, unable to decode '.$filename.'.'); 149 | } 150 | $test = json_decode($actual); 151 | if ($test === null && json_last_error() !== JSON_ERROR_NONE) { 152 | self::fail('Invalid Specter File, unable to decode response.'); 153 | } 154 | unset($test); 155 | 156 | 157 | // Build these both into arrays for processing 158 | $actual = json_decode($actual, true); 159 | $spec = json_decode($spec, true); 160 | 161 | // Convert the spec to matcher format 162 | $matcherSpec = self::getMatcherFormat($spec); 163 | $factory = new SimpleFactory(); 164 | $matcher = $factory->createMatcher(); 165 | 166 | // Pass this test because the matcher matched 167 | if ($matcher->match($actual, $matcherSpec)) { 168 | self::assertTrue(true); 169 | return; 170 | } 171 | 172 | // Display the output in a better format by using a diffing tool 173 | // We convert to strings to try to make the output more accurate 174 | $difference = $matcher->getError().PHP_EOL; 175 | $specString = json_encode($spec, JSON_PRETTY_PRINT); 176 | $specString = explode(PHP_EOL, $specString); 177 | $actualString = json_encode($actual, JSON_PRETTY_PRINT); 178 | $actualString = explode(PHP_EOL, $actualString); 179 | $diffOptions = []; 180 | $diff = new Diff($specString, $actualString, $diffOptions); 181 | $renderer = new Diff_Renderer_Text_Unified(); 182 | 183 | self::fail( 184 | $difference.$diff->render($renderer), 185 | 'Incorrect api response' 186 | ); 187 | } 188 | 189 | /** 190 | * Convert a Specter specification into a matcher formatted structure 191 | * 192 | * @param array $spec 193 | * 194 | * @return array 195 | * @throws \LogicException 196 | */ 197 | static private function getMatcherFormat(array $spec) 198 | { 199 | foreach ($spec as $jsonKey => $jsonValue) { 200 | // Recurse into a json structure. 201 | if (is_array($jsonValue)) { 202 | $spec[$jsonKey] = self::getMatcherFormat($jsonValue); 203 | continue; 204 | } 205 | 206 | // Confirm that this property is meant to be mocked by starting 207 | // with our trigger 208 | if (strpos($jsonValue, self::$trigger) !== 0) { 209 | continue; 210 | } 211 | 212 | // Use the Faker producer for this data type if available. 213 | $producer = trim($jsonValue, self::$trigger); 214 | $spec[$jsonKey] = self::getMatcherType($producer); 215 | } 216 | 217 | return $spec; 218 | } 219 | 220 | /** 221 | * Map a faker provider to the correct matcher string 222 | * 223 | * @param string $producer faker producer name 224 | * 225 | * @return string string matcher type 226 | * @throws LogicException 227 | */ 228 | static private function getMatcherType(string $producer) 229 | { 230 | // Explode a `randomElements|args1|arg2|arg3` into pieces 231 | $pieces = explode('|', $producer); 232 | $producerName = $pieces[0]; 233 | $arguments = array_splice($pieces, 1); 234 | 235 | // The arguments might be a csv list for an array 236 | $arguments = array_map( 237 | function ($argument) { 238 | if (str_contains($argument, ',')) { 239 | return explode(',', $argument); 240 | } 241 | return $argument; 242 | }, 243 | $arguments 244 | ); 245 | 246 | // These are explicitly set by configuration to match a php-matcher to 247 | // the output of a particular Faker provider 248 | if (array_key_exists($producer, self::$fakerMatchers)) { 249 | return self::$fakerMatchers[$producer]; 250 | } 251 | 252 | // These are type checked by getting the type from Faker output 253 | try { 254 | $result = call_user_func_array([self::$faker, $producerName], $arguments); 255 | $type = gettype($result); 256 | return '@'.$type.'@'; 257 | } catch (InvalidArgumentException $e) { 258 | throw new LogicException('Unsupported formatter: @'.$producer.'@'); 259 | } 260 | } 261 | 262 | /** 263 | * Get the content of a fixture file or resource 264 | * 265 | * @param string|resource $filename resource or filename in $fixtureFolder 266 | * @param string $mimeType file extension 267 | * 268 | * @return string fixture data as json string 269 | * @throws LogicException 270 | */ 271 | static private function getFixtureText($filename, string $mimeType) 272 | { 273 | if (is_resource($filename)) { 274 | return stream_get_contents($filename); 275 | } 276 | 277 | $fixtureFolder = self::getFixtureFolder(); 278 | if (self::$fixtureFolder === '') { 279 | throw new LogicException( 280 | 'Please set a fixture folder for Specter JSON files' 281 | ); 282 | } 283 | $fixtureFile = sprintf( 284 | '%s/%s.%s', 285 | $fixtureFolder, 286 | $filename, 287 | $mimeType 288 | ); 289 | if (!file_exists($fixtureFile)) { 290 | throw new LogicException('Spec is not readable: ' . $fixtureFile); 291 | } 292 | return file_get_contents($fixtureFile); 293 | } 294 | 295 | /** 296 | * Get the API fixture data path 297 | * 298 | * @return string path to api fixture data where json 299 | * 300 | * @codeCoverageIgnore 301 | */ 302 | static public function getFixtureFolder() 303 | { 304 | return self::$fixtureFolder; 305 | } 306 | 307 | /** 308 | * Set the API fixture data path 309 | * 310 | * @param string $path to api fixture data where json 311 | * 312 | * @return void 313 | * 314 | * @codeCoverageIgnore 315 | */ 316 | static public function setFixtureFolder(string $path) 317 | { 318 | self::$fixtureFolder = rtrim($path, '/'); 319 | } 320 | } 321 | 322 | /* End of file SpecterTestTrait.php */ 323 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | 9 | require dirname(__DIR__).'/vendor/autoload.php'; 10 | require __DIR__.'/constants.php'; 11 | 12 | /* End of file bootstrap.php */ 13 | -------------------------------------------------------------------------------- /tests/constants.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | 9 | define('TEST_FIXTURE_FOLDER', __DIR__.'/fixture'); 10 | 11 | /* End of file constants.php */ 12 | -------------------------------------------------------------------------------- /tests/fixture/customer.json: -------------------------------------------------------------------------------- 1 | { 2 | "__specter": "Sample customer record", 3 | "id": "@randomDigitNotNull@", 4 | "fname": "@firstName@", 5 | "lname": "@lastName@", 6 | "avatar": "@randomGravatar|wavatar@", 7 | "company": "@company@", 8 | "jobTitle": "@jobTitle@", 9 | "background": "@catchPhrase@", 10 | "notes": "@paragraph@", 11 | "address": { 12 | "city": "@city@", 13 | "state": "@stateAbbr@", 14 | "zip": "@postcode@", 15 | "country": "@country@" 16 | }, 17 | "emails": ["@companyEmail@", "@freeEmail@", "@email@"] 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixture/numbers.json: -------------------------------------------------------------------------------- 1 | { 2 | "__specter": "Example of using Faker number producers", 3 | "id": "@numberBetween|1000|9000@", 4 | "quantity": "@randomNumber@", 5 | "description": "@numerify|You've been a member for for ### days@" 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixture/random-element.json: -------------------------------------------------------------------------------- 1 | { 2 | "__specter": "Example of using a random element from a list", 3 | "id": "@randomDigitNotNull@", 4 | "name": "@name@", 5 | "type": "@randomElement|customer,vendor,owner|@", 6 | "subscriptions" : "@randomElements|news,sports,medicine,tv|2@" 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixture/related-element.json: -------------------------------------------------------------------------------- 1 | { 2 | "__specter": "Example of using a related selection from a list", 3 | "id": "@randomDigitNotNull@", 4 | "type": "@randomElement|user,guest@", 5 | "name": "@relatedElement:type|user:@name@|guest:Guest User@" 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixture/todo.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "@uuid@", 3 | "title": "@sentence@", 4 | "description": "@paragraph@", 5 | "createdAt": "@unixTime@", 6 | "updatedAt": "@unixTime@", 7 | "completed": "@boolean@" 8 | } 9 | -------------------------------------------------------------------------------- /tests/generators/todo.php: -------------------------------------------------------------------------------- 1 | './examples/fixtures', 7 | 'specterDirectory' => './tests/fixture', 8 | 'specs' => [ 9 | [ 10 | 'specterFile' => 'todo.json', 11 | 'outputFile' => 'todo-simple.json', 12 | 'seed' => 1, 13 | 'description' => 'A simple todo example', 14 | ], 15 | [ 16 | 'specterFile' => 'todo.json', 17 | 'outputFile' => 'todo-no-title.json', 18 | 'seed' => 2, 19 | 'postProcess' => function ($fixture) { 20 | $fixture->title = ''; 21 | }, 22 | 'description' => 'A todo without a title', 23 | ], 24 | [ 25 | 'specterFile' => 'todo.json', 26 | 'outputFile' => 'todo-with-integer-id.json', 27 | 'seed' => 3, 28 | 'postProcess' => function ($fixture) { 29 | $fixture->id = 45678; 30 | }, 31 | 'description' => 'A todo with an integer id instead of a uuid', 32 | ] 33 | ] 34 | ]; 35 | -------------------------------------------------------------------------------- /tests/src/ExampleTest.php: -------------------------------------------------------------------------------- 1 | 8 | * @copyright 2016 Help Scout 9 | */ 10 | namespace HelpScout\Specter\Tests; 11 | 12 | use HelpScout\Specter\Testing\SpecterTestTrait; 13 | use PHPUnit\Framework\TestCase; 14 | 15 | /** 16 | * Class ExampleTest 17 | * 18 | * @package HelpScout\Specter\Tests 19 | */ 20 | class ExampleTest extends TestCase 21 | { 22 | use SpecterTestTrait; 23 | 24 | /** 25 | * This could be a wrapper to run routes in your application 26 | * 27 | * @var WebTestClient 28 | */ 29 | public $client; 30 | 31 | /** 32 | * Create our testing client that will execute routes 33 | * 34 | * @return void 35 | */ 36 | public function setUp() 37 | { 38 | $this->client = new WebTestClient(); 39 | self::setFixtureFolder(TEST_FIXTURE_FOLDER); 40 | } 41 | 42 | /** 43 | * Clean up after the tests 44 | * 45 | * @return void 46 | */ 47 | public function tearDown() 48 | { 49 | unset($this->client); 50 | parent::tearDown(); 51 | } 52 | 53 | /** 54 | * Test that our customer api route returns a appropriate response 55 | * 56 | * @return void 57 | * @throws \LogicException 58 | * @throws \RuntimeException 59 | */ 60 | public function testCustomerRouteMeetsSpec() 61 | { 62 | $fixtureCustomerId = 37; 63 | self::assertResponseContent( 64 | $this->client->get('/api/v1/customer/'.$fixtureCustomerId), 65 | 'customer' 66 | ); 67 | } 68 | } 69 | 70 | // @codingStandardsIgnoreStart 71 | /** 72 | * This is a pretend interface to a client that would execute routes in your 73 | * application. For example in Symfony, this might be a client created by 74 | * WebTestCase::createClient(); 75 | * 76 | * @package HelpScout\Specter\Tests 77 | */ 78 | class WebTestClient 79 | { 80 | use HttpFactory; 81 | 82 | /** 83 | * Run a route in your application 84 | * 85 | * In a real application, this would run a route and return the psr7 86 | * response object. Though, perhaps we should also allow this to return 87 | * a string. 88 | * 89 | * @param $url 90 | * @return GuzzleHttp\Psr7\Response 91 | */ 92 | public function get($url) 93 | { 94 | // This isn't going to be particularly close to the customer.json file. 95 | // The diff should us that we meant to implement `fname` and `lname` 96 | $response = json_encode([ 97 | 'id' => 3, 98 | 'name' => 'Eric Smith', 99 | 'company' => 'Serious International', 100 | 'jobTitle' => 'Master Carpenter' 101 | ]); 102 | 103 | return $this->responseFactory($response); 104 | } 105 | } 106 | // @codingStandardsIgnoreEnd 107 | 108 | /* End of file ExampleTest.php */ 109 | -------------------------------------------------------------------------------- /tests/src/Helpers/FakerFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | namespace HelpScout\Specter\Tests\Helpers; 9 | 10 | use Faker; 11 | use HelpScout\Specter\Provider\Avatar; 12 | use HelpScout\Specter\Provider\RelatedElement; 13 | 14 | /** 15 | * Trait FakerFactory 16 | * 17 | * @package HelpScout\Specter\Tests\Helpers 18 | */ 19 | trait FakerFactory 20 | { 21 | /** 22 | * Create a faker instance with an optional seed. 23 | * 24 | * @param int|null $seed random generator seed for repeatable results 25 | * 26 | * @return Faker\Generator 27 | */ 28 | public function fakerFactory($seed = 0) 29 | { 30 | $faker = Faker\Factory::create(); 31 | $faker->addProvider(new Avatar($faker)); 32 | $faker->addProvider(new RelatedElement($faker, '@')); 33 | 34 | if ($seed) { 35 | $faker->seed($seed); 36 | } 37 | 38 | return $faker; 39 | } 40 | } 41 | 42 | /* End of file FakerFactory.php */ 43 | -------------------------------------------------------------------------------- /tests/src/Helpers/IlluminateHttpFactory.php: -------------------------------------------------------------------------------- 1 | setContent($content); 26 | $response->setStatusCode($code); 27 | 28 | return $response; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/src/Helpers/PSR7HttpFactory.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | namespace HelpScout\Specter\Tests\Helpers; 9 | 10 | use Closure; 11 | use Psr\Http\Message\ResponseInterface; 12 | use Psr\Http\Message\RequestInterface; 13 | use GuzzleHttp\Psr7\Request; 14 | use GuzzleHttp\Psr7\Response; 15 | use RuntimeException; 16 | 17 | /** 18 | * Trait HttpFactory 19 | * 20 | * @package HelpScout\Specter\Tests 21 | */ 22 | trait PSR7HttpFactory 23 | { 24 | /** 25 | * Create a final step in a mock middleware stack to access the body. 26 | * 27 | * @return Closure 28 | * @throws RuntimeException 29 | */ 30 | public function getCallableMiddleware() 31 | { 32 | return function ( 33 | RequestInterface $request, 34 | ResponseInterface $response 35 | ) { 36 | return $response; 37 | }; 38 | } 39 | 40 | /** 41 | * Create a php stream with text. 42 | * 43 | * @param string $text 44 | * @param string|null $mode 45 | * 46 | * @return resource 47 | */ 48 | public function streamFactory(string $text, $mode = 'r+') 49 | { 50 | $stream = fopen('php://temp', $mode); 51 | fwrite($stream, $text); 52 | rewind($stream); 53 | 54 | return $stream; 55 | } 56 | 57 | /** 58 | * Create a new PSR7 Response Object. 59 | * 60 | * @param string $content 61 | * @param int|null $code 62 | * 63 | * @return Response 64 | * @throws \InvalidArgumentException 65 | */ 66 | public function responseFactory(string $content, $code = 200) 67 | { 68 | $headers = []; 69 | return new Response($code, $headers, $content); 70 | } 71 | 72 | /** 73 | * Create a new PSR7 Request Object. 74 | * 75 | * @return Request 76 | * @throws \InvalidArgumentException 77 | */ 78 | public function requestFactory() 79 | { 80 | $uri = 'https://example.com/foo/bar?abc=123'; 81 | $headers = []; 82 | $body = ''; 83 | $request = new Request( 84 | 'GET', 85 | $uri, 86 | $headers, 87 | $body 88 | ); 89 | 90 | return $request; 91 | } 92 | } 93 | 94 | /* End of file HttpFactory.php */ 95 | -------------------------------------------------------------------------------- /tests/src/IlluminateMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | fakerFactory($seed)->name; 32 | $request = $this->requestFactory(); 33 | 34 | $request->headers->set('SpecterSeed', $seed); 35 | 36 | $response = $this->responseFactory($body); 37 | $middleware = new SpecterIlluminate; 38 | $callable = $this->getCallableMiddleware($response); 39 | $response = $middleware->handle($request, $callable); 40 | $json = json_decode((string) $response->content(), true); 41 | 42 | self::assertSame($expected, $json['name'], 'Incorrect json value'); 43 | } 44 | 45 | /** 46 | * Assert that we ignore a file without the __specter property trigger 47 | * 48 | * We run the middleware with a seed header so that we can assert that the 49 | * response matches the expectation. 50 | * 51 | * @return void 52 | * @throws InvalidArgumentException 53 | * @throws RuntimeException 54 | */ 55 | public function testMiddlewareCanIgnoreNonSpecterFile() 56 | { 57 | $body = '{"name":"@name@"}'; 58 | $seed = 3; 59 | $request = $this->requestFactory(); 60 | 61 | $request->headers->set('SpecterSeed', $seed); 62 | 63 | $response = $this->responseFactory($body); 64 | $middleware = new SpecterIlluminate; 65 | $callable = $this->getCallableMiddleware($response); 66 | $response = $middleware->handle($request, $callable); 67 | 68 | self::assertSame( 69 | $body, 70 | (string) $response->getContent(), 71 | 'Specter did not ignore a non-specter file' 72 | ); 73 | } 74 | 75 | /** 76 | * Assert that the correct exception is thrown for invalid Specter JSON. 77 | * 78 | * @return void 79 | * @expectedException LogicException 80 | * @throws InvalidArgumentException 81 | * @throws RuntimeException 82 | */ 83 | public function testMiddlewareFailsOnInvalidJson() 84 | { 85 | $body = '{"__specter": "", "name":"@name@"'; 86 | $request = $this->requestFactory(); 87 | $response = $this->responseFactory($body); 88 | $middleware = new SpecterIlluminate; 89 | $middleware->handle($request, $this->getCallableMiddleware($response)); 90 | } 91 | 92 | /** 93 | * Assert that the correct output is sent for invalid Specter selector. 94 | * 95 | * @return void 96 | * @throws RuntimeException 97 | * @throws InvalidArgumentException 98 | */ 99 | public function testMiddlewareFailsOnInvalidProviderJson() 100 | { 101 | $body = '{"__specter": "", "name":"@nameButMaybeMisspelled@"}'; 102 | $request = $this->requestFactory(); 103 | $response = $this->responseFactory($body); 104 | $middleware = new SpecterIlluminate; 105 | $callable = $this->getCallableMiddleware($response); 106 | $response = $middleware->handle($request, $callable); 107 | $json = json_decode((string) $response->getContent(), true); 108 | 109 | self::assertStringStartsWith( 110 | 'Unsupported formatter', 111 | $json['name'], 112 | 'The error message from Specter was not correct' 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/src/Provider/AvatarTest.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2017 Help Scout 13 | * @group providers 14 | */ 15 | class AvatarTest extends TestCase 16 | { 17 | use FakerFactory; 18 | 19 | /** 20 | * @test 21 | * @return void 22 | */ 23 | public function providerCanInstantiate() 24 | { 25 | $faker = \Faker\Factory::create(); 26 | 27 | $this->assertInstanceOf( 28 | Avatar::class, 29 | new Avatar($faker) 30 | ); 31 | } 32 | 33 | /** 34 | * @test 35 | * @return void 36 | */ 37 | public function providerCanProviderRobotAvatar() 38 | { 39 | $this->assertContains( 40 | 'robohash', 41 | $this->fakerFactory()->randomRobotAvatar, 42 | 'The robot avatar provider is not working' 43 | ); 44 | } 45 | 46 | /** 47 | * @test 48 | * @return void 49 | */ 50 | public function providerCanProviderRandomGravatar() 51 | { 52 | $this->assertContains( 53 | 'gravatar', 54 | $this->fakerFactory()->randomGravatar, 55 | 'The gravatar provider is not working' 56 | ); 57 | } 58 | } 59 | 60 | /* End of file AvatarTest.php */ 61 | -------------------------------------------------------------------------------- /tests/src/Provider/RelatedElementTest.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2017 Help Scout 13 | * @group providers 14 | */ 15 | class RelatedElementTest extends TestCase 16 | { 17 | use FakerFactory; 18 | 19 | /** 20 | * @test 21 | * @return void 22 | */ 23 | public function providerCanInstantiate() 24 | { 25 | $faker = \Faker\Factory::create(); 26 | 27 | $this->assertInstanceOf( 28 | RelatedElement::class, 29 | new RelatedElement($faker) 30 | ); 31 | } 32 | 33 | /** 34 | * @test 35 | * @return void 36 | */ 37 | public function providerCanSelectRelatedWithStaticValue() 38 | { 39 | $seed = 8; 40 | $faker = $this->fakerFactory($seed); 41 | 42 | $fixture = [ 43 | 'type' => 'guest', 44 | ]; 45 | 46 | $fixture['name'] = $faker->relatedElement( 47 | 'type', 48 | $fixture, 49 | [ 50 | 'guest' => 'Guest Account', 51 | 'user' => '@name@' 52 | ] 53 | ); 54 | 55 | $this->assertEquals('Guest Account', $fixture['name']); 56 | } 57 | 58 | /** 59 | * @test 60 | * @return void 61 | */ 62 | public function providerCanSelectRelatedWithFakerValue() 63 | { 64 | $seed = 8; 65 | $faker = $this->fakerFactory($seed); 66 | $expected = $faker->name; 67 | 68 | // reset the seed so we get the same via @name@ 69 | $faker = $this->fakerFactory($seed); 70 | 71 | $fixture = [ 72 | 'type' => 'user', 73 | ]; 74 | 75 | $fixture['name'] = $faker->relatedElement( 76 | 'type', 77 | $fixture, 78 | [ 79 | 'guest' => 'Guest Account', 80 | 'user' => '@name@' 81 | ] 82 | ); 83 | 84 | $this->assertEquals($expected, $fixture['name']); 85 | } 86 | 87 | /** 88 | * @test 89 | * @return void 90 | */ 91 | public function providerProvidesExplanationForInvalidRelatedToKey() 92 | { 93 | $seed = 8; 94 | $faker = $this->fakerFactory($seed); 95 | $fixture = [ 96 | 'type' => 'user', 97 | ]; 98 | $name = $faker->relatedElement( 99 | 'somekeythatdoesnotexist', 100 | $fixture, 101 | [ 102 | 'guest' => 'Guest Account', 103 | 'user' => '@name@' 104 | ] 105 | ); 106 | 107 | $this->assertContains('Invalid related to key', $name); 108 | } 109 | 110 | /** 111 | * @test 112 | * @return void 113 | */ 114 | public function providerProvidesExplanationForInvalidRelatedToOptions() 115 | { 116 | $seed = 8; 117 | $faker = $this->fakerFactory($seed); 118 | $fixture = [ 119 | 'type' => 'thisdoesnotmatchanoption', 120 | ]; 121 | $name = $faker->relatedElement( 122 | 'type', 123 | $fixture, 124 | [ 125 | 'guest' => 'Guest Account', 126 | 'user' => '@name@' 127 | ] 128 | ); 129 | 130 | $this->assertContains('was not found in the options list', $name); 131 | } 132 | 133 | /** 134 | * @test 135 | * @return void 136 | */ 137 | public function providerProvidesExplanationForInvalidProducer() 138 | { 139 | $seed = 8; 140 | $faker = $this->fakerFactory($seed); 141 | $fixture = [ 142 | 'type' => 'user', 143 | ]; 144 | $name = $faker->relatedElement( 145 | 'type', 146 | $fixture, 147 | [ 148 | 'guest' => 'Guest Account', 149 | 'user' => '@thisdoesnotexist@' 150 | ] 151 | ); 152 | 153 | $this->assertContains('Unsupported formatter', $name); 154 | } 155 | } 156 | 157 | /* End of file RelatedElementTest.php */ 158 | -------------------------------------------------------------------------------- /tests/src/Psr7MiddlewareTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2016 Help Scout 10 | */ 11 | namespace HelpScout\Specter\Tests; 12 | 13 | use HelpScout\Specter\Middleware\SpecterPsr7; 14 | use InvalidArgumentException; 15 | use LogicException; 16 | use PHPUnit\Framework\TestCase; 17 | use RuntimeException; 18 | 19 | class Psr7MiddlewareTest extends TestCase implements SpecterMiddlewareTestInterface 20 | { 21 | use Helpers\PSR7HttpFactory, Helpers\FakerFactory; 22 | 23 | /** 24 | * Assert that we process a Specter JSON file to random data. 25 | * 26 | * We run the middleware with a seed header so that we can assert that the 27 | * response matches the expectation. 28 | * 29 | * @return void 30 | * @throws InvalidArgumentException 31 | * @throws RuntimeException 32 | */ 33 | public function testMiddlewareCanProcessSimpleJson() 34 | { 35 | $body = '{"__specter": "", "name":"@name@"}'; 36 | $seed = 3; 37 | $expected = $this->fakerFactory($seed)->name; 38 | $request = $this->requestFactory()->withHeader('SpecterSeed', $seed); 39 | $response = $this->responseFactory($body); 40 | $middleware = new SpecterPsr7(); 41 | $callable = $this->getCallableMiddleware(); 42 | $response = $middleware($request, $response, $callable); 43 | $json = json_decode((string) $response->getBody(), true); 44 | 45 | self::assertSame($expected, $json['name'], 'Incorrect json value'); 46 | } 47 | 48 | /** 49 | * Assert that we ignore a file without the __specter property trigger 50 | * 51 | * We run the middleware with a seed header so that we can assert that the 52 | * response matches the expectation. 53 | * 54 | * @return void 55 | * @throws InvalidArgumentException 56 | * @throws RuntimeException 57 | */ 58 | public function testMiddlewareCanIgnoreNonSpecterFile() 59 | { 60 | $body = '{"name":"@name@"}'; 61 | $seed = 3; 62 | $expected = $this->fakerFactory($seed)->name; 63 | $request = $this->requestFactory()->withHeader('SpecterSeed', $seed); 64 | $response = $this->responseFactory($body); 65 | $middleware = new SpecterPsr7(); 66 | $callable = $this->getCallableMiddleware(); 67 | $response = $middleware($request, $response, $callable); 68 | 69 | self::assertSame( 70 | $body, 71 | (string) $response->getBody(), 72 | 'Specter did not ignore a non-specter file' 73 | ); 74 | } 75 | 76 | /** 77 | * Assert that the correct exception is thrown for invalid Specter JSON. 78 | * 79 | * @return void 80 | * @expectedException LogicException 81 | * @throws InvalidArgumentException 82 | * @throws RuntimeException 83 | */ 84 | public function testMiddlewareFailsOnInvalidJson() 85 | { 86 | $body = '{"__specter": "", "name":"@name@"'; 87 | $request = $this->requestFactory(); 88 | $response = $this->responseFactory($body); 89 | $middleware = new SpecterPsr7(); 90 | $middleware($request, $response, $this->getCallableMiddleware()); 91 | } 92 | 93 | /** 94 | * Assert that the correct output is sent for invalid Specter selector. 95 | * 96 | * @return void 97 | * @throws RuntimeException 98 | * @throws InvalidArgumentException 99 | */ 100 | public function testMiddlewareFailsOnInvalidProviderJson() 101 | { 102 | $body = '{"__specter": "", "name":"@nameButMaybeMisspelled@"}'; 103 | $request = $this->requestFactory(); 104 | $response = $this->responseFactory($body); 105 | $middleware = new SpecterPsr7(); 106 | $callable = $this->getCallableMiddleware(); 107 | $response = $middleware($request, $response, $callable); 108 | $json = json_decode((string) $response->getBody(), true); 109 | 110 | self::assertStringStartsWith( 111 | 'Unsupported formatter', 112 | $json['name'], 113 | 'The error message from Specter was not correct' 114 | ); 115 | } 116 | } 117 | 118 | /* End of file Psr7MiddlewareTest.php */ 119 | -------------------------------------------------------------------------------- /tests/src/SpecterMiddlewareTestInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * @copyright 2016 Help Scout 7 | */ 8 | namespace HelpScout\Specter\Tests; 9 | 10 | use HelpScout\Specter\Specter; 11 | use PHPUnit\Framework\TestCase; 12 | 13 | /** 14 | * Class SpecterTest 15 | * 16 | * @package HelpScout\Specter\Tests 17 | */ 18 | class SpecterTest extends TestCase 19 | { 20 | use Helpers\FakerFactory; 21 | 22 | /** 23 | * A quick health check to make sure we're namespaced and lint free 24 | * 25 | * @return void 26 | */ 27 | public function testSpecterCanInitSpecter() 28 | { 29 | $specter = new Specter(); 30 | self::assertInstanceOf( 31 | 'HelpScout\Specter\Specter', 32 | $specter 33 | ); 34 | } 35 | 36 | /** 37 | * Assert that we can process simple json and return a consistent result 38 | * by using a seed value 39 | * 40 | * Note: We have to be careful to call the expected values in the same 41 | * order that we find them in the fixture file. We start with the 42 | * same `$seed` and we have to march in lockstep with the Specter 43 | * producer so that the values are generated in the same order. 44 | * 45 | * @test 46 | * @return void 47 | */ 48 | public function specterCanProcessJson() 49 | { 50 | $seed = 5; 51 | $faker = $this->fakerFactory($seed); 52 | $id = $faker->randomDigitNotNull; 53 | $firstName = $faker->firstName; 54 | $lastName = $faker->lastName; 55 | $specter = new Specter($seed); 56 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/customer.json'); 57 | $fixture = json_decode($json, true); 58 | $data = $specter->substituteMockData($fixture); 59 | 60 | self::assertSame($id, $data['id']); 61 | self::assertSame($firstName, $data['fname']); 62 | self::assertSame($lastName, $data['lname']); 63 | } 64 | 65 | /** 66 | * Specter can generate random numbers 67 | * 68 | * @test 69 | * @return void 70 | */ 71 | public function specterCanGenerateNumbers() 72 | { 73 | $specter = new Specter(); 74 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/numbers.json'); 75 | $fixture = json_decode($json, true); 76 | $data = $specter->substituteMockData($fixture); 77 | 78 | self::assertGreaterThanOrEqual( 79 | 1000, 80 | $data['id'], 81 | 'The random id should have been between 1000 and 9000' 82 | ); 83 | self::assertLessThanOrEqual( 84 | 9000, 85 | $data['id'], 86 | 'The random id should have been between 1000 and 9000' 87 | ); 88 | 89 | self::assertInternalType( 90 | 'integer', 91 | $data['quantity'], 92 | "The quantity should be an integer" 93 | ); 94 | 95 | self::assertRegExp( 96 | '~[0-9]~', 97 | $data['description'], 98 | "The description should have a number in it" 99 | ); 100 | } 101 | 102 | /** 103 | * Specter should be able to select a random value from a list 104 | * 105 | * @test 106 | * @return void 107 | */ 108 | public function specterCanSelectRandomValues() 109 | { 110 | $specter = new Specter(); 111 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/random-element.json'); 112 | $fixture = json_decode($json, true); 113 | $data = $specter->substituteMockData($fixture); 114 | 115 | self::assertContains( 116 | $data['type'], 117 | ['customer', 'vendor', 'owner'], 118 | 'The random value was not in the expected set' 119 | ); 120 | 121 | self::assertEquals( 122 | 2, 123 | count($data['subscriptions']), 124 | 'The subscriptions subset was not the correct length' 125 | ); 126 | } 127 | 128 | /** 129 | * Specter should be able to generate Avatar urls 130 | * 131 | * @test 132 | * @return void 133 | */ 134 | public function specterCanMakeAvatarUrls() 135 | { 136 | $specter = new Specter(); 137 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/customer.json'); 138 | $fixture = json_decode($json, true); 139 | $data = $specter->substituteMockData($fixture); 140 | 141 | self::assertContains( 142 | 'gravatar', 143 | $data['avatar'], 144 | 'The avatar does not appear to be value' 145 | ); 146 | } 147 | 148 | 149 | /** 150 | * Specter should be able to select a related value from a list 151 | * 152 | * @test 153 | * @return void 154 | */ 155 | public function specterCanSelectRelatedValuesWithGeneratedValue() 156 | { 157 | $seed = 1; 158 | $faker = $this->fakerFactory($seed); 159 | $faker->randomDigitNotNull; // Call to keep the random generator in sync 160 | $faker->randomDigitNotNull; 161 | $name = $faker->name; 162 | $specter = new Specter($seed); 163 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/related-element.json'); 164 | $fixture = json_decode($json, true); 165 | $data = $specter->substituteMockData($fixture); 166 | self::assertEquals( 167 | $data['type'], 168 | 'user', 169 | 'The seed should have made the type == customer' 170 | ); 171 | self::assertEquals( 172 | $data['name'], 173 | $name, 174 | 'Incorrect related name generated' 175 | ); 176 | } 177 | 178 | /** 179 | * Specter should be able to select a related value from a list 180 | * 181 | * @test 182 | * @return void 183 | */ 184 | public function specterCanSelectRelatedValuesWithStaticValue() 185 | { 186 | $seed = 2; 187 | $specter = new Specter($seed); 188 | $json = file_get_contents(TEST_FIXTURE_FOLDER.'/related-element.json'); 189 | $fixture = json_decode($json, true); 190 | $data = $specter->substituteMockData($fixture); 191 | self::assertEquals( 192 | $data['type'], 193 | 'guest', 194 | 'The seed should have made the type == guest' 195 | ); 196 | self::assertEquals( 197 | $data['name'], 198 | 'Guest User', 199 | 'A guest user should have been created' 200 | ); 201 | } 202 | } 203 | 204 | /* End of file SpecterTest.php */ 205 | -------------------------------------------------------------------------------- /tests/src/SpecterTestTraitTest.php: -------------------------------------------------------------------------------- 1 | 8 | * @copyright 2016 Help Scout 9 | */ 10 | namespace HelpScout\Specter\Tests; 11 | 12 | use Exception; 13 | use HelpScout\Specter\Testing\SpecterTestTrait; 14 | use InvalidArgumentException; 15 | use PHPUnit\Framework\AssertionFailedError; 16 | use PHPUnit\Framework\TestCase; 17 | 18 | /** 19 | * Class SpecterTestTraitTest 20 | * 21 | * @package HelpScout\Specter\Tests 22 | */ 23 | class SpecterTestTraitTest extends TestCase 24 | { 25 | use Helpers\PSR7HttpFactory; 26 | use Helpers\FakerFactory; 27 | use SpecterTestTrait; 28 | 29 | /** 30 | * Assert that we pass if the response code is incorrect 31 | * 32 | * @throws InvalidArgumentException 33 | * @return void 34 | */ 35 | public function testTraitPassesMatchingResponseCode() 36 | { 37 | $code = 200; 38 | $response = $this->responseFactory('sample text', $code); 39 | self::assertResponseCode($response, $code); 40 | } 41 | 42 | /** 43 | * Assert that we fail if the response code is incorrect 44 | * 45 | * @return void 46 | * @expectedException \PHPUnit\Framework\ExpectationFailedException 47 | * @throws InvalidArgumentException 48 | */ 49 | public function testTraitFailsIncorrectResponseCode() 50 | { 51 | $code = 200; 52 | $mismatch = 404; 53 | $response = $this->responseFactory('sample text', $code); 54 | self::assertResponseCode($response, $mismatch); 55 | } 56 | 57 | /** 58 | * Assert that we fail if a property is missing from the json object 59 | * 60 | * @return void 61 | * @throws \LogicException 62 | */ 63 | public function testTraitFailsForMissingJsonProperty() 64 | { 65 | $spec = '{"name":"@firstName@","email":"@email@"}'; 66 | $actual = '{"name":"Jeffery Weiss"}'; 67 | 68 | // Normally, logic is bad in these tests, but here we have to make 69 | // assertions about the failure exceptions that our test trait 70 | // should be throwing. 71 | try { 72 | self::assertResponseContent( 73 | $this->responseFactory($actual), 74 | $this->streamFactory($spec) 75 | ); 76 | } catch (Exception $e) { 77 | self::assertInstanceOf( 78 | AssertionFailedError::class, 79 | $e, 80 | 'SpecterTestTrait should have failed with a missing property.' 81 | ); 82 | self::assertStringStartsWith( 83 | 'There is no element under path', 84 | $e->getMessage(), 85 | 'The error message from SpecterTestTrait was incorrect.' 86 | ); 87 | return; 88 | } 89 | self::fail('SpecterTestTrait failed to notice the missing property'); 90 | } 91 | 92 | /** 93 | * Assert that we fail if a property is of the incorrect type 94 | * 95 | * @return void 96 | */ 97 | public function testTraitFailsForIncorrectJsonPropertyType() 98 | { 99 | $spec = '{"id":"@randomDigit@"}'; 100 | $actual = '{"id":"this should be an integer"}'; 101 | 102 | try { 103 | self::assertResponseContent( 104 | $this->responseFactory($actual), 105 | $this->streamFactory($spec) 106 | ); 107 | } catch (Exception $e) { 108 | self::assertInstanceOf( 109 | AssertionFailedError::class, 110 | $e, 111 | 'SpecterTestTrait should have failed with a missing property.' 112 | ); 113 | self::assertContains( 114 | 'does not match', 115 | $e->getMessage(), 116 | 'The error message from SpecterTestTrait was incorrect.' 117 | ); 118 | return; 119 | } 120 | self::fail( 121 | 'SpecterTestTrait failed to notice the incorrect property type' 122 | ); 123 | } 124 | 125 | /** 126 | * Assert that we fail if a property is of the incorrect type 127 | * 128 | * @return void 129 | */ 130 | public function testTraitFailsForExpandedPatterns() 131 | { 132 | $spec = '{"email":"@freeEmail@"}'; 133 | $actual = '{"email":"example.com"}'; 134 | 135 | try { 136 | self::assertResponseContent( 137 | $this->responseFactory($actual), 138 | $this->streamFactory($spec) 139 | ); 140 | } catch (Exception $e) { 141 | self::assertInstanceOf( 142 | AssertionFailedError::class, 143 | $e, 144 | 'SpecterTestTrait should have failed with a missing property.' 145 | ); 146 | self::assertContains( 147 | 'does not match', 148 | $e->getMessage(), 149 | 'The error message from SpecterTestTrait was incorrect.' 150 | ); 151 | return; 152 | } 153 | self::fail( 154 | 'SpecterTestTrait failed to notice the incorrect property type' 155 | ); 156 | } 157 | } 158 | 159 | /* End of file SpecterTestTraitTest.php */ 160 | --------------------------------------------------------------------------------