diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcf895c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +/.github export-ignore +/docs export-ignore +/examples export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore + +# Configure diff output for .php and .phar files. +*.php diff=php +*.phar -diff diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fd20b19 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml new file mode 100644 index 0000000..debf364 --- /dev/null +++ b/.github/workflows/analyze.yml @@ -0,0 +1,74 @@ +# When a PR is opened or a push is made, perform +# a static analysis check on the code using PHPStan. +name: PHPStan + +on: + pull_request: + branches: + - 'develop' + paths: + - 'src/**' + - 'tests/**' + - 'composer.**' + - 'phpstan*' + - '.github/workflows/analyze.yml' + push: + branches: + - 'develop' + paths: + - 'src/**' + - 'tests/**' + - 'composer.**' + - 'phpstan*' + - '.github/workflows/analyze.yml' + +jobs: + build: + name: PHP ${{ matrix.php-versions }} Static Analysis + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-versions: ['7.3', '7.4', '8.0'] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer, pecl, phpunit + extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Create composer cache directory + run: mkdir -p ${{ steps.composer-cache.outputs.dir }} + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Create PHPStan cache directory + run: mkdir -p build/phpstan + + - name: Cache PHPStan results + uses: actions/cache@v2 + with: + path: build/phpstan + key: ${{ runner.os }}-phpstan-${{ github.sha }} + restore-keys: ${{ runner.os }}-phpstan- + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + + - name: Run static analysis + run: vendor/bin/phpstan analyze diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9ded22a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +name: PHPUnit + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + +jobs: + main: + name: PHP ${{ matrix.php-versions }} Unit Tests + + strategy: + matrix: + php-versions: ['7.3', '7.4', '8.0'] + + runs-on: ubuntu-latest + + if: "!contains(github.event.head_commit.message, '[ci skip]')" + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer, pecl, phpunit + extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 + coverage: xdebug + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + + - name: Test with PHPUnit + run: vendor/bin/phpunit --verbose --coverage-text + env: + TERM: xterm-256color + + - if: matrix.php-versions == '8.0' + name: Mutate with Infection + run: | + composer global require infection/infection + git fetch --depth=1 origin $GITHUB_BASE_REF + infection --threads=2 --skip-initial-tests --coverage=build/phpunit --git-diff-base=origin/$GITHUB_BASE_REF --git-diff-filter=AM --logger-github --ignore-msi-with-no-mutations + + - if: matrix.php-versions == '8.0' + name: Run Coveralls + run: vendor/bin/php-coveralls --verbose --coverage_clover=build/phpunit/clover.xml --json_path build/phpunit/coveralls-upload.json + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_PARALLEL: true + COVERALLS_FLAG_NAME: PHP ${{ matrix.php-versions }} + + coveralls: + needs: [main] + name: Coveralls Finished + runs-on: ubuntu-latest + steps: + - name: Upload Coveralls results + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/.gitignore b/.gitignore index c5c5f07..11192f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ vendor/ build/ phpunit*.xml phpunit +*.cache composer.lock .DS_Store diff --git a/README.md b/README.md index ed776b2..a1e2309 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Tatter\Forms RESTful AJAX forms for CodeIgniter 4 +[![](https://github.com/tattersoftware/codeigniter4-forms/workflows/PHPUnit/badge.svg)](https://github.com/tattersoftware/codeigniter4-forms/actions?query=workflow%3A%22PHPUnit) +[![](https://github.com/tattersoftware/codeigniter4-forms/workflows/PHPStan/badge.svg)](https://github.com/tattersoftware/codeigniter4-forms/actions?query=workflow%3A%22PHPStan) +[![Coverage Status](https://coveralls.io/repos/github/tattersoftware/codeigniter4-forms/badge.svg?branch=develop)](https://coveralls.io/github/tattersoftware/codeigniter4-forms?branch=develop) + ## Quick Start 1. Install with Composer: `> composer require tatter/forms` @@ -46,7 +50,7 @@ may vary): ## Configuration (optional) The library's default behavior can be overridden or augment by its config file. Copy -**bin/Forms.php** to **app/Config/Forms.php** and follow the instructions in the +**examples/Forms.php** to **app/Config/Forms.php** and follow the instructions in the comments. If no config file is found the library will use its defaults. ## Usage diff --git a/composer.json b/composer.json index 8be0236..0bd62d4 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "tatter/forms", + "type": "library", "description": "RESTful AJAX forms for CodeIgniter 4", "keywords": [ "codeigniter", @@ -20,40 +21,43 @@ "role": "Developer" } ], - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/codeigniter4/CodeIgniter4" - } - ], - "minimum-stability": "dev", "require": { - "php" : ">=7.2", - "tatter/assets": "^2.0", + "php": "^7.3 || ^8.0", + "components/jquery": "^3.3", "tatter/alerts": "^2.0", - "components/jquery": "^3.3", - "twbs/bootstrap": "^4.3" + "tatter/assets": "^2.0", + "twbs/bootstrap": "^4.3" }, "require-dev": { "codeigniter4/codeigniter4": "dev-develop", - "mikey179/vfsstream": "1.6.*", - "mockery/mockery": "^1.0", - "phpunit/phpunit" : "^7.0" + "mikey179/vfsstream": "^1.6", + "tatter/tools": "^1.7" }, "autoload": { "psr-4": { "Tatter\\Forms\\": "src" - } + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] }, "autoload-dev": { "psr-4": { - "ModuleTests\\Support\\": "tests/_support" + "Tests\\Support\\": "tests/_support" } }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/codeigniter4/CodeIgniter4" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, "scripts": { - "test": "phpunit", - "post-update-cmd": [ - "composer dump-autoload" - ] + "analyze": "phpstan analyze", + "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", + "style": "phpcbf --standard=./vendor/codeigniter4/codeigniter4-standard/CodeIgniter4 tests/ src/", + "test": "phpunit" } } diff --git a/bin/Forms.php b/examples/Forms.php similarity index 100% rename from bin/Forms.php rename to examples/Forms.php diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..b175102 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,19 @@ +{ + "source": { + "directories": [ + "src" + ], + "excludes": [ + "Config", + "Database/Migrations", + "Views" + ] + }, + "logs": { + "text": "build/infection.log" + }, + "mutators": { + "@default": true + }, + "bootstrap": "vendor/codeigniter4/codeigniter4/system/Test/bootstrap.php" +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..c45b9b7 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,21 @@ +parameters: + tmpDir: build/phpstan + level: 5 + paths: + - src + - tests + bootstrapFiles: + - vendor/codeigniter4/codeigniter4/system/Test/bootstrap.php + excludes_analyse: + - src/Config/Routes.php + - src/Views/* + ignoreErrors: + universalObjectCratesClasses: + - CodeIgniter\Entity + - Faker\Generator + scanDirectories: + - vendor/codeigniter4/codeigniter4/system/Helpers + dynamicConstantNames: + - APP_NAMESPACE + - CI_DEBUG + - ENVIRONMENT diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3b2cf45..6ab0206 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,50 +1,92 @@ - + + + + + ./src + + + ./src/Views + ./src/Config/Routes.php + + + + + + + + + + - + ./tests - - - ./src - - ./src/Views - ./src/Config/Routes.php - - - - + + + + + + 0.50 + + + 30 + + + 2 + + + true + + + + + + - - - - - - - + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/Config/Forms.php b/src/Config/Forms.php index c34da15..8a367c4 100644 --- a/src/Config/Forms.php +++ b/src/Config/Forms.php @@ -4,9 +4,18 @@ class Forms extends BaseConfig { - // Whether to continue instead of throwing exceptions + /** + * Whether to continue instead of throwing exceptions + * + * @var bool + */ public $silent = true; - // URL base for Resource controllers + + /** + * URL base for Resource controllers + * + * @var string + */ public $apiUrl = 'api/'; } diff --git a/src/Controllers/ResourceController.php b/src/Controllers/ResourceController.php index 7046a34..501f6c7 100644 --- a/src/Controllers/ResourceController.php +++ b/src/Controllers/ResourceController.php @@ -1,14 +1,18 @@ request->getPost(); @@ -18,14 +22,14 @@ public function create() return $this->actionFailed('create', 422); } - return $this->respondCreated(null, lang('Forms.created', [$this->name])); + return $this->respondCreated($this->model->find($id), lang('Forms.created', [ucfirst($this->name)])); } public function index() { return $this->respond($this->model->findAll()); } - + public function show($id = null) { if (($object = $this->ensureExists($id)) instanceof ResponseInterface) @@ -33,9 +37,9 @@ public function show($id = null) return $object; } - return $this->respond([$this->model->find($id)]); + return $this->respond($object); } - + public function update($id = null) { if (($object = $this->ensureExists($id)) instanceof ResponseInterface) @@ -50,7 +54,7 @@ public function update($id = null) return $this->actionFailed('update', 422); } - return $this->respond(null, 200, lang('Forms.updated', [$this->name])); + return $this->respond($this->model->find($id), 200, lang('Forms.updated', [ucfirst($this->name)])); } public function delete($id = null) @@ -59,37 +63,54 @@ public function delete($id = null) { return $object; } - + if (! $this->model->delete($id)) { return $this->actionFailed('delete'); } - return $this->respondDeleted(null, lang('Forms.deleted', [$this->name])); + return $this->respondDeleted($object, lang('Forms.deleted', [ucfirst($this->name)])); } - - /************* SUPPORT METHODS *************/ - + + //-------------------------------------------------------------------- + // Support Methods + //-------------------------------------------------------------------- + + /** + * Fetches an object or returns a failure Response. + * + * @param string|int|null $id + * + * @return mixed + */ protected function ensureExists($id = null) { - if ($object = $this->model->find($id)) + if (isset($id) && $object = $this->model->find($id)) { return $object; } - - return $this->failNotFound('Not Found', null, lang('Forms.notFound', [$this->name])); + + return $this->failNotFound('Not Found', null, lang('Forms.notFound', [ucfirst($this->name)])); } + /** + * Creates a standardized failure Response. + * + * @param string $action + * @param int $status + * + * @return ResponseInterface + */ protected function actionFailed(string $action, int $status = 400) { - $errors = $this->model->errors() ?? [lang("Forms.{$action}Failed", [$this->name])]; + $errors = $this->model->errors() ?? [lang("Forms.{$action}Failed", [ucfirst($this->name)])]; $response = [ 'status' => $status, - 'error' => "{$action} Failed", + 'error' => ucfirst($action) . ' Failed', 'messages' => $this->model->errors(), ]; - - return $this->respond($response, $status, $message); + + return $this->respond($response, $status); } } diff --git a/src/Controllers/ResourcePresenter.php b/src/Controllers/ResourcePresenter.php index e2a3b5d..478a7e7 100644 --- a/src/Controllers/ResourcePresenter.php +++ b/src/Controllers/ResourcePresenter.php @@ -1,44 +1,45 @@ request->isAJAX() ? - view("{$this->names}/form") : - view("{$this->names}/new"); + return view($this->request->isAJAX() ? "{$this->names}/form" : "{$this->names}/new"); } - + public function create() { $data = $this->request->getPost(); - + if (! $id = $this->model->insert($data)) { return $this->actionFailed('create'); } $this->alert('success', lang('Forms.created', [$this->name])); - - return redirect()->to($this->names); + + return redirect()->to(site_url($this->names)); } public function index() { - $data = [$this->names => $this->model->findAll()]; - return view("{$this->names}/index", $data); + return view("{$this->names}/index", [ + $this->names => $this->model->findAll(), + ]); } - + public function show($id = null) { if (($object = $this->ensureExists($id)) instanceof RedirectResponse) @@ -46,82 +47,82 @@ public function show($id = null) return $object; } - $data = [$this->name => $object]; - - return view("{$this->names}/show", $data); + return view("{$this->names}/show", [$this->name => $object]); } - + public function edit($id = null) { if (($object = $this->ensureExists($id)) instanceof RedirectResponse) { return $object; } - - $data = [$this->name => $object]; - - helper('form'); - return $this->request->isAJAX() ? - view("{$this->names}/form", $data) : - view("{$this->names}/edit", $data); + + return view($this->request->isAJAX() ? "{$this->names}/form" : "{$this->names}/edit", [ + $this->name => $object + ]); } - + public function update($id = null) { if (($object = $this->ensureExists($id)) instanceof RedirectResponse) { return $object; } - + $data = $this->request->getPost(); if (! $this->model->update($id, $data)) { return $this->actionFailed('update'); } - + $this->alert('success', lang('Forms.updated', [$this->name])); - - return redirect()->to("{$this->names}/{$id}"); + + return redirect()->to(site_url("{$this->names}/{$id}")); } - + public function remove($id = null) { if (($object = $this->ensureExists($id)) instanceof RedirectResponse) { return $object; } - + $data = [$this->name => $object]; - - helper('form'); - return $this->request->isAJAX() ? - view("{$this->names}/confirm", $data) : - view("{$this->names}/remove", $data); + + return view($this->request->isAJAX() ? "{$this->names}/confirm" : "{$this->names}/remove", [ + $this->name => $object, + ]); } - + public function delete($id = null) { if (($object = $this->ensureExists($id)) instanceof RedirectResponse) { return $object; } - + if (! $this->model->delete($id)) { - return $this->actionFailed('delete'); + return $this->actionFailed('delete'); } + + $this->alert('success', lang('Forms.deleted', [$this->name])); + + return redirect()->to(site_url("{$this->names}")); } - - /************* SUPPORT METHODS *************/ - + + //-------------------------------------------------------------------- + // Support Methods + //-------------------------------------------------------------------- + protected function ensureExists($id = null) { if ($object = $this->model->find($id)) { return $object; } - + $error = lang('Forms.notFound', [$this->name]); $this->alert('danger', $error); @@ -131,16 +132,18 @@ protected function ensureExists($id = null) protected function actionFailed(string $action) { - $errors = $this->model->errors() ?? [lang("Forms.{$action}Failed", [$this->name])]; + $errors = $this->model->errors() ?? [ + lang("Forms.{$action}Failed", [$this->name]), + ]; foreach ($errors as $error) { $this->alert('warning', $error); } - + return redirect()->back()->withInput()->with('errors', $errors); } - + protected function alert($status, $message) { if ($alerts = service('alerts')) diff --git a/src/Language/en/Forms.php b/src/Language/en/Forms.php index f70fdc0..2a2627d 100644 --- a/src/Language/en/Forms.php +++ b/src/Language/en/Forms.php @@ -6,7 +6,7 @@ 'created' => 'New {0} created successfully.', 'updated' => '{0} updated successfully.', 'deleted' => '{0} deleted successfully.', - 'updateFailed' => 'Unable to create a new {0}.', + 'createFailed' => 'Unable to create a new {0}.', 'updateFailed' => 'Unable to update that {0}.', 'deleteFailed' => 'Unable to delete that {0}.', ]; diff --git a/src/Traits/ResourceTrait.php b/src/Traits/ResourceTrait.php index 1ab7781..f5348e9 100644 --- a/src/Traits/ResourceTrait.php +++ b/src/Traits/ResourceTrait.php @@ -1,7 +1,12 @@ model instanceof \CodeIgniter\Model) + if (! $this->model instanceof Model) { throw FormsException::forMissingModel(get_class($this)); } diff --git a/tests/_support/Controllers/API/Factories.php b/tests/_support/Controllers/API/Factories.php index b2ce180..9546a8a 100644 --- a/tests/_support/Controllers/API/Factories.php +++ b/tests/_support/Controllers/API/Factories.php @@ -1,8 +1,9 @@ - ['type' => 'varchar', 'constraint' => 31], 'uid' => ['type' => 'varchar', 'constraint' => 31], - 'class' => ['type' => 'varchar', 'constraint' => 63], - 'icon' => ['type' => 'varchar', 'constraint' => 31], - 'summary' => ['type' => 'varchar', 'constraint' => 255], + 'class' => ['type' => 'varchar', 'constraint' => 63, 'null' => true], + 'icon' => ['type' => 'varchar', 'constraint' => 31, 'default' => ''], + 'summary' => ['type' => 'varchar', 'constraint' => 255, 'default' => ''], 'created_at' => ['type' => 'datetime', 'null' => true], 'updated_at' => ['type' => 'datetime', 'null' => true], 'deleted_at' => ['type' => 'datetime', 'null' => true], diff --git a/tests/_support/Database/Seeds/IndustrialSeeder.php b/tests/_support/Database/Seeds/IndustrialSeeder.php index 917b036..74d569e 100644 --- a/tests/_support/Database/Seeds/IndustrialSeeder.php +++ b/tests/_support/Database/Seeds/IndustrialSeeder.php @@ -1,6 +1,8 @@ -routes = Services::routes(); - - $this->routes->presenter('factories', ['controller' => 'ModuleTests\Support\Controllers\Factories']); - $this->routes->resource('api/factories', ['controller' => 'ModuleTests\Support\Controllers\API\Factories']); - - Services::injectMock('routes', $this->routes); - - // Inject mock renderer - $config = new \Config\View(); - $viewPath = config('Paths')->viewDirectory; - $renderer = new MockRenderer($config, $viewPath, Services::locator(true), CI_DEBUG, Services::logger(true)); - Services::injectMock('renderer', $renderer); - - // Mock framework - $config = config('App'); - $this->codeigniter = new MockCodeIgniter($config); - - // Module classes - $this->config = new \Tatter\Forms\Config\Forms(); - $this->model = new \ModuleTests\Support\Models\FactoryModel(); - } - - public function tearDown(): void - { - parent::tearDown(); - - if (count(ob_list_handlers()) > 1) - { - ob_end_clean(); - } - } -} diff --git a/tests/_support/FormsTestCase.php b/tests/_support/FormsTestCase.php new file mode 100644 index 0000000..2328c87 --- /dev/null +++ b/tests/_support/FormsTestCase.php @@ -0,0 +1,82 @@ +presenter('factories', ['controller' => 'Tests\Support\Controllers\Factories']); + $routes->resource('api/factories', ['controller' => 'Tests\Support\Controllers\API\Factories']); + + Services::injectMock('routes', $routes); + $this->routes = $routes; + + // Load the test classes + $this->config = config('Forms'); + $this->model = new FactoryModel(); + $this->codeigniter = new MockCodeIgniter(config('App')); + } +} diff --git a/tests/_support/MockRenderer.php b/tests/_support/MockRenderer.php index 26f40dd..5d86115 100644 --- a/tests/_support/MockRenderer.php +++ b/tests/_support/MockRenderer.php @@ -1,11 +1,20 @@ - $view, 'data' => $this->data]); + return serialize(['view' => $view, 'data' => $this->tempData]); } } diff --git a/tests/_support/Models/FactoryModel.php b/tests/_support/Models/FactoryModel.php index 93c1213..ae1f9c0 100644 --- a/tests/_support/Models/FactoryModel.php +++ b/tests/_support/Models/FactoryModel.php @@ -1,4 +1,4 @@ - 'required|max_length[31]', + 'uid' => 'required|max_length[31]', + ]; } diff --git a/tests/_support/PresenterResponse.php b/tests/_support/PresenterResponse.php new file mode 100644 index 0000000..6467250 --- /dev/null +++ b/tests/_support/PresenterResponse.php @@ -0,0 +1,63 @@ +getBody()) + { + throw new RuntimeException('Empty body from ' . $response->request()->uri); + } + + try + { + $result = unserialize($response->getBody()); + } + catch (Throwable $e) + { + throw new RuntimeException('Invalid response ' . $response->getBody(), $e->getCode(), $e); + } + + if (! is_array($result)) + { + throw new RuntimeException('Indecipherable response ' . $response->getBody()); + } + + $this->response = $response; + $this->view = $result['view']; + $this->data = $result['data']; + } +} diff --git a/tests/_support/PresenterTrait.php b/tests/_support/PresenterTrait.php new file mode 100644 index 0000000..f7c8254 --- /dev/null +++ b/tests/_support/PresenterTrait.php @@ -0,0 +1,59 @@ +execute($method, ...$params)); + } + + /** + * Sets the headers to trigger the next call + * as an AJAX method. + * + * @return $this + */ + protected function asAjax(): self + { + $this->request->setHeader('X-Requested-With', 'xmlhttprequest'); + + return $this; + } +} diff --git a/tests/_support/bootstrap.php b/tests/_support/bootstrap.php deleted file mode 100644 index 0d0a5a6..0000000 --- a/tests/_support/bootstrap.php +++ /dev/null @@ -1,54 +0,0 @@ -appDirectory) . DIRECTORY_SEPARATOR); -define('ROOTPATH', realpath(APPPATH . '../') . DIRECTORY_SEPARATOR); -define('FCPATH', realpath(ROOTPATH . 'public') . DIRECTORY_SEPARATOR); -define('SYSTEMPATH', realpath($paths->systemDirectory) . DIRECTORY_SEPARATOR); -define('WRITEPATH', realpath($paths->writableDirectory) . DIRECTORY_SEPARATOR); -define('SUPPORTPATH', realpath(ROOTPATH . 'tests/_support') . DIRECTORY_SEPARATOR); - -// Define necessary module test path constants -define('MODULESUPPORTPATH', realpath(__DIR__) . DIRECTORY_SEPARATOR); -define('TESTPATH', realpath(MODULESUPPORTPATH . '../') . DIRECTORY_SEPARATOR); -define('MODULEPATH', realpath(__DIR__ . '/../../') . DIRECTORY_SEPARATOR); -define('COMPOSER_PATH', MODULEPATH . 'vendor/autoload.php'); - -// Set environment values that would otherwise stop the framework from functioning during tests. -if (! isset($_SERVER['app.baseURL'])) -{ - $_SERVER['app.baseURL'] = 'http://example.com'; -} - -// Load necessary modules -require_once APPPATH . 'Config/Autoload.php'; -require_once APPPATH . 'Config/Constants.php'; -require_once APPPATH . 'Config/Modules.php'; - -require_once SYSTEMPATH . 'Autoloader/Autoloader.php'; -require_once SYSTEMPATH . 'Config/BaseService.php'; -require_once APPPATH . 'Config/Services.php'; - -// Use Config\Services as CodeIgniter\Services -if (! class_exists('CodeIgniter\Services', false)) -{ - class_alias('Config\Services', 'CodeIgniter\Services'); -} - -// Launch the autoloader to gather namespaces (includes composer.json's "autoload-dev") -$loader = \CodeIgniter\Services::autoloader(); -$loader->initialize(new Config\Autoload(), new Config\Modules()); -$loader->register(); // Register the loader with the SPL autoloader stack. diff --git a/tests/database/ControllerTest.php b/tests/database/ControllerTest.php deleted file mode 100644 index 44430e3..0000000 --- a/tests/database/ControllerTest.php +++ /dev/null @@ -1,222 +0,0 @@ -codeigniter->useSafeOutput(true)->run($this->routes); - $output = json_decode(ob_get_clean()); - - $expected = [ - 'view' => 'factories/index', - 'data' => [ - 'factories' => $this->model->findAll(), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceShow() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'show', - '1', - ]; - $_SERVER['argc'] = 5; - $_SERVER['REQUEST_URI'] = '/api/factories/show/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = json_decode(ob_get_clean()); - - $expected = [ - 'view' => 'factories/show', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceNew() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'new', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/api/factories/new'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = json_decode(ob_get_clean()); - - $expected = [ - 'view' => 'factories/new', - 'data' => [], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceCreate() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'create', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/api/factories/create'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - $_POST['name'] = 'Rainbow Factory'; - $_POST['uid'] = 'bow'; - $_POST['class'] = 'ModuleTests\Rainbows\Factory'; - $_POST['icon'] = ''; - $_POST['summary'] = ''; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $factory = $this->model->find(4); - $this->assertEquals($_POST['name'], $factory->name); - } - - public function testResourceRemove() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'remove', - '1', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/api/factories/remove/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = json_decode(ob_get_clean()); - - $expected = [ - 'view' => 'factories/remove', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceDelete() - { - $this->assertNotNull($this->model->find(3)); - - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'delete', - '3', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/api/factories/delete/3'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $this->assertNull($this->model->find(3)); - } - - public function testResourceEdit() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'edit', - '1', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/api/factories/edit/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = json_decode(ob_get_clean()); - - $expected = [ - 'view' => 'factories/edit', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceUpdate() - { - $_SERVER['argv'] = [ - 'index.php', - 'api', - 'factories', - 'update', - '1', - ]; - $_SERVER['argc'] = 5; - $_SERVER['REQUEST_URI'] = '/api/factories/update/1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - $_POST['name'] = 'Rainbow Factory'; - $_POST['uid'] = 'bow'; - $_POST['class'] = 'ModuleTests\Rainbows\Factory'; - $_POST['icon'] = ''; - $_POST['summary'] = ''; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $factory = $this->model->find(1); - $this->assertEquals($_POST['name'], $factory->name); - } -} diff --git a/tests/database/PresenterTest.php b/tests/database/PresenterTest.php deleted file mode 100644 index a596cb5..0000000 --- a/tests/database/PresenterTest.php +++ /dev/null @@ -1,214 +0,0 @@ -codeigniter->useSafeOutput(true)->run($this->routes); - $output = unserialize(ob_get_clean()); - - $expected = [ - 'view' => 'factories/index', - 'data' => [ - 'factories' => $this->model->findAll(), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceShow() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'show', - '1', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/factories/show/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = unserialize(ob_get_clean()); - - $expected = [ - 'view' => 'factories/show', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceNew() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'new', - ]; - $_SERVER['argc'] = 3; - $_SERVER['REQUEST_URI'] = '/factories/new'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = unserialize(ob_get_clean()); - - $expected = [ - 'view' => 'factories/new', - 'data' => [], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceCreate() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'create', - ]; - $_SERVER['argc'] = 3; - $_SERVER['REQUEST_URI'] = '/factories/create'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - $_POST['name'] = 'Rainbow Factory'; - $_POST['uid'] = 'bow'; - $_POST['class'] = 'ModuleTests\Rainbows\Factory'; - $_POST['icon'] = ''; - $_POST['summary'] = ''; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $factory = $this->model->find(4); - $this->assertEquals($_POST['name'], $factory->name); - } - - public function testResourceRemove() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'remove', - '1', - ]; - $_SERVER['argc'] = 3; - $_SERVER['REQUEST_URI'] = '/factories/remove/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = unserialize(ob_get_clean()); - - $expected = [ - 'view' => 'factories/remove', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceDelete() - { - $this->assertNotNull($this->model->find(3)); - - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'delete', - '3', - ]; - $_SERVER['argc'] = 3; - $_SERVER['REQUEST_URI'] = '/factories/delete/3'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $this->assertNull($this->model->find(3)); - } - - public function testResourceEdit() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'edit', - '1', - ]; - $_SERVER['argc'] = 3; - $_SERVER['REQUEST_URI'] = '/factories/edit/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = unserialize(ob_get_clean()); - - $expected = [ - 'view' => 'factories/edit', - 'data' => [ - 'factory' => $this->model->find(1), - ], - ]; - - $this->assertEquals($expected, $output); - } - - public function testResourceUpdate() - { - $_SERVER['argv'] = [ - 'index.php', - 'factories', - 'update', - '1', - ]; - $_SERVER['argc'] = 4; - $_SERVER['REQUEST_URI'] = '/factories/update/1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - - $_POST['name'] = 'Rainbow Factory'; - $_POST['uid'] = 'bow'; - $_POST['class'] = 'ModuleTests\Rainbows\Factory'; - $_POST['icon'] = ''; - $_POST['summary'] = ''; - - ob_start(); - $this->codeigniter->useSafeOutput(true)->run($this->routes); - $output = ob_get_clean(); - - $this->assertEquals('', $output); - - $factory = $this->model->find(1); - $this->assertEquals($_POST['name'], $factory->name); - } -} diff --git a/tests/read/ControllerReadTest.php b/tests/read/ControllerReadTest.php new file mode 100644 index 0000000..5c104cd --- /dev/null +++ b/tests/read/ControllerReadTest.php @@ -0,0 +1,160 @@ +controller(Factories::class); + } + + public function testIndex() + { + $result = $this->execute('index'); + + $result->assertOK(); + $result->assertJSONExact($this->model->findAll()); + } + + public function testShow() + { + $result = $this->execute('show', 1); + + $result->assertOK(); + $result->assertJSONExact((array) $this->model->find(1)); + } + + public function testShowNull() + { + $result = $this->execute('show'); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testShowNonexistant() + { + $result = $this->execute('show', 42); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testCreateFailed() + { + // Missing "name" + $_POST = [ + 'uid' => 'bow', + 'class' => 'ModuleTests\Rainbows\Factory', + 'icon' => '', + 'summary' => '', + ]; + + $result = $this->execute('create'); + + $result->assertNotOK(); + $result->assertStatus(422); + + $body = json_decode($result->response()->getBody()); + + $this->assertSame('Create Failed', $body->error); + $this->assertSame(['name' => 'The name field is required.'], (array) $body->messages); + } + + public function testUpdateNull() + { + $result = $this->execute('update'); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testUpdateNonexistant() + { + $result = $this->execute('update', 42); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testUpdateFailed() + { + $factory = model(FactoryModel::class)->first(); + + $_POST = ['name' => 'This name exceeds the limit of 31 characters']; + + $result = $this->execute('update', $factory->id); + + $result->assertNotOK(); + $result->assertStatus(422); + + $body = json_decode($result->response()->getBody()); + + $this->assertSame('Update Failed', $body->error); + $this->assertSame(['name' => 'The name field cannot exceed 31 characters in length.'], (array) $body->messages); + } + + public function testDeleteNull() + { + $result = $this->execute('delete'); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testDeleteNonexistant() + { + $result = $this->execute('delete', 42); + + $result->assertNotOK(); + $result->assertStatus(404); + } + + public function testDeleteFailed() + { + // Mock the Model so all deletes fail + $model = new class extends FactoryModel { + + protected function doDelete($id = null, bool $purge = false) + { + return false; + } + }; + $this->controller->setModel($model); + + $factory = model(FactoryModel::class)->first(); + $result = $this->execute('delete', $factory->id); + + $result->assertNotOK(); + $result->assertStatus(400); + + $body = json_decode($result->response()->getBody()); + + $this->assertSame('Delete Failed', $body->error); + } +} diff --git a/tests/read/PresenterReadTest.php b/tests/read/PresenterReadTest.php new file mode 100644 index 0000000..7a43518 --- /dev/null +++ b/tests/read/PresenterReadTest.php @@ -0,0 +1,116 @@ +controller(Factories::class); + } + + public function testNew() + { + $result = $this->call('new'); + $result->response->assertOK(); + + $this->assertEquals('factories/new', $result->view); + } + + public function testNewAjax() + { + $result = $this->asAjax()->call('new'); + $result->response->assertOK(); + + $this->assertEquals('factories/form', $result->view); + } + + public function testIndex() + { + $data = [ + 'factories' => model(FactoryModel::class)->findAll(), + ]; + + $result = $this->call('index'); + $result->response->assertOK(); + + $this->assertEquals('factories/index', $result->view); + $this->assertEquals($data, $result->data); + } + + public function testShow() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->call('show', $factory->id); + $result->response->assertOK(); + + $this->assertEquals('factories/show', $result->view); + $this->assertEquals(['factory' => $factory], $result->data); + } + + public function testEdit() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->call('edit', $factory->id); + $result->response->assertOK(); + + $this->assertEquals('factories/edit', $result->view); + $this->assertEquals(['factory' => $factory], $result->data); + } + + public function testEditAjax() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->asAjax()->call('edit', $factory->id); + $result->response->assertOK(); + + $this->assertEquals('factories/form', $result->view); + $this->assertEquals(['factory' => $factory], $result->data); + } + + public function testRemove() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->call('remove', $factory->id); + $result->response->assertOK(); + + $this->assertEquals('factories/remove', $result->view); + $this->assertEquals(['factory' => $factory], $result->data); + } + + public function testRemoveAjax() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->asAjax()->call('remove', $factory->id); + $result->response->assertOK(); + + $this->assertEquals('factories/confirm', $result->view); + $this->assertEquals(['factory' => $factory], $result->data); + } +} diff --git a/tests/write/ControllerWriteTest.php b/tests/write/ControllerWriteTest.php new file mode 100644 index 0000000..aa077ab --- /dev/null +++ b/tests/write/ControllerWriteTest.php @@ -0,0 +1,81 @@ +controller(Factories::class); + } + + public function testCreate() + { + $_POST = [ + 'name' => 'Rainbow Factory', + 'uid' => 'bow', + 'class' => 'ModuleTests\Rainbows\Factory', + 'icon' => '', + 'summary' => '', + ]; + + $result = $this->execute('create'); + + $result->assertOK(); + $result->assertStatus(201); + $this->assertEquals('New Factory created successfully.', $result->response()->getReason()); + + // Get the last Factory to confirm the response + $factories = model(FactoryModel::class)->findAll(); + $factory = end($factories); + + $this->assertEquals($factory, json_decode($result->response()->getBody())); + } + + public function testUpdate() + { + $factory = model(FactoryModel::class)->first(); + + $_POST = ['name' => 'Banana Factory']; + + $result = $this->execute('update', $factory->id); + + $result->assertOK(); + $result->assertStatus(200); + $this->assertEquals('Factory updated successfully.', $result->response()->getReason()); + + $factory = model(FactoryModel::class)->find($factory->id); + $this->assertEquals($factory, json_decode($result->response()->getBody())); + } + + public function testDelete() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->execute('delete', $factory->id); + + $result->assertOK(); + $result->assertStatus(200); + $this->assertEquals('Factory deleted successfully.', $result->response()->getReason()); + + $factory = model(FactoryModel::class)->find($factory->id); + $this->assertNull($factory); + } +} diff --git a/tests/write/PresenterWriteTest.php b/tests/write/PresenterWriteTest.php new file mode 100644 index 0000000..48fc8e8 --- /dev/null +++ b/tests/write/PresenterWriteTest.php @@ -0,0 +1,85 @@ +controller(Factories::class); + } + + public function testCreate() + { + $_POST = [ + 'name' => 'Rainbow Factory', + 'uid' => 'bow', + 'class' => 'ModuleTests\Rainbows\Factory', + 'icon' => '', + 'summary' => '', + ]; + + $result = $this->execute('create'); + + $result->assertOK(); + $result->assertRedirectTo('factories'); + + // Get the last Factory to confirm the response + $factories = model(FactoryModel::class)->findAll(); + $factory = end($factories); + + $expected = [ + [ + 'class' => 'success', + 'text' => 'New factory created successfully.', + ], + ]; + + // @phpstan-ignore-next-line Remove after https://github.com/codeigniter4/CodeIgniter4/pull/4503 + $result->assertSessionHas('alerts-queue', $expected); + } + + public function testUpdate() + { + $factory = model(FactoryModel::class)->first(); + + $_POST = ['name' => 'Banana Factory']; + + $result = $this->execute('update', $factory->id); + $result->assertOK(); + $result->assertRedirectTo('factories/' . $factory->id); + + $factory = model(FactoryModel::class)->find($factory->id); + $this->assertEquals('Banana Factory', $factory->name); + } + + public function testDelete() + { + $factory = model(FactoryModel::class)->first(); + + $result = $this->execute('delete', $factory->id); + + $result->assertOK(); + $result->assertRedirectTo('factories'); + + $factory = model(FactoryModel::class)->find($factory->id); + $this->assertNull($factory); + } +}