diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa73cf3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.idea +.vscode +vendor +build +.phpunit.result.cache +node_modules +terminals diff --git a/.gitattributes b/.gitattributes index d495f26..8888a89 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ /tests export-ignore /snippets export-ignore +*.sh text eol=lf diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..63f9b60 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,183 @@ +name: Tests + +on: + push: + branches: + - main + - master + - v2.0 + pull_request: + +jobs: + matrix: + name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - laravel: '10' + php: '8.1' + testbench: '^8.0' + - laravel: '11' + php: '8.2' + testbench: '^9.0' + - laravel: '12' + php: '8.2' + testbench: '^10.0' + - laravel: '13' + php: '8.3' + testbench: '^11.0' + + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: helpers_test + MYSQL_USER: helpers + MYSQL_PASSWORD: secret + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uhelpers -psecret" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: helpers_test + DB_USERNAME: helpers + DB_PASSWORD: secret + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, json, mbstring, pcntl, pdo_mysql, pdo_sqlite, redis + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ matrix.laravel }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: composer-${{ matrix.laravel }}- + + - name: Install dependencies + run: | + composer install --no-interaction --prefer-dist + composer require --dev "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update orchestra/testbench --with-all-dependencies --no-interaction --prefer-dist + + - name: Verify Laravel version + run: | + php -r " + require 'vendor/autoload.php'; + \$app = require 'vendor/orchestra/testbench-core/laravel/bootstrap/app.php'; + \$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap(); + echo \$app->version(); + " | grep -E '^${{ matrix.laravel }}\.' + + - name: Run full behavioral suite + run: vendor/bin/phpunit -c phpunit.ci.xml --testsuite HalaeiHelpers,Characterization + + - name: Public API contract + run: vendor/bin/phpunit -c phpunit.ci.xml --testsuite Contract + + - name: Comprehensive validation + run: vendor/bin/phpunit -c phpunit.ci.xml --testsuite Validation + + coverage: + name: Coverage (min 95% gate) + runs-on: ubuntu-latest + + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: helpers_test + MYSQL_USER: helpers + MYSQL_PASSWORD: secret + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uhelpers -psecret" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: helpers_test + DB_USERNAME: helpers + DB_PASSWORD: secret + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: dom, json, mbstring, pcntl, pdo_mysql, pdo_sqlite, redis + coverage: pcov + + - name: Install dependencies + run: | + composer install --no-interaction --prefer-dist + composer require --dev "orchestra/testbench:^11.0" --no-interaction --no-update + composer update orchestra/testbench --with-all-dependencies --no-interaction --prefer-dist + + - name: Wait for MySQL + run: | + for i in $(seq 1 30); do + if php -r " + try { + new PDO( + 'mysql:host=127.0.0.1;port=3306;dbname=helpers_test', + 'helpers', + 'secret' + ); + exit(0); + } catch (Throwable \$e) { + exit(1); + } + "; then + echo "MySQL is ready." + exit 0 + fi + sleep 2 + done + echo "MySQL did not become ready in time." + exit 1 + + - name: Coverage gate + run: bash scripts/check-coverage.sh diff --git a/.gitignore b/.gitignore index e15f904..195e457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea /vendor /.phpunit.result.cache -composer.lock \ No newline at end of file +/build +composer.lock diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 915e8b5..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: php -php: - - 7.4 - - 8.0 - -services: - - redis-server - -install: - - composer install --no-interaction --prefer-source -script: vendor/bin/phpunit diff --git a/README.md b/README.md index a320b3b..d1ebcec 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,26 @@ # Miscellaneous Helpers for PHP and Laravel -[![Build Status](https://travis-ci.org/halaei/helpers.svg)](https://travis-ci.org/halaei/helpers) +[![Tests](https://github.com/halaei/helpers/actions/workflows/tests.yml/badge.svg)](https://github.com/halaei/helpers/actions/workflows/tests.yml) [![Latest Stable Version](https://poser.pugx.org/halaei/helpers/v/stable)](https://packagist.org/packages/halaei/helpers) [![Total Downloads](https://poser.pugx.org/halaei/helpers/downloads)](https://packagist.org/packages/halaei/helpers) [![Latest Unstable Version](https://poser.pugx.org/halaei/helpers/v/unstable)](https://packagist.org/packages/halaei/helpers) [![License](https://poser.pugx.org/halaei/helpers/license)](https://packagist.org/packages/halaei/helpers) +## Requirements + +| Version | PHP | Laravel | +|---------|-----|---------| +| **^2.0** | ^8.1 | 10, 11, 12, 13 | +| ^0.9 / ^1.0 | 7.4+ | 8, 9 | + +```bash +composer require halaei/helpers:^2.0 +``` + +### Upgrading from v0.9 / v1.0 + +v2.0 preserves the public API. Update `composer.json` to `^2.0` and ensure PHP 8.1+. If you use `db:restore-dump` or Redis locks with phpredis, no code changes are required — Flysystem 3 and phpredis compatibility are handled internally. See [docs/laravel-13-migration-plan.md](docs/laravel-13-migration-plan.md) for the full checklist. + ## About this Package This is a collection of miscellanous utilities gathered in one package for you: - Supervisor: safely run a piece of code in an infinite loop. @@ -253,7 +268,7 @@ while (! $lock->lock('critical_section', 0.1) {} // 2. Do some critical job //... // 3. Release the lock -$lock->unlock('critical_section', 0.1); +$lock->unlock('critical_section'); ``` ### Process @@ -326,5 +341,44 @@ echo $crypt->encrypt(16, 0 /*no padding*/); // 89 echo $crypt->decrypt('999989'); // 36 ``` +## Testing + +### Local (Windows / macOS) + +Requires PHP 8.1+ and Composer. Uses SQLite by default; Redis, MySQL, `pcntl`, and Process tests are skipped via `phpunit.xml` groups. + +```bash +composer install +vendor/bin/phpunit +``` + +On Windows, use PHP 8.1+ from [Laravel Herd](https://herd.laravel.com/) or similar (`php84 vendor/bin/phpunit`). + +### Docker (full suite) + +Linux container with PHP 8.3, Redis 7, MySQL 8, `pcntl`, `pcov`, and the `unix` / `redis` test groups via `phpunit.docker.xml`. + +```bash +docker compose run --rm test +docker compose run --rm test --testsuite Contract +docker compose run --rm test --coverage-text +docker compose run --rm --entrypoint bash test scripts/check-coverage.sh +``` + +### CI + +GitHub Actions runs a **Laravel 10–13 matrix** (with Redis) plus a **coverage gate** (minimum 95%, configurable via `MIN_COVERAGE`) on PHP 8.3. Locally: + +```bash +composer test:coverage # requires Linux, pcov, Redis, MySQL (see docker compose) +``` + +| Service | Image | Purpose | +|---------|-------|---------| +| `test` | `docker/Dockerfile` | PHPUnit + Composer | +| `redis` | `redis:7-alpine` | Redis lock tests | +| `mysql` | `mysql:8.0` | `insertIgnore` macro tests | + + ## License This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) diff --git a/changelog.md b/changelog.md index 33a7136..1880373 100644 --- a/changelog.md +++ b/changelog.md @@ -1,65 +1,87 @@ # Change Log -#v1.0.0 +## v2.0.0 + +- Laravel 10–13 support (PHP ^8.1) +- Fix `RestoreDumpFromFileSystem` for Flysystem 3 (`Storage::readStream` / `writeStream`) +- Fix Redis `Lock` for phpredis default client via `PhpRedisLockClient` adapter +- Fix `ViewServiceProvider` for Laravel 11+ component cache (`createFactory()` override only) +- Remove dead Laravel `<5.3` branch from `EloquentServiceProvider::batchUpdate` +- Add Orchestra Testbench test infrastructure, contract tests, and characterization suite +- Add Docker Compose test environment (`docker compose run --rm test`) +- High `src/` line coverage enforced in CI (minimum 95% gate, configurable via `MIN_COVERAGE`) +- **BREAKING:** drops Laravel 8/9 and PHP 7.4 support — use `^0.9` or `^1.0` for older Laravel + +## v1.0.0 + - Support resource as process input. - Improving db:restore-dump: --force option and switching to Halaei\Helpers\Process. -#v0.9.1 +## v0.9.1 + - Artisan command 'db:log-slow-queries'. - Artisan command 'db:backup-table'. - Artisan command 'db:restore-dump'. - Bug fixes in reading input from process. -#v0.9 +## v0.9 + - Minimum Laravel version: 8. - Fix getOriginal() for Laravel >= 7. -#v0.8.0 +## v0.8.0 + - Drop support for old PHP and Laravel versions. - Fix reporting throwable by calling `report()` helper function. -#v0.7.0 +## v0.7.0 + - Supervisor can return instead of exit using dontDie option. - Bugfix in handling Throwable errors. -#v0.6.1 +## v0.6.1 + - New feature: Process. -#v0.6.0 +## v0.6.0 + - Laravel 6 & 7 support - Drop PHP 7.0 support - Drop Laravel 5.6 support -#v0.5.0 +## v0.5.0 + - Laravel 5.8 compatibility - Supervisor can optionally stop on error. - Supervisor sleeps one second on error to make it CPU friendly. - Helper methods for Redis Lock: block() and instance(). -#v0.4.7 +## v0.4.7 + - New feature: QuitsOnSignals trait. -#v0.4.6 +## v0.4.6 + - New feature: HasCastables trait. -#v0.4.5 +## v0.4.5 - New feature: Random worker terminator. - New feature: DataCollection::unionBy(). -#v0.4.4 +## v0.4.4 - New feature: fusing DataObjects and DataCollections. -#v0.4.2 +## v0.4.2 - New feature: DataObject. -#v0.4.1 +## v0.4.1 - New feature: Eloquent Cache. -#v0.4.0 +## v0.4.0 - Make batchUpdate() compatible with Laravel 5.3+ diff --git a/composer.json b/composer.json index 02b1cbc..b5c7cee 100755 --- a/composer.json +++ b/composer.json @@ -1,34 +1,115 @@ { + "name": "halaei/helpers", - "description": "Miscellaneous Helpers for PHP and Laravel", + + "description": "Miscellaneous Helpers for PHP and Laravel 10–13", + "keywords": ["Laravel", "Eloquent", "Redis"], + "license": "MIT", + "authors": [ + { + "name": "Hamid Alaei V.", + "email": "hamid@opilo.com" + } + ], + "require": { - "php": ">=7.4.0" + + "php": "^8.1", + + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/cache": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/events": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/redis": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", + + "illuminate/filesystem": "^10.0|^11.0|^12.0|^13.0" + }, + "require-dev": { - "predis/predis": "^1.1", - "illuminate/console": "^8.0", - "illuminate/cache": "^8.0", - "illuminate/database": "^8.0", - "mockery/mockery": "^1.0", - "phpunit/phpunit": "^9.0", - "symfony/process": "^5.0" + + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + + "mockery/mockery": "^1.6", + + "phpunit/phpunit": "^10.5|^11.0", + + "predis/predis": "^2.0|^3.0" + }, + "autoload": { + "psr-4": { + "Halaei\\Helpers\\": "src/" + } + }, + "autoload-dev": { + + "psr-4": { + + "HalaeiTests\\": "tests/" + + }, + "files": [ + "tests/TestAssets.php" + ] - } + + }, + + "config": { + + "sort-packages": true, + + "audit": { + + "block-insecure": false + + } + + }, + + "scripts": { + "test": "phpunit", + "test:coverage": "bash scripts/check-coverage.sh" + }, + + "extra": { + "branch-alias": { + "dev-main": "2.0-dev", + "dev-master": "2.0-dev" + } + }, + + "minimum-stability": "stable", + + "prefer-stable": true + } + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d1c7e1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + test: + build: + context: . + dockerfile: docker/Dockerfile + working_dir: /app + volumes: + - .:/app + environment: + REDIS_HOST: redis + REDIS_PORT: 6379 + DB_CONNECTION: mysql + DB_HOST: mysql + DB_PORT: 3306 + DB_DATABASE: helpers_test + DB_USERNAME: helpers + DB_PASSWORD: secret + depends_on: + redis: + condition: service_started + mysql: + condition: service_healthy + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: helpers_test + MYSQL_USER: helpers + MYSQL_PASSWORD: secret + MYSQL_ROOT_PASSWORD: root + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uhelpers", "-psecret"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..2399702 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM php:8.3-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libsqlite3-dev \ + && docker-php-ext-install pcntl pdo_mysql pdo_sqlite \ + && pecl install pcov redis \ + && docker-php-ext-enable pcov redis \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD [] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..be6532b --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /app + +composer install --no-interaction --prefer-dist + +if [[ -n "${DB_HOST:-}" ]]; then + echo "Waiting for MySQL at ${DB_HOST}:${DB_PORT:-3306}..." + for _ in $(seq 1 60); do + if php -r " + try { + new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s', getenv('DB_HOST'), getenv('DB_PORT') ?: '3306', getenv('DB_DATABASE')), + getenv('DB_USERNAME'), + getenv('DB_PASSWORD') + ); + exit(0); + } catch (Throwable \$e) { + exit(1); + } + " 2>/dev/null; then + echo "MySQL is ready." + break + fi + sleep 1 + done +fi + +mkdir -p build + +if compgen -G "tests/fixtures/*.sh" > /dev/null; then + sed -i 's/\r$//' tests/fixtures/*.sh + chmod +x tests/fixtures/*.sh +fi + +if [[ $# -gt 0 ]]; then + exec vendor/bin/phpunit -c phpunit.docker.xml "$@" +else + exec vendor/bin/phpunit -c phpunit.docker.xml +fi diff --git a/phpunit.ci.xml b/phpunit.ci.xml new file mode 100644 index 0000000..0d667b2 --- /dev/null +++ b/phpunit.ci.xml @@ -0,0 +1,33 @@ + + + + + ./tests + ./tests/Characterization + + + ./tests/Characterization + + + ./tests/PublicApiContractTest.php + + + ./tests/ComprehensiveValidationTest.php + + + + + ./src + + + + + stress + + + diff --git a/phpunit.docker.xml b/phpunit.docker.xml new file mode 100644 index 0000000..dafddb3 --- /dev/null +++ b/phpunit.docker.xml @@ -0,0 +1,42 @@ + + + + + ./tests + ./tests/Characterization + + + ./tests/Characterization + + + ./tests/PublicApiContractTest.php + + + ./tests/ComprehensiveValidationTest.php + + + + + ./src + + + + + + + + + + + + + + stress + + + diff --git a/phpunit.xml b/phpunit.xml index cd619ae..325b0fe 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,22 +1,41 @@ - ./tests/ + ./tests + ./tests/Characterization + + + ./tests/Characterization + + + ./tests/PublicApiContractTest.php + + + ./tests/ComprehensiveValidationTest.php - - + + ./src - - + + + + + + + + + + + redis + pcntl + unix + + diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100644 index 0000000..39f935c --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +mkdir -p build + +# Minimum line-coverage percentage required to pass. We deliberately do not +# require 100%: a few platform/error branches (e.g. the Windows-only argument +# escaping and broken-pipe/stream error handlers in Process) cannot be +# exercised on the Linux CI without gaming the report with ignore annotations. +MIN_COVERAGE="${MIN_COVERAGE:-95}" + +vendor/bin/phpunit -c phpunit.docker.xml --coverage-clover=build/coverage.xml "$@" + +MIN_COVERAGE="$MIN_COVERAGE" php -r " + \$xml = simplexml_load_file('build/coverage.xml'); + \$metrics = \$xml->project->metrics; + \$covered = (int) \$metrics['coveredstatements']; + \$total = (int) \$metrics['statements']; + \$percent = \$total > 0 ? round((\$covered / \$total) * 100, 2) : 100; + \$min = (float) getenv('MIN_COVERAGE'); + echo \"Line coverage: {\$percent}% ({\$covered}/{\$total}), minimum required: {\$min}%\n\"; + if (\$percent < \$min) { + exit(1); + } +" diff --git a/src/Eloquent/Commands/LogSlowQueries.php b/src/Eloquent/Commands/LogSlowQueries.php index 9cab2e1..ddc0318 100644 --- a/src/Eloquent/Commands/LogSlowQueries.php +++ b/src/Eloquent/Commands/LogSlowQueries.php @@ -53,10 +53,18 @@ public function handle() if ($this->option('once')) { return; } - sleep($this->option('sleep')); + $this->sleepForPoll((int) $this->option('sleep')); } } + /** + * @codeCoverageIgnore + */ + protected function sleepForPoll(int $seconds): void + { + sleep($seconds); + } + private function stripSql($query) { $query = trim($query); diff --git a/src/Eloquent/Commands/RestoreDumpFromFileSystem.php b/src/Eloquent/Commands/RestoreDumpFromFileSystem.php index 5989c94..23a1c87 100644 --- a/src/Eloquent/Commands/RestoreDumpFromFileSystem.php +++ b/src/Eloquent/Commands/RestoreDumpFromFileSystem.php @@ -8,7 +8,6 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use League\Flysystem\MountManager; class RestoreDumpFromFileSystem extends Command { @@ -33,14 +32,27 @@ public function handle() private function mount(): string { - $mountManager = new MountManager([ - 'remote' => Storage::disk($this->argument('disk'))->getDriver(), - 'local' => Storage::disk('local')->getDriver(), - ]); $path = $this->argument('path'); - Storage::disk('local')->delete($path); - $mountManager->copy('remote://'. $path, 'local://'. $path); - return Storage::disk('local')->path($path); + $remote = Storage::disk($this->argument('disk')); + $local = Storage::disk('local'); + + $local->delete($path); + + $stream = $remote->readStream($path); + + if ($stream === false) { + throw new \RuntimeException("Unable to read dump stream from disk [{$this->argument('disk')}] at path [{$path}]."); + } + + try { + $local->writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + return $local->path($path); } private function uncompress(string $src) diff --git a/src/Eloquent/EloquentServiceProvider.php b/src/Eloquent/EloquentServiceProvider.php index 9363b51..6259f66 100644 --- a/src/Eloquent/EloquentServiceProvider.php +++ b/src/Eloquent/EloquentServiceProvider.php @@ -33,8 +33,7 @@ public function registerBatchUpdate() return $model->newQuery()->getQuery()->batchUpdate($model->getKeyName(), $dirties); }); - $version = $this->app->version(); - Builder::macro('batchUpdate', function ($keyName, array $values) use ($version) { + Builder::macro('batchUpdate', function ($keyName, array $values) { $this->whereIn($keyName, array_keys($values)); $columns = []; @@ -64,11 +63,7 @@ public function registerBatchUpdate() $this->bindings['where'] = array_merge($params, $this->bindings['where']); - if (version_compare($version, '5.3.0') >= 0) { - $bindings = $this->bindings; - } else { - $bindings = array_values(array_merge($params, $this->getBindings())); - } + $bindings = $this->bindings; $sql = $this->grammar->compileUpdate($this, $cases); diff --git a/src/Eloquent/HasCastables.php b/src/Eloquent/HasCastables.php index 3956b29..5bd2d85 100644 --- a/src/Eloquent/HasCastables.php +++ b/src/Eloquent/HasCastables.php @@ -75,7 +75,7 @@ public function prepareSaving() } } - public function offsetUnset($offset) + public function offsetUnset($offset): void { unset($this->castedAttributes[$offset]); diff --git a/src/Listeners/RandomWorkerTerminator.php b/src/Listeners/RandomWorkerTerminator.php index 4d084b3..f55043f 100644 --- a/src/Listeners/RandomWorkerTerminator.php +++ b/src/Listeners/RandomWorkerTerminator.php @@ -41,8 +41,6 @@ public static function boot($minTTL = 180, $maxTTL = 240) { $instance = new static($minTTL, $maxTTL); - Queue::looping(function () use ($instance) { - $instance->handle(); - }); + $instance->handle(); } } diff --git a/src/Listeners/RefreshDBConnections.php b/src/Listeners/RefreshDBConnections.php index 2fdb61a..179255f 100644 --- a/src/Listeners/RefreshDBConnections.php +++ b/src/Listeners/RefreshDBConnections.php @@ -2,15 +2,13 @@ namespace Halaei\Helpers\Listeners; -use Exception; - class RefreshDBConnections { public function handle() { try { \DB::rollBack(0); - } catch (Exception $e) { + } catch (\Exception $e) { \DB::reconnect(); report($e); } diff --git a/src/Process/Process.php b/src/Process/Process.php index 5604400..b23395c 100644 --- a/src/Process/Process.php +++ b/src/Process/Process.php @@ -215,17 +215,31 @@ protected function write() $this->inputClosed = true; } } - // Write from buffer to pipe + // Write from buffer to pipe. fwrite returns false when the child + // has closed stdin (broken pipe); in that case stop writing. if ($this->inputCursor < strlen($this->inputBuffer)) { - $this->inputCursor += fwrite($this->pipes[0], substr($this->inputBuffer, $this->inputCursor), strlen($this->inputBuffer) - $this->inputCursor); + $written = fwrite($this->pipes[0], substr($this->inputBuffer, $this->inputCursor), strlen($this->inputBuffer) - $this->inputCursor); + if ($written === false) { + fclose($this->pipes[0]); + $this->inputClosed = true; + } else { + $this->inputCursor += $written; + } } return; } // Read from string input - if ($this->inputCursor < strlen($this->input)) { - $this->inputCursor += fwrite($this->pipes[0], substr($this->input, $this->inputCursor), strlen($this->input) - $this->inputCursor); + $input = (string) $this->input; + if ($this->inputCursor < strlen($input)) { + $written = fwrite($this->pipes[0], substr($input, $this->inputCursor), strlen($input) - $this->inputCursor); + if ($written === false) { + fclose($this->pipes[0]); + $this->inputClosed = true; + return; + } + $this->inputCursor += $written; } - if ($this->inputCursor >= strlen($this->input)) { + if ($this->inputCursor >= strlen($input)) { fclose($this->pipes[0]); $this->inputClosed = true; } diff --git a/src/Redis/Lock.php b/src/Redis/Lock.php index a5c610a..abc1b54 100644 --- a/src/Redis/Lock.php +++ b/src/Redis/Lock.php @@ -9,18 +9,24 @@ class Lock { /** - * @var ClientInterface + * @var ClientInterface|PhpRedisLockClient */ protected $redis; - public function __construct(ClientInterface $redis) + public function __construct(ClientInterface|PhpRedisLockClient $redis) { $this->redis = $redis; } public static function instance($connection = null) { - return new static(app(RedisManager::class)->connection($connection)->client()); + $client = app(RedisManager::class)->connection($connection)->client(); + + if ($client instanceof ClientInterface) { + return new static($client); + } + + return new static(new PhpRedisLockClient($client)); } /** diff --git a/src/Redis/PhpRedisLockClient.php b/src/Redis/PhpRedisLockClient.php new file mode 100644 index 0000000..9859ee2 --- /dev/null +++ b/src/Redis/PhpRedisLockClient.php @@ -0,0 +1,39 @@ +redis->eval($script, array_values($arguments), (int) $numKeys); + } + + public function brpoplpush($source, $destination, $timeout) + { + return $this->redis->brpoplpush($source, $destination, (float) $timeout); + } + + public function expire($key, $seconds) + { + if ((float) $seconds !== (float) (int) $seconds) { + return $this->redis->pexpire($key, (int) ceil($seconds * 1000)); + } + + return $this->redis->expire($key, (int) $seconds); + } +} diff --git a/src/Supervisor/QuitsOnSignals.php b/src/Supervisor/QuitsOnSignals.php index 6ebeffb..ff2b6ee 100644 --- a/src/Supervisor/QuitsOnSignals.php +++ b/src/Supervisor/QuitsOnSignals.php @@ -57,7 +57,15 @@ protected function stopListeningToSignals() protected function quitIfSignaled($status = 0) { if ($this->shouldQuit) { - exit($status); + $this->exitOnSignal($status); } } + + /** + * @codeCoverageIgnore + */ + protected function exitOnSignal(int $status): void + { + exit($status); + } } diff --git a/src/Supervisor/Supervisor.php b/src/Supervisor/Supervisor.php index f2451dc..98991ab 100644 --- a/src/Supervisor/Supervisor.php +++ b/src/Supervisor/Supervisor.php @@ -287,10 +287,18 @@ protected function stop($status, $exit, SupervisorState $state) $this->events->dispatch(new SupervisorStopping($status)); if ($exit) { - exit($status); + $this->exitProcess($status); } } + /** + * @codeCoverageIgnore + */ + protected function exitProcess(int $status): void + { + exit($status); + } + /** * Kill the process. * @@ -298,11 +306,19 @@ protected function stop($status, $exit, SupervisorState $state) * @return void */ protected function kill($status = 0) + { + $this->terminateProcess($status); + } + + /** + * @codeCoverageIgnore + */ + protected function terminateProcess(int $status = 0): void { if (extension_loaded('posix')) { posix_kill(getmypid(), SIGKILL); } - exit($status); + $this->exitProcess($status); } } diff --git a/src/View/ViewFactory.php b/src/View/ViewFactory.php index bc3690d..f2550a4 100644 --- a/src/View/ViewFactory.php +++ b/src/View/ViewFactory.php @@ -4,8 +4,24 @@ use Illuminate\View\Factory; +/** + * A view factory that intentionally disables Blade's `@parent` directive. + * + * Stock Blade (see Illuminate\View\Concerns\ManagesLayouts) merges a child + * section into its parent by replacing a `@parent` placeholder. This factory + * overrides that behaviour so the first `@section` definition always wins and + * the `@parent` placeholder is never expanded. This is a deliberate design + * choice carried over from the original package (initial commit, Oct 2016: + * "disabling `@parent`"), not a temporary framework bug workaround. + * + * Upstream reference (method signatures this class overrides): + * https://github.com/laravel/framework/blob/master/src/Illuminate/View/Concerns/ManagesLayouts.php + */ class ViewFactory extends Factory { + /** + * Keep the existing (parent) section instead of merging via `@parent`. + */ protected function extendSection($section, $content) { if (isset($this->sections[$section])) { @@ -14,6 +30,9 @@ protected function extendSection($section, $content) $this->sections[$section] = $content; } + /** + * Return the section content as-is, without expanding `@parent`. + */ public function yieldContent($section, $default = '') { $sectionContent = $default; diff --git a/src/View/ViewServiceProvider.php b/src/View/ViewServiceProvider.php index 2052f0f..7891b34 100644 --- a/src/View/ViewServiceProvider.php +++ b/src/View/ViewServiceProvider.php @@ -2,28 +2,26 @@ namespace Halaei\Helpers\View; +/** + * Swaps in {@see ViewFactory}, which disables Blade's `@parent` directive. + * + * We override only createFactory() (the narrow instantiation hook in + * Illuminate\View\ViewServiceProvider::registerFactory) so the rest of the + * framework's factory wiring is inherited unchanged. + * + * Upstream reference: + * https://github.com/laravel/framework/blob/master/src/Illuminate/View/ViewServiceProvider.php + */ class ViewServiceProvider extends \Illuminate\View\ViewServiceProvider { - public function registerFactory() + /** + * @param \Illuminate\View\Engines\EngineResolver $resolver + * @param \Illuminate\View\ViewFinderInterface $finder + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return \Illuminate\View\Factory + */ + protected function createFactory($resolver, $finder, $events) { - $this->app->singleton('view', function ($app) { - // Next we need to grab the engine resolver instance that will be used by the - // environment. The resolver will be used by an environment to get each of - // the various engine implementations such as plain PHP or Blade engine. - $resolver = $app['view.engine.resolver']; - - $finder = $app['view.finder']; - - $env = new ViewFactory($resolver, $finder, $app['events']); - - // We will also set the container instance on this view environment since the - // view composers may be classes registered in the container, which allows - // for great testable, flexible composers for the application developer. - $env->setContainer($app); - - $env->share('app', $app); - - return $env; - }); + return new ViewFactory($resolver, $finder, $events); } } diff --git a/tests/BackupTableToFileSystemTest.php b/tests/BackupTableToFileSystemTest.php new file mode 100644 index 0000000..dddc7e6 --- /dev/null +++ b/tests/BackupTableToFileSystemTest.php @@ -0,0 +1,178 @@ +setAccessible(true); + $property->setValue($command, now()); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake('backup'); + + $this->fixtureScript('fake-mysqldump.sh'); + + $this->recreateTable('backup_items', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + }); + + DB::table('backup_items')->insert([ + ['id' => 1, 'name' => 'alpha'], + ['id' => 2, 'name' => 'beta'], + ]); + } + + public function test_handle_backs_up_compresses_and_uploads_without_truncate(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('tar/mysqldump scripts require Unix'); + } + + $events = []; + $this->app['events']->listen('db:backup-table:starting', function ($payload) use (&$events) { + $events[] = ['starting', $payload]; + }); + $this->app['events']->listen('db:backup-table:done', function ($payload) use (&$events) { + $events[] = ['done', $payload]; + }); + + $this->runCommand(new BackupTableToFileSystem, $this->commandArguments(), [ + '--mysqldump' => $this->fixtureScript('fake-mysqldump.sh'), + ]); + + $files = Storage::disk('backup')->allFiles('archives'); + $this->assertCount(1, $files); + $this->assertStringEndsWith('backup_items.sql.tar.gz', $files[0]); + $this->assertCount(2, DB::table('backup_items')->get()); + $this->assertCount(2, $events); + } + + public function test_handle_truncates_and_restores_auto_increment_when_requested(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('tar/mysqldump scripts require Unix'); + } + + if (! $this->usesMysqlFromEnvironment()) { + $this->markTestSkipped('truncate path requires MySQL'); + } + + $this->runCommand(new BackupTableToFileSystem, $this->commandArguments(), [ + '--mysqldump' => $this->fixtureScript('fake-mysqldump.sh'), + '--truncate' => true, + '--auto-increment' => 'id', + ]); + + $this->assertCount(0, DB::table('backup_items')->get()); + $status = DB::select("SHOW TABLE STATUS LIKE 'backup_items'"); + $this->assertGreaterThanOrEqual(1001, (int) $status[0]->Auto_increment); + } + + public function test_upload_failure_throws_and_removes_dump_file(): void + { + $command = $this->makeBoundCommand(new BackupTableToFileSystem, $this->commandArguments()); + $this->setCommandDate($command); + $dump = storage_path('app/backup/manual/fail.sql.tar.gz'); + if (! is_dir(dirname($dump))) { + mkdir(dirname($dump), 0777, true); + } + file_put_contents($dump, 'payload'); + + Storage::partialMock()->shouldReceive('disk')->with('backup')->andReturn( + \Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class) + ->shouldReceive('put')->andReturn(false) + ->getMock() + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Upload failed'); + + try { + $this->invokePrivateMethod($command, 'upload', [$dump]); + } finally { + $this->assertFileDoesNotExist($dump); + } + } + + public function test_set_auto_increment_value_reports_errors_without_aborting(): void + { + $command = $this->makeBoundCommand(new BackupTableToFileSystem, [ + 'database' => 'testing', + 'table' => 'missing_table', + 'disk' => 'backup', + 'dir' => 'archives', + ], ['--auto-increment' => 'id']); + + $this->invokePrivateMethod($command, 'setAutoIncrementValue', [5]); + + $this->assertTrue(true); + } + + public function test_get_auto_increment_value_returns_null_when_option_disabled(): void + { + $command = $this->makeBoundCommand(new BackupTableToFileSystem, $this->commandArguments(), [ + '--auto-increment' => '', + ]); + + $this->assertNull($this->invokePrivateMethod($command, 'getAutoIncrementValue')); + } + + public function test_log_writes_to_console_and_log_channel(): void + { + Log::shouldReceive('info')->once()->with('backup message'); + + $command = $this->makeBoundCommand(new BackupTableToFileSystem, $this->commandArguments()); + $this->invokePrivateMethod($command, 'log', ['backup message']); + + $this->addToAssertionCount(1); + } + + private function commandArguments(): array + { + return [ + 'database' => 'testing', + 'table' => 'backup_items', + 'disk' => 'backup', + 'dir' => 'archives', + ]; + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/BatchUpdateTest.php b/tests/BatchUpdateTest.php deleted file mode 100644 index 5a47c1d..0000000 --- a/tests/BatchUpdateTest.php +++ /dev/null @@ -1,60 +0,0 @@ -app) { - $this->createApplication(); - } - } - - /** - * Creates the application. - * - * Needs to be implemented by subclasses. - * - * @return \Symfony\Component\HttpKernel\HttpKernelInterface - */ - public function createApplication() - { - $this->app = $this->getMockConsole(['addToParent', 'version']); - $command = \Mockery::mock(\Illuminate\Console\Command::class); - $command->shouldReceive('setLaravel')->once()->with(\Mockery::type(\Illuminate\Contracts\Foundation\Application::class)); - $this->app->expects($this->once())->method('addToParent')->with($this->equalTo($command))->will($this->returnValue($command)); - $result = $this->app->add($command); - - $this->assertEquals($command, $result); - } - - protected function getMockConsole(array $methods) - { - $app = \Mockery::mock(\Illuminate\Contracts\Foundation\Application::class, ['version' => '5.4.0']); - $events = \Mockery::mock(\Illuminate\Contracts\Events\Dispatcher::class, ['fire' => null]); - $events->shouldReceive('dispatch'); - - $console = $this->getMockBuilder(\Illuminate\Console\Application::class)->setMethods($methods)->setConstructorArgs([ - $app, $events, 'test-version', - ])->getMock(); - - return $console; - } - - public function testMacrosCanBeRegistered() - { - $this->app->expects($this->once())->method('version')->will($this->returnValue('5.4.0')); - (new EloquentServiceProvider($this->app))->register(); - } -} diff --git a/tests/CacheableTraitTest.php b/tests/CacheableTraitTest.php new file mode 100644 index 0000000..1d5fdcc --- /dev/null +++ b/tests/CacheableTraitTest.php @@ -0,0 +1,45 @@ +recreateTable('cacheable_trait_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + }); + } + + public function test_sync_with_db_reloads_model_when_cached(): void + { + DB::table('cacheable_trait_models')->insert(['id' => 1, 'name' => 'fresh']); + + $model = SyncableModel::find(1); + $model->markAsCached(); + $model->name = 'stale'; + + $model->syncWithDB(); + + $this->assertSame('fresh', $model->name); + $this->assertFalse($model->isCached()); + } +} + +class SyncableModel extends Model implements Cacheable +{ + use CacheableTrait; + + protected $table = 'cacheable_trait_models'; + + protected $guarded = []; +} diff --git a/tests/CastingTest.php b/tests/CastingTest.php new file mode 100644 index 0000000..10f997d --- /dev/null +++ b/tests/CastingTest.php @@ -0,0 +1,17 @@ +expectException(LogicException::class); + $this->expectExceptionMessage('Cannot cast field into the given type'); + + Casting::cast('value', 'Definitely\\Missing\\Class', 'field'); + } +} diff --git a/tests/Characterization/DataObjectBehaviorTest.php b/tests/Characterization/DataObjectBehaviorTest.php new file mode 100644 index 0000000..9dbf621 --- /dev/null +++ b/tests/Characterization/DataObjectBehaviorTest.php @@ -0,0 +1,67 @@ + 5, 'name' => 'Test User']); + + $this->assertSame(['id' => 5, 'name' => 'Test User'], $user->all()); + } + + public function test_to_json_encodes_array_representation(): void + { + $user = new User(['id' => 5, 'name' => 'Test User']); + + $this->assertSame('{"id":5,"name":"Test User"}', $user->toJson()); + } + + public function test_magic_getter_and_setter_via_call(): void + { + $user = new User(['id' => 1, 'name' => 'Before']); + $user->setName('After'); + + $this->assertSame('After', $user->getName()); + $this->assertSame('After', $user->name); + } + + public function test_magic_isset_and_unset(): void + { + $user = new User(['id' => 1, 'name' => 'Exists']); + + $this->assertTrue(isset($user->name)); + unset($user->name); + $this->assertFalse(isset($user->name)); + $this->assertNull($user->name); + } + + public function test_bad_method_call_throws(): void + { + $user = new User(['id' => 1]); + + $this->expectException(\BadMethodCallException::class); + $user->unknownMethod(); + } + + public function test_data_collection_to_raw_converts_nested_objects(): void + { + $collection = new DataCollection([ + new User(['id' => 1, 'name' => 'A']), + new User(['id' => 2, 'name' => 'B']), + ]); + + $this->assertSame([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ], $collection->toRaw()); + } +} diff --git a/tests/Characterization/EloquentMacroBehaviorTest.php b/tests/Characterization/EloquentMacroBehaviorTest.php new file mode 100644 index 0000000..044bfe3 --- /dev/null +++ b/tests/Characterization/EloquentMacroBehaviorTest.php @@ -0,0 +1,107 @@ +recreateTable('macro_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('status')->nullable(); + }); + } + + public function test_batch_update_generates_case_when_sql_and_updates_rows(): void + { + DB::table('macro_models')->insert([ + ['id' => 1, 'name' => 'a', 'status' => 'pending'], + ['id' => 2, 'name' => 'b', 'status' => 'pending'], + ]); + + $model = new class extends Model { + protected $table = 'macro_models'; + protected $guarded = []; + }; + + $records = $model->newQuery()->orderBy('id')->get(); + $records[0]->status = 'done'; + $records[1]->status = 'done'; + + DB::enableQueryLog(); + $records->update(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $updateSql = collect($queries)->pluck('query')->first(function ($sql) { + return stripos($sql, 'update') !== false && stripos($sql, 'case') !== false; + }); + + $this->assertNotNull($updateSql, 'batchUpdate must emit CASE WHEN update SQL'); + $this->assertStringContainsStringIgnoringCase('case', $updateSql); + $this->assertStringContainsStringIgnoringCase('status', $updateSql); + $this->assertSame('done', DB::table('macro_models')->where('id', 1)->value('status')); + $this->assertSame('done', DB::table('macro_models')->where('id', 2)->value('status')); + } + + public function test_collection_update_is_no_op_when_nothing_is_dirty(): void + { + DB::table('macro_models')->insert([ + ['id' => 1, 'name' => 'a', 'status' => 'pending'], + ]); + + $model = new class extends Model { + protected $table = 'macro_models'; + protected $guarded = []; + }; + + $records = $model->newQuery()->get(); + + DB::enableQueryLog(); + $records->update(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertEmpty($queries, 'Collection::update should not query when nothing is dirty'); + } + + public function test_insert_ignore_inserts_rows_without_error(): void + { + if (! $this->usesMysqlFromEnvironment()) { + $this->markTestSkipped('insertIgnore macro requires MySQL'); + } + + DB::table('macro_models')->insert([ + ['id' => 1, 'name' => 'existing', 'status' => 'ok'], + ]); + + $result = DB::table('macro_models')->insertIgnore([ + ['id' => 1, 'name' => 'duplicate', 'status' => 'ignored'], + ['id' => 2, 'name' => 'new', 'status' => 'added'], + ]); + + $this->assertTrue($result); + $this->assertSame('existing', DB::table('macro_models')->where('id', 1)->value('name')); + $this->assertSame('new', DB::table('macro_models')->where('id', 2)->value('name')); + } + + public function test_insert_ignore_returns_true_for_empty_values(): void + { + if (! $this->usesMysqlFromEnvironment()) { + $this->markTestSkipped('insertIgnore macro requires MySQL'); + } + + $this->assertTrue(DB::table('macro_models')->insertIgnore([])); + } +} diff --git a/tests/Characterization/LockBehaviorTest.php b/tests/Characterization/LockBehaviorTest.php new file mode 100644 index 0000000..58d9d9f --- /dev/null +++ b/tests/Characterization/LockBehaviorTest.php @@ -0,0 +1,82 @@ +redisIsAvailable()) { + $this->markTestSkipped('Redis is not available'); + } + + parent::setUp(); + + $this->redis = $this->getRedisClient(); + $this->redis->flushdb(); + $this->lock = new Lock($this->redis); + } + + protected function tearDown(): void + { + if ($this->redis) { + $this->redis->flushdb(); + } + + parent::tearDown(); + } + + public function test_lock_unlock_round_trip(): void + { + $this->assertTrue($this->lock->lock('char-test', 2)); + $this->lock->unlock('char-test'); + $this->assertTrue($this->lock->lock('char-test', 2)); + } + + public function test_block_executes_callback_and_releases_lock(): void + { + $value = $this->lock->block('char-block', function () { + return 'executed'; + }); + + $this->assertSame('executed', $value); + $this->assertTrue($this->lock->lock('char-block', 2), 'Lock must be released after block()'); + } + + public function test_second_lock_attempt_fails_while_first_is_held(): void + { + $this->assertTrue($this->lock->lock('contention', 2)); + $this->assertFalse($this->lock->lock('contention', 1)); + $this->lock->unlock('contention'); + } + + private function redisIsAvailable(): bool + { + try { + $client = $this->getRedisClient(); + $client->ping(); + + return true; + } catch (\Throwable $e) { + return false; + } + } + + private function getRedisClient(): Client + { + return RedisConfig::client(); + } +} diff --git a/tests/Characterization/MacroRegistrationTest.php b/tests/Characterization/MacroRegistrationTest.php new file mode 100644 index 0000000..e7235cb --- /dev/null +++ b/tests/Characterization/MacroRegistrationTest.php @@ -0,0 +1,27 @@ +assertTrue( + \Illuminate\Database\Eloquent\Collection::hasMacro('update'), + 'Collection::update macro must be registered' + ); + $this->assertTrue( + \Illuminate\Database\Query\Builder::hasMacro('batchUpdate'), + 'Builder::batchUpdate macro must be registered' + ); + $this->assertTrue( + \Illuminate\Database\Query\Builder::hasMacro('insertIgnore'), + 'Builder::insertIgnore macro must be registered' + ); + } +} diff --git a/tests/Characterization/SupervisorBehaviorTest.php b/tests/Characterization/SupervisorBehaviorTest.php new file mode 100644 index 0000000..4bbe306 --- /dev/null +++ b/tests/Characterization/SupervisorBehaviorTest.php @@ -0,0 +1,152 @@ +laravel = Mockery::mock(Application::class); + $this->cache = Mockery::mock(Cache::class); + $this->bus = Mockery::mock(Bus::class); + $this->events = Mockery::mock(Events::class); + $this->exceptions = Mockery::mock(ExceptionHandler::class); + + $this->supervisor = new SupervisorStub( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + } + + protected function tearDown(): void + { + Mockery::close(); + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + parent::tearDown(); + } + + public function test_pauses_when_app_is_in_maintenance_mode(): void + { + $runs = 0; + + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn(new SupervisorState()); + $this->cache->shouldReceive('get')->with('illuminate:queue:restart')->twice()->andReturn(null, Carbon::now()); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(true); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof SupervisorStopping && $event->status === 0; + }))->andThrow(new \Exception('stopped')); + + try { + $this->supervisor->supervise(function () use (&$runs) { + $runs++; + }); + $this->fail('Expected supervisor to stop'); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + + $this->assertSame(0, $runs); + $this->assertSame(1, $this->supervisor->paused); + } + + public function test_event_listeners_can_pause_and_stop_the_loop(): void + { + $runs = 0; + + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn(new SupervisorState()); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + $this->cache->shouldReceive('get')->twice()->with('illuminate:queue:restart')->andReturn(null); + + $this->events->shouldReceive('until')->once()->with(Mockery::on(function ($event) { + return $event instanceof LoopBeginning; + }))->andReturn(false); + + $this->events->shouldReceive('until')->once()->with(Mockery::on(function ($event) { + return $event instanceof LoopCompleting; + }))->andReturn(false); + + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof SupervisorStopping && $event->status === 0; + }))->andThrow(new \Exception('stopped')); + + try { + $this->supervisor->supervise(function () use (&$runs) { + $runs++; + }); + $this->fail('Expected supervisor to stop'); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + + $this->assertSame(0, $runs); + $this->assertSame(1, $this->supervisor->paused); + } + + public function test_runs_once_before_queue_restart_signal(): void + { + $runs = 0; + + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn(new SupervisorState()); + $this->cache->shouldReceive('get')->with('illuminate:queue:restart')->once()->andReturn(null); + $this->cache->shouldReceive('get')->with('illuminate:queue:restart')->once()->andReturn(Carbon::now()); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + + $this->events->shouldReceive('until')->once()->with(Mockery::on(function ($event) { + return $event instanceof LoopBeginning; + }))->andReturnNull(); + + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof RunSucceed; + }))->andReturnNull(); + + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof SupervisorStopping && $event->status === 0; + }))->andThrow(new \Exception('stopped')); + + try { + $this->supervisor->supervise(function () use (&$runs) { + $runs++; + }, new SupervisorOptions()); + $this->fail('Expected supervisor to stop'); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + + $this->assertSame(1, $runs); + $this->assertSame(0, $this->supervisor->paused); + } +} diff --git a/tests/Characterization/ViewParentBehaviorTest.php b/tests/Characterization/ViewParentBehaviorTest.php new file mode 100644 index 0000000..cd5fa2a --- /dev/null +++ b/tests/Characterization/ViewParentBehaviorTest.php @@ -0,0 +1,93 @@ +makeViewFactory(); + + $factory->startSection('content'); + echo 'child'; + $factory->stopSection(); + + $existing = $factory->getSections()['content'] ?? ''; + + $method = new \ReflectionMethod(ViewFactory::class, 'extendSection'); + $method->setAccessible(true); + $method->invoke($factory, 'content', 'parent-should-not-win'); + + $this->assertSame($existing, $factory->yieldContent('content')); + $this->assertStringNotContainsString('parent-should-not-win', $factory->yieldContent('content')); + } + + public function test_yield_content_returns_default_when_section_missing(): void + { + $factory = $this->makeViewFactory(); + + $this->assertSame('default-value', $factory->yieldContent('missing', 'default-value')); + } + + public function test_view_service_provider_registers_custom_view_factory(): void + { + $this->assertInstanceOf(ViewFactory::class, $this->app['view']); + } + + public function test_blade_parent_directive_does_not_stack_in_rendered_output(): void + { + $layout = $this->fixturePath('layout.blade.php'); + $child = $this->fixturePath('child.blade.php'); + + file_put_contents($layout, <<<'BLADE' +@section('body') +layout-body +@show +BLADE); + + file_put_contents($child, <<<'BLADE' +@extends('layout') +@section('body') +child-body +@parent +@endsection +BLADE); + + $rendered = trim($this->app['view']->file($child)->render()); + + $this->assertStringContainsString('child-body', $rendered); + $this->assertStringNotContainsString('layout-body', $rendered); + } + + private function makeViewFactory(): ViewFactory + { + $files = new Filesystem; + $resolver = new EngineResolver; + $resolver->register('php', function () { + return new PhpEngine; + }); + $finder = new FileViewFinder($files, [__DIR__.'/../fixtures/views']); + + return new ViewFactory($resolver, $finder, $this->app['events']); + } + + private function fixturePath(string $name): string + { + $dir = __DIR__.'/../fixtures/views'; + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + + return $dir.'/'.$name; + } +} diff --git a/tests/ComprehensiveValidationTest.php b/tests/ComprehensiveValidationTest.php new file mode 100644 index 0000000..6842d75 --- /dev/null +++ b/tests/ComprehensiveValidationTest.php @@ -0,0 +1,115 @@ +recreateTable('validation_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('status')->nullable(); + }); + } + + public function test_all_modules_execute_without_exception(): void + { + $this->assertInstanceOf(ViewFactory::class, $this->app['view']); + + DB::table('validation_models')->insert([ + ['id' => 1, 'name' => 'alpha', 'status' => 'old'], + ['id' => 2, 'name' => 'beta', 'status' => 'old'], + ]); + + if ($this->usesMysqlFromEnvironment()) { + DB::table('validation_models')->insertIgnore([ + ['id' => 3, 'name' => 'gamma', 'status' => 'new'], + ]); + } + + $model = new class extends Model { + protected $table = 'validation_models'; + protected $guarded = []; + }; + + $records = $model->newQuery()->whereIn('id', [1, 2])->get(); + $records[0]->status = 'updated'; + $records[1]->status = 'updated'; + $records->update(); + + $this->assertSame('updated', DB::table('validation_models')->where('id', 1)->value('status')); + $this->assertSame('updated', DB::table('validation_models')->where('id', 2)->value('status')); + + $cache = new EloquentCache($model, $this->app['cache.store']); + $found = $cache->find(1); + $this->assertSame(1, $found->id); + + $user = new User(['id' => 1, 'name' => 'Test']); + $this->assertSame('{"id":1,"name":"Test"}', $user->toJson()); + $this->assertSame(['id' => 1, 'name' => 'Test'], $user->all()); + + $crypt = new NumCrypt(); + $encrypted = $crypt->encrypt(42); + $this->assertSame(42, $crypt->decrypt($encrypted)); + + if (DIRECTORY_SEPARATOR !== '\\' || $this->usesMysqlFromEnvironment()) { + $process = new Process([PHP_BINARY, '-r', 'echo "ok";'], null, null, null, 5); + $result = $process->mustRun(); + $this->assertStringContainsString('ok', $result->stdOut); + } + + RefreshDBConnections::boot(); + RandomWorkerTerminator::boot(1, 1); + + $this->assertInstanceOf(Supervisor::class, $this->app->make(Supervisor::class)); + + $redis = new Client([ + 'scheme' => 'tcp', + 'host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'port' => getenv('REDIS_PORT') ?: 6379, + ]); + $this->assertInstanceOf(Lock::class, new Lock($redis)); + } + + public function test_data_collection_to_raw_in_validation_flow(): void + { + $collection = new DataCollection([ + new User(['id' => 1, 'name' => 'A']), + ]); + + $this->assertSame([['id' => 1, 'name' => 'A']], $collection->toRaw()); + } + + public function test_supervisor_options_defaults_match_contract(): void + { + $options = new SupervisorOptions(); + + $this->assertSame(60, $options->timeout); + $this->assertSame(128, $options->memory); + $this->assertFalse($options->force); + $this->assertFalse($options->stopOnError); + $this->assertFalse($options->dontDie); + } +} diff --git a/tests/DataCollectionTest.php b/tests/DataCollectionTest.php new file mode 100644 index 0000000..6ee2b52 --- /dev/null +++ b/tests/DataCollectionTest.php @@ -0,0 +1,67 @@ +assertSame(['plain'], $collection->toRaw()); + } + + public function test_to_raw_uses_to_array_for_arrayable_values(): void + { + $collection = new DataCollection([new ArrayableValue(['key' => 'value'])]); + + $this->assertSame([['key' => 'value']], $collection->toRaw()); + } + + public function test_fuse_merges_items_with_duplicate_keys(): void + { + $left = new DataCollection([ + new User(['id' => 1, 'name' => 'A']), + ]); + $right = new DataCollection([ + new User(['id' => 1, 'name' => 'B']), + ]); + + $fused = $left->fuse($right, 'id'); + + $this->assertCount(1, $fused); + $this->assertSame('B', $fused->first()->name); + } + + public function test_union_by_keeps_existing_keys_only(): void + { + $left = new DataCollection([ + new User(['id' => 1, 'name' => 'A']), + ]); + $right = new DataCollection([ + new User(['id' => 1, 'name' => 'B']), + new User(['id' => 2, 'name' => 'C']), + ]); + + $union = $left->unionBy($right, 'id'); + + $this->assertCount(2, $union); + $this->assertSame('A', $union->firstWhere('id', 1)->name); + $this->assertSame('C', $union->firstWhere('id', 2)->name); + } +} + +class ArrayableValue implements Arrayable +{ + public function __construct(private array $value) + { + } + + public function toArray() + { + return $this->value; + } +} diff --git a/tests/EloquentCacheTest.php b/tests/EloquentCacheTest.php index 776ef93..0b35976 100644 --- a/tests/EloquentCacheTest.php +++ b/tests/EloquentCacheTest.php @@ -187,6 +187,36 @@ public function test_forget() $this->cache->shouldReceive('forget')->once()->with('elq-ch:cached_models:4'); $repository->forget(4); } + + public function test_forget_accepts_model_instance() + { + $repository = new EloquentCache($this->model, $this->cache, 'cached_models'); + $model = Mockery::mock(CachedModel::class); + $model->shouldReceive('getKey')->once()->andReturn(9); + $this->cache->shouldReceive('forget')->once()->with('elq-ch:cached_models:9'); + $repository->forget($model); + } + + public function test_find_bypasses_invalid_cache_payload() + { + $repository = new EloquentCache($this->cacheable, $this->cache, 'cacheable_models'); + $this->cache->shouldReceive('get')->once()->with('elq-ch:cacheable_models:5')->andReturn('invalid'); + $this->cacheable->shouldReceive('newQuery')->once()->andReturnSelf(); + $this->cacheable->shouldReceive('where')->once()->with('id', '=', 5)->andReturnSelf(); + $this->cacheable->shouldReceive('firstOrFail')->once()->andReturnSelf(); + + $this->assertSame($this->cacheable, $repository->find(5)); + } + + public function test_find_by_secondary_key_without_cache_hits_database_directly() + { + $repository = new EloquentCache($this->model, $this->cache, 'cached_models'); + $this->model->shouldReceive('newQuery')->once()->andReturnSelf(); + $this->model->shouldReceive('where')->once()->with(['column1' => 'value1'])->andReturnSelf(); + $this->model->shouldReceive('firstOrFail')->once()->andReturnSelf(); + + $this->assertSame($this->model, $repository->findBySecondaryKey(['column1' => 'value1'], false)); + } } class CachedModel extends Model diff --git a/tests/EloquentServiceProviderTest.php b/tests/EloquentServiceProviderTest.php new file mode 100644 index 0000000..91f0816 --- /dev/null +++ b/tests/EloquentServiceProviderTest.php @@ -0,0 +1,91 @@ +recreateTable('provider_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('status')->nullable(); + }); + } + + public function test_provider_registers_batch_update_and_insert_ignore_macros(): void + { + $this->assertTrue(Collection::hasMacro('update')); + $this->assertTrue(Builder::hasMacro('batchUpdate')); + $this->assertTrue(Builder::hasMacro('insertIgnore')); + } + + public function test_batch_update_macro_updates_multiple_rows(): void + { + DB::table('provider_models')->insert([ + ['id' => 1, 'name' => 'a', 'status' => 'old'], + ['id' => 2, 'name' => 'b', 'status' => 'old'], + ]); + + $model = new class extends Model { + protected $table = 'provider_models'; + protected $guarded = []; + }; + + $records = $model->newQuery()->orderBy('id')->get(); + $records[0]->status = 'new'; + $records[1]->status = 'new'; + $records->update(); + + $this->assertSame('new', DB::table('provider_models')->where('id', 1)->value('status')); + $this->assertSame('new', DB::table('provider_models')->where('id', 2)->value('status')); + } + + public function test_insert_ignore_macro_on_mysql(): void + { + if (! $this->usesMysqlFromEnvironment()) { + $this->markTestSkipped('insertIgnore requires MySQL'); + } + + DB::table('provider_models')->insert(['id' => 1, 'name' => 'first', 'status' => 'ok']); + + $this->assertTrue( + DB::table('provider_models')->insertIgnore([ + ['id' => 1, 'name' => 'duplicate', 'status' => 'ignored'], + ['id' => 2, 'name' => 'second', 'status' => 'added'], + ]) + ); + + $this->assertSame('first', DB::table('provider_models')->where('id', 1)->value('name')); + $this->assertSame('second', DB::table('provider_models')->where('id', 2)->value('name')); + } + + public function test_insert_ignore_accepts_single_row_shape(): void + { + if (! $this->usesMysqlFromEnvironment()) { + $this->markTestSkipped('insertIgnore requires MySQL'); + } + + $this->assertTrue(DB::table('provider_models')->insertIgnore(['id' => 3, 'name' => 'solo', 'status' => 'ok'])); + $this->assertSame('solo', DB::table('provider_models')->where('id', 3)->value('name')); + } + + public function test_provider_register_methods_are_callable(): void + { + $provider = new EloquentServiceProvider($this->app); + $provider->registerBatchUpdate(); + $provider->registerInsertIgnore(); + + $this->assertTrue(Collection::hasMacro('update')); + $this->assertTrue(Builder::hasMacro('insertIgnore')); + } +} diff --git a/tests/HasCastablesTest.php b/tests/HasCastablesTest.php index d37a931..6b95426 100644 --- a/tests/HasCastablesTest.php +++ b/tests/HasCastablesTest.php @@ -3,10 +3,21 @@ namespace HalaeiTests; use Halaei\Helpers\Objects\DataCollection; -use PHPUnit\Framework\TestCase; +use Illuminate\Database\Schema\Blueprint; class HasCastablesTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->recreateTable('user_models', function (Blueprint $table) { + $table->increments('id'); + $table->string('mobile')->nullable(); + $table->timestamps(); + }); + } + public function test_user_model() { // assign raw value @@ -56,4 +67,44 @@ public function test_order() $this->assertInstanceOf(Item::class, $order->items[0]); $this->assertEquals('#124', $order->items[1]->code); } + + public function test_get_casted_attribute_returns_null_for_null_values(): void + { + $user = new UserModel; + $user->setRawAttributes(['mobile' => null]); + $user->syncOriginal(); + + $this->assertNull($user->mobile); + $this->assertNull($user->mobile); + } + + public function test_prepare_saving_persists_raw_castable_values(): void + { + $user = new UserModel(['mobile' => '+98-9121231212']); + $user->setConnection('testing'); + $this->assertInstanceOf(Mobile::class, $user->mobile); + $user->save(); + + $this->assertSame('+98-9121231212', $user->getAttributes()['mobile']); + } + + public function test_get_attribute_falls_back_to_parent_for_non_castable_keys(): void + { + $user = new UserModel; + $user->setConnection('testing'); + $user->setRawAttributes(['id' => 5, 'mobile' => '+98-9121231212']); + $user->syncOriginal(); + + $this->assertSame(5, $user->getAttribute('id')); + } + + public function test_offset_unset_clears_casted_cache(): void + { + $user = new UserModel(['mobile' => '+98-9121231212']); + $this->assertInstanceOf(Mobile::class, $user->mobile); + + unset($user['mobile']); + + $this->assertNull($user->mobile); + } } diff --git a/tests/LockInstanceTest.php b/tests/LockInstanceTest.php new file mode 100644 index 0000000..61b7326 --- /dev/null +++ b/tests/LockInstanceTest.php @@ -0,0 +1,113 @@ +expectException(\TypeError::class); + + new Lock(new \stdClass); + } + + public function test_instance_returns_lock_with_predis_client(): void + { + $predis = \Mockery::mock(ClientInterface::class); + $connection = \Mockery::mock(); + $connection->shouldReceive('client')->once()->andReturn($predis); + + $manager = \Mockery::mock(RedisManager::class); + $manager->shouldReceive('connection')->once()->with('default')->andReturn($connection); + + $app = new \Illuminate\Foundation\Application; + $app->instance(RedisManager::class, $manager); + $app->instance('redis', $manager); + \Illuminate\Container\Container::setInstance($app); + + $lock = Lock::instance('default'); + + $this->assertInstanceOf(Lock::class, $lock); + } + + public function test_instance_wraps_phpredis_clients(): void + { + $phpredis = new class { + public function eval($script, $args, $numKeys) + { + return 'token'; + } + + public function brpoplpush($source, $destination, $timeout) + { + return 'token'; + } + + public function expire($key, $seconds) + { + return true; + } + }; + + $connection = \Mockery::mock(); + $connection->shouldReceive('client')->once()->andReturn($phpredis); + + $manager = \Mockery::mock(RedisManager::class); + $manager->shouldReceive('connection')->once()->with(null)->andReturn($connection); + + $app = new \Illuminate\Foundation\Application; + $app->instance(RedisManager::class, $manager); + $app->instance('redis', $manager); + \Illuminate\Container\Container::setInstance($app); + + $lock = Lock::instance(); + $this->assertInstanceOf(Lock::class, $lock); + $this->assertTrue($lock->lock('phpredis', 2)); + } + + #[Group('redis')] + public function test_lock_uses_brpoplpush_fallback_when_eval_returns_false(): void + { + $redis = \Mockery::mock(ClientInterface::class); + $redis->shouldReceive('eval')->once()->andReturn(false); + $redis->shouldReceive('brpoplpush')->once()->with('fallback2', 'fallback1', 2)->andReturn('token'); + $redis->shouldReceive('expire')->once()->with('fallback1', 2)->andReturn(true); + + $lock = new Lock($redis); + + $this->assertTrue($lock->lock('fallback', 2)); + } + + #[Group('redis')] + public function test_lock_integration_with_real_redis(): void + { + try { + $client = RedisConfig::client(); + $client->ping(); + } catch (\Throwable $e) { + $this->markTestSkipped('Redis is not available'); + } + + $client->flushdb(); + $lock = new Lock($client); + + $this->assertTrue($lock->lock('integration', 2)); + $lock->unlock('integration'); + } + + protected function tearDown(): void + { + \Mockery::close(); + \Illuminate\Container\Container::setInstance(null); + parent::tearDown(); + } +} diff --git a/tests/LogSlowQueriesCommandTest.php b/tests/LogSlowQueriesCommandTest.php new file mode 100644 index 0000000..fbfda02 --- /dev/null +++ b/tests/LogSlowQueriesCommandTest.php @@ -0,0 +1,112 @@ +with(null)->andReturnSelf(); + DB::shouldReceive('select')->once()->with('show full processlist')->andReturn([ + (object) [ + 'Command' => 'Query', + 'Info' => 'SELECT "secret" FROM users WHERE id = 42', + 'Time' => 3, + ], + (object) [ + 'Command' => 'Sleep', + 'Info' => null, + 'Time' => 10, + ], + (object) [ + 'Command' => 'Query', + 'Info' => '', + 'Time' => 2, + ], + (object) [ + 'Command' => 'Query', + 'Info' => null, + 'Time' => 5, + ], + ]); + + $this->runCommand(new LogSlowQueries, [], ['--once' => true]); + + $this->assertTrue(true); + } + + public function test_handle_sleeps_between_iterations_when_not_once(): void + { + DB::shouldReceive('connection')->with(null)->andReturnSelf(); + DB::shouldReceive('select') + ->once() + ->with('show full processlist') + ->andReturn([], []); + + $command = new class extends LogSlowQueries { + public int $sleepCalls = 0; + + protected function sleepForPoll(int $seconds): void + { + $this->sleepCalls++; + + if ($this->sleepCalls >= 1) { + throw new \RuntimeException('stop-loop'); + } + } + }; + + try { + $this->runCommand($command, [], ['--sleep' => 0]); + } catch (\RuntimeException $e) { + $this->assertSame('stop-loop', $e->getMessage()); + } + + $this->assertSame(1, $command->sleepCalls); + } + + public function test_strip_sql_normalizes_literals_and_numbers(): void + { + $command = $this->makeBoundCommand(new LogSlowQueries); + + $normalized = $this->invokePrivateMethod( + $command, + 'stripSql', + [' SELECT "abc", \'def\', 99 FROM t '] + ); + + $this->assertSame('SELECT ?, ?, ? FROM t', $normalized); + } + + public function test_sleep_for_poll_can_be_overridden_by_subclasses(): void + { + $command = new class extends LogSlowQueries { + public int $slept = -1; + + protected function sleepForPoll(int $seconds): void + { + $this->slept = $seconds; + } + }; + + $method = new \ReflectionMethod($command, 'sleepForPoll'); + $method->setAccessible(true); + $method->invoke($command, 0); + + $this->assertSame(0, $command->slept); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/NumCryptTest.php b/tests/NumCryptTest.php index 10d331c..d8d0436 100644 --- a/tests/NumCryptTest.php +++ b/tests/NumCryptTest.php @@ -43,13 +43,4 @@ public function test_num_to_code_with_custom_constructor() $this->assertSame($i, $crypt->decrypt($code)); } } - - public static function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void - { - if (method_exists(parent::class, 'assertMatchesRegularExpression')) { - parent::assertMatchesRegularExpression($pattern, $string, $message); - } else { - parent::assertRegExp($pattern, $string, $message); - } - } } diff --git a/tests/PhpRedisLockClientTest.php b/tests/PhpRedisLockClientTest.php new file mode 100644 index 0000000..38a3d9b --- /dev/null +++ b/tests/PhpRedisLockClientTest.php @@ -0,0 +1,73 @@ +calls[] = compact('script', 'args', 'numKeys'); + + return true; + } + }; + + $client = new PhpRedisLockClient($redis); + $client->eval('script', 2, 'key1', 'key2', '1000'); + + $this->assertSame([ + 'script' => 'script', + 'args' => ['key1', 'key2', '1000'], + 'numKeys' => 2, + ], $redis->calls[0]); + } + + public function test_brpoplpush_and_expire_delegate_to_phpredis(): void + { + $redis = new class { + public array $brpoplpush = []; + + public array $expire = []; + + public array $pexpire = []; + + public function brpoplpush($source, $destination, $timeout) + { + $this->brpoplpush = compact('source', 'destination', 'timeout'); + + return 'ok'; + } + + public function expire($key, $seconds) + { + $this->expire = compact('key', 'seconds'); + + return true; + } + + public function pexpire($key, $milliseconds) + { + $this->pexpire = compact('key', 'milliseconds'); + + return true; + } + }; + + $client = new PhpRedisLockClient($redis); + $client->brpoplpush('a', 'b', 2.5); + $client->expire('fractional-lock', 3.2); + $client->expire('whole-lock', 3); + + $this->assertSame(['source' => 'a', 'destination' => 'b', 'timeout' => 2.5], $redis->brpoplpush); + $this->assertSame(['key' => 'fractional-lock', 'milliseconds' => 3200], $redis->pexpire); + $this->assertSame(['key' => 'whole-lock', 'seconds' => 3], $redis->expire); + } +} diff --git a/tests/ProcessTest.php b/tests/ProcessTest.php index 6ac1cb5..c569353 100644 --- a/tests/ProcessTest.php +++ b/tests/ProcessTest.php @@ -4,8 +4,10 @@ use Halaei\Helpers\Process\Process; use Halaei\Helpers\Process\ProcessException; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +#[Group('unix')] class ProcessTest extends TestCase { private static $randPath; @@ -150,4 +152,152 @@ public function test_run_without_timeout() $process = new Process(['echo', 'hello'], null, null, null, null); $this->assertSame(0, $process->mustRun()->exitCode); } + + public function test_must_run_throws_when_process_cannot_start() + { + $process = new class(['echo', 'ok']) extends Process { + protected function start() + { + return false; + } + }; + + try { + $process->mustRun(); + $this->fail('Expected ProcessException was not thrown'); + } catch (ProcessException $e) { + $this->assertSame(ProcessException::CODE_START_ERROR, $e->getCode()); + } + } + + public function test_run_returns_null_when_start_fails() + { + $process = new class(['echo', 'ok']) extends Process { + protected function start() + { + return false; + } + }; + + $this->assertNull($process->run()); + } + + public function test_start_returns_false_when_proc_open_fails_with_invalid_cwd(): void + { + if (PHP_VERSION_ID < 80300) { + $this->markTestSkipped('proc_open returns false for invalid cwd only since PHP 8.3'); + } + + $process = new Process(['echo', 'ok'], '/definitely/missing/directory'); + + $start = new \ReflectionMethod($process, 'start'); + $start->setAccessible(true); + + $this->assertFalse($start->invoke($process)); + } + + public function test_must_run_throws_when_process_times_out() + { + $process = new Process(['sleep', '5'], null, null, null, 1); + + try { + $process->mustRun(); + $this->fail('Expected ProcessException was not thrown'); + } catch (ProcessException $e) { + $this->assertSame(ProcessException::CODE_TIMEOUT_ERROR, $e->getCode()); + $this->assertTrue($e->result->timedOut); + } + } + + #[Group('windows')] + public function test_escape_argument_quotes_windows_special_characters() + { + if (DIRECTORY_SEPARATOR !== '\\') { + $this->markTestSkipped('Windows-only escapeArgument branch'); + } + + $method = new \ReflectionMethod(Process::class, 'escapeArgument'); + $method->setAccessible(true); + + $this->assertSame('"^&"', $method->invoke(null, '&')); + $this->assertSame('"a^^b"', $method->invoke(null, 'a^b')); + } + + public function test_kill_invokes_terminate_process_hook(): void + { + $process = new class(['sleep', '1']) extends Process { + public bool $terminated = false; + + protected function kill($status = 0) + { + $this->terminated = true; + } + }; + + $reflection = new \ReflectionMethod($process, 'kill'); + $reflection->setAccessible(true); + $reflection->invoke($process, 1); + + $this->assertTrue($process->terminated); + } + + public function test_escape_argument_quotes_empty_values_on_unix(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Unix-only escapeArgument branch'); + } + + $method = new \ReflectionMethod(Process::class, 'escapeArgument'); + $method->setAccessible(true); + + $this->assertSame('""', $method->invoke(null, '')); + $this->assertSame('""', $method->invoke(null, null)); + } + + public function test_timeout_kills_process_when_sigterm_is_ignored(): void + { + $process = new Process(['php', '-r', 'pcntl_signal(SIGTERM, SIG_IGN); sleep(30);'], null, null, null, 1); + $process->waitForKill = 0.1; + $process->usleep = 1000; + + $result = $process->run(); + + $this->assertTrue($result->timedOut); + } + + public function test_timeout_leaves_stderr_for_final_drain_phase(): void + { + $process = new Process( + ['php', '-r', 'fwrite(STDERR, str_repeat("z", 50000)); sleep(5);'], + null, + null, + null, + 1 + ); + $process->waitForKill = 0.2; + $process->usleep = 1000; + + $result = $process->run(); + + $this->assertTrue($result->timedOut); + $this->assertGreaterThan(0, strlen($result->stdErr)); + } + + public function test_wait_handles_stream_select_exceptions() + { + $process = new class(['php', '-r', 'echo "ok";']) extends Process { + protected function wait() + { + try { + throw new \Exception('select failed'); + } catch (\Exception $e) { + usleep($this->usleep); + + return $this->inputClosed ? [[true, true], []] : [[true, true], [true]]; + } + } + }; + + $this->assertStringContainsString('ok', $process->mustRun()->stdOut); + } } diff --git a/tests/PublicApiContractTest.php b/tests/PublicApiContractTest.php new file mode 100644 index 0000000..c2cdb32 --- /dev/null +++ b/tests/PublicApiContractTest.php @@ -0,0 +1,265 @@ +assertClassHasPublicMethods(Supervisor::class, ['supervise']); + $this->assertClassHasPublicConstructor(Supervisor::class, 5); + + $options = new ReflectionClass(SupervisorOptions::class); + foreach (['timeout', 'memory', 'force', 'stopOnError', 'dontDie'] as $property) { + $this->assertTrue($options->hasProperty($property), "SupervisorOptions missing property: {$property}"); + $this->assertTrue($options->getProperty($property)->isPublic(), "SupervisorOptions::\${$property} must be public"); + } + $this->assertClassHasPublicConstructor(SupervisorOptions::class, 5); + + $state = new ReflectionClass(SupervisorState::class); + foreach (['paused', 'shouldQuit', 'lastRestart', 'exitStatus'] as $property) { + $this->assertTrue($state->hasProperty($property), "SupervisorState missing property: {$property}"); + $this->assertTrue($state->getProperty($property)->isPublic(), "SupervisorState::\${$property} must be public"); + } + } + + public function test_supervisor_event_classes_exist(): void + { + foreach ([ + Looping::class, + LoopBeginning::class, + LoopCompleting::class, + RunSucceed::class, + RunFailed::class, + SupervisorStopping::class, + ] as $class) { + $this->assertTrue(class_exists($class), "Missing event class: {$class}"); + } + } + + public function test_quits_on_signals_protected_api(): void + { + $this->assertTraitHasProtectedMethods(QuitsOnSignals::class, [ + 'listenToSignals', 'stopListeningToSignals', 'quitIfSignaled', + ]); + } + + public function test_objects_public_api(): void + { + $this->assertTrue(interface_exists(Rawable::class)); + $this->assertInterfaceHasMethod(Rawable::class, 'toRaw'); + + $this->assertClassHasPublicMethods(Casting::class, ['cast']); + + $this->assertClassHasPublicMethods(DataObject::class, [ + 'relations', 'all', 'toArray', 'toRaw', 'toJson', 'fuse', + '__call', '__get', '__set', '__isset', '__unset', + ]); + + $this->assertClassHasPublicMethods(DataCollection::class, [ + 'toRaw', 'fuse', 'unionBy', + ]); + } + + public function test_eloquent_public_api(): void + { + $this->assertTrue(interface_exists(Cacheable::class)); + $this->assertInterfaceHasMethods(Cacheable::class, ['isCached', 'markAsCached', 'syncWithDB']); + + $this->assertTraitHasPublicMethods(CacheableTrait::class, ['isCached', 'markAsCached', 'syncWithDB']); + + $this->assertClassHasPublicMethods(EloquentCache::class, [ + 'find', 'findBySecondaryKey', 'update', 'delete', 'invalidateCache', 'forget', + ]); + + $this->assertTraitHasPublicMethods(HasCastables::class, [ + 'bootHasCastables', 'attributesToArray', 'getAttribute', 'setAttribute', + 'prepareSaving', 'offsetUnset', + ]); + + $this->assertClassHasPublicMethods(SqlState::class, [ + 'is_integrity_constraint_violation', 'is_transaction_rollback', + ]); + + $this->assertTrue(is_subclass_of(LogSlowQueriesAlias::class, LogSlowQueries::class)); + + $this->assertClassHasPublicMethods(EloquentServiceProvider::class, [ + 'register', 'registerBatchUpdate', 'registerInsertIgnore', + ]); + } + + public function test_artisan_command_signatures(): void + { + $this->assertCommandSignature( + LogSlowQueries::class, + 'db:log-slow-queries {--connection=} {--sleep=2} {--once}' + ); + $this->assertCommandSignature( + BackupTableToFileSystem::class, + 'db:backup-table {database} {table} {disk} {dir} {--truncate} {--auto-increment=id} {--mysqldump=mysqldump}' + ); + $this->assertCommandSignature( + RestoreDumpFromFileSystem::class, + 'db:restore-dump {database} {disk} {path} {--mysqlcli=mysql} {--force}' + ); + } + + public function test_redis_lock_public_api(): void + { + $this->assertClassHasPublicMethods(Lock::class, [ + 'instance', 'lock', 'unlock', 'block', + ]); + $this->assertClassHasPublicConstructor(Lock::class, 1); + } + + public function test_listeners_public_api(): void + { + $this->assertClassHasPublicMethods(RefreshDBConnections::class, ['handle', 'boot']); + $this->assertClassHasPublicMethods(RandomWorkerTerminator::class, ['handle', 'boot']); + $this->assertClassHasPublicConstructor(RandomWorkerTerminator::class, 2); + } + + public function test_view_public_api(): void + { + $this->assertTrue(is_subclass_of(ViewFactory::class, \Illuminate\View\Factory::class)); + $this->assertClassHasPublicMethods(ViewFactory::class, ['yieldContent']); + $this->assertTrue(is_subclass_of(ViewServiceProvider::class, \Illuminate\View\ViewServiceProvider::class)); + $this->assertClassHasPublicMethods(ViewServiceProvider::class, ['registerFactory']); + $this->assertClassHasProtectedMethods(ViewServiceProvider::class, ['createFactory']); + } + + public function test_process_and_crypt_public_api(): void + { + $this->assertClassHasPublicMethods(Process::class, ['run', 'mustRun']); + $this->assertClassHasPublicConstructor(Process::class, 5); + + $result = new ReflectionClass(ProcessResult::class); + foreach (['exitCode', 'stdOut', 'stdErr', 'timedOut', 'readError'] as $property) { + $this->assertTrue($result->hasProperty($property), "ProcessResult missing: {$property}"); + } + + $exception = new ReflectionClass(ProcessException::class); + $this->assertSame(1, $exception->getConstant('CODE_START_ERROR')); + $this->assertSame(2, $exception->getConstant('CODE_TIMEOUT_ERROR')); + $this->assertSame(3, $exception->getConstant('CODE_EXIT_CODE_ERROR')); + $this->assertClassHasPublicMethods(ProcessException::class, ['setResult']); + + $this->assertClassHasPublicMethods(NumCrypt::class, ['encrypt', 'decrypt']); + $this->assertClassHasPublicConstructor(NumCrypt::class, 2); + } + + private function assertClassHasPublicMethods(string $class, array $methods): void + { + $this->assertTrue(class_exists($class) || trait_exists($class), "Class/trait not found: {$class}"); + + $reflection = new ReflectionClass($class); + foreach ($methods as $method) { + $this->assertTrue( + $reflection->hasMethod($method), + "{$class} missing public method: {$method}" + ); + $this->assertTrue( + $reflection->getMethod($method)->isPublic(), + "{$class}::{$method}() must be public" + ); + } + } + + private function assertTraitHasPublicMethods(string $trait, array $methods): void + { + $this->assertClassHasPublicMethods($trait, $methods); + } + + private function assertTraitHasProtectedMethods(string $trait, array $methods): void + { + $this->assertClassHasProtectedMethods($trait, $methods); + } + + private function assertClassHasProtectedMethods(string $class, array $methods): void + { + $reflection = new ReflectionClass($class); + foreach ($methods as $method) { + $this->assertTrue($reflection->hasMethod($method), "{$class} missing protected method: {$method}"); + $this->assertTrue( + $reflection->getMethod($method)->isProtected(), + "{$class}::{$method}() must be protected" + ); + } + } + + private function assertInterfaceHasMethod(string $interface, string $method): void + { + $this->assertInterfaceHasMethods($interface, [$method]); + } + + private function assertInterfaceHasMethods(string $interface, array $methods): void + { + $reflection = new ReflectionClass($interface); + foreach ($methods as $method) { + $this->assertTrue($reflection->hasMethod($method), "{$interface} missing method: {$method}"); + $this->assertTrue($reflection->getMethod($method)->isPublic(), "{$interface}::{$method}() must be public"); + } + } + + private function assertClassHasPublicConstructor(string $class, int $parameterCount): void + { + $reflection = new ReflectionClass($class); + $constructor = $reflection->getConstructor(); + $this->assertNotNull($constructor, "{$class} must have a constructor"); + $this->assertTrue($constructor->isPublic(), "{$class}::__construct() must be public"); + $this->assertCount($parameterCount, $constructor->getParameters(), "{$class}::__construct() parameter count mismatch"); + } + + private function assertCommandSignature(string $commandClass, string $expectedSignature): void + { + $reflection = new ReflectionClass($commandClass); + $property = $reflection->getProperty('signature'); + $property->setAccessible(true); + + $this->assertSame($expectedSignature, $property->getValue(new $commandClass)); + } +} diff --git a/tests/QuitsOnSignalsTest.php b/tests/QuitsOnSignalsTest.php new file mode 100644 index 0000000..f75829f --- /dev/null +++ b/tests/QuitsOnSignalsTest.php @@ -0,0 +1,98 @@ +markTestSkipped('pcntl extension is required'); + } + + $host = new QuitsOnSignalsHost([SIGUSR1]); + + $this->invokePrivateMethod($host, 'listenToSignals'); + $this->setShouldQuit($host, false); + + $handler = pcntl_signal_get_handler(SIGUSR1); + if (is_callable($handler)) { + $handler(); + $this->assertTrue($this->getShouldQuit($host)); + } + + $this->invokePrivateMethod($host, 'stopListeningToSignals'); + $this->assertFalse($this->getShouldQuit($host)); + } + + public function test_quit_if_signaled_exits_when_flag_is_set(): void + { + $host = new QuitsOnSignalsHost; + $this->setShouldQuit($host, true); + + $host->triggerQuitIfSignaled(9); + + $this->assertTrue($host->exited); + $this->assertSame(9, $host->exitStatus); + } + + public function test_quit_if_signaled_is_no_op_when_not_signaled(): void + { + $host = new QuitsOnSignalsHost; + $host->triggerQuitIfSignaled(); + + $this->assertFalse($host->exited); + } + + private function setShouldQuit(QuitsOnSignalsHost $host, bool $value): void + { + $property = new \ReflectionProperty($host, 'shouldQuit'); + $property->setAccessible(true); + $property->setValue($host, $value); + } + + private function getShouldQuit(QuitsOnSignalsHost $host): bool + { + $property = new \ReflectionProperty($host, 'shouldQuit'); + $property->setAccessible(true); + + return $property->getValue($host); + } +} + +class QuitsOnSignalsHost +{ + use QuitsOnSignals; + + public bool $exited = false; + + public ?int $exitStatus = null; + + public array $quitOnSignals = [SIGUSR1]; + + public function __construct(array $quitOnSignals = [SIGUSR1]) + { + $this->quitOnSignals = $quitOnSignals; + } + + public function triggerQuitIfSignaled(int $status = 0): void + { + $method = new \ReflectionMethod($this, 'quitIfSignaled'); + $method->setAccessible(true); + $method->invoke($this, $status); + } + + protected function exitOnSignal(int $status): void + { + $this->exited = true; + $this->exitStatus = $status; + $this->shouldQuit = false; + } +} diff --git a/tests/RandomWorkerTerminatorTest.php b/tests/RandomWorkerTerminatorTest.php new file mode 100644 index 0000000..6aca1ef --- /dev/null +++ b/tests/RandomWorkerTerminatorTest.php @@ -0,0 +1,82 @@ +shouldReceive('stop')->once(); + $this->app->instance(Worker::class, $worker); + + $terminator = new RandomWorkerTerminator(0, 0); + $this->setTerminatorClock($terminator, start: 0, ttl: 0); + + $terminator->handle(); + event(new Looping('default', 'default')); + + $this->addToAssertionCount(1); + } + + public function test_handle_does_not_stop_worker_before_ttl(): void + { + $worker = \Mockery::mock(Worker::class); + $worker->shouldReceive('stop')->never(); + $this->app->instance(Worker::class, $worker); + + $terminator = new RandomWorkerTerminator(3600, 3600); + $this->setTerminatorClock($terminator, start: time(), ttl: 3600); + + $terminator->handle(); + event(new Looping('default', 'default')); + + $this->addToAssertionCount(1); + } + + public function test_boot_stops_worker_after_ttl(): void + { + $worker = \Mockery::mock(Worker::class); + $worker->shouldReceive('stop')->once(); + $this->app->instance(Worker::class, $worker); + + RandomWorkerTerminator::boot(0, 0); + + sleep(1); + event(new Looping('default', 'default')); + + $this->addToAssertionCount(1); + } + + public function test_boot_registers_looping_listener(): void + { + RandomWorkerTerminator::boot(0, 0); + + event(new Looping('default', 'default')); + + $this->addToAssertionCount(1); + } + + private function setTerminatorClock(RandomWorkerTerminator $terminator, int $start, int $ttl): void + { + $reflection = new \ReflectionClass($terminator); + + $startProperty = $reflection->getProperty('start'); + $startProperty->setAccessible(true); + $startProperty->setValue($terminator, $start); + + $ttlProperty = $reflection->getProperty('timeToLive'); + $ttlProperty->setAccessible(true); + $ttlProperty->setValue($terminator, $ttl); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/RedisLockTest.php b/tests/RedisLockTest.php index c3c2ff7..a68415a 100644 --- a/tests/RedisLockTest.php +++ b/tests/RedisLockTest.php @@ -3,10 +3,13 @@ namespace HalaeiTests; use Halaei\Helpers\Redis\Lock; +use HalaeiTests\Support\RedisConfig; use Illuminate\Contracts\Cache\LockTimeoutException; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Predis\Client; +#[Group('redis')] class RedisLockTest extends TestCase { /** @@ -42,12 +45,7 @@ public function tearDown(): void */ private function getRedis() { - return new Client([ - 'host' => '127.0.0.1', - 'port' => 6379, - 'database' => 5, - 'timeout' => 10.0, - ]); + return RedisConfig::client(); } public function test_lock_and_unlock_with_no_race() @@ -108,6 +106,8 @@ public function test_lock_twice_sequentially_and_unlock_when_it_is_late() $this->assertEquals(0, $this->redis->exists('test2')); } + #[Group('redis')] + #[Group('stress')] public function test_under_stress() { $this->redis->disconnect(); diff --git a/tests/RefreshDBConnectionsTest.php b/tests/RefreshDBConnectionsTest.php new file mode 100644 index 0000000..c134355 --- /dev/null +++ b/tests/RefreshDBConnectionsTest.php @@ -0,0 +1,76 @@ +once()->with(0); + + (new RefreshDBConnections)->handle(); + + $this->addToAssertionCount(1); + } + + public function test_handle_reconnects_when_rollback_fails(): void + { + $exception = new \RuntimeException('rollback failed'); + + DB::shouldReceive('rollBack')->once()->with(0)->andThrow($exception); + DB::shouldReceive('reconnect')->once(); + + $reported = []; + $this->app->instance(\Illuminate\Contracts\Debug\ExceptionHandler::class, new class($reported) implements \Illuminate\Contracts\Debug\ExceptionHandler { + public function __construct(private array &$reported) + { + } + + public function report($e) + { + $this->reported[] = $e; + } + + public function shouldReport($e) + { + return true; + } + + public function render($request, $e) + { + throw $e; + } + + public function renderForConsole($output, $e) + { + throw $e; + } + }); + + (new RefreshDBConnections)->handle(); + + $this->assertCount(1, $reported); + $this->assertSame($exception, $reported[0]); + } + + public function test_boot_registers_queue_looping_listener(): void + { + RefreshDBConnections::boot(); + + DB::shouldReceive('rollBack')->once()->with(0); + + event(new Looping('default', 'default')); + + $this->addToAssertionCount(1); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/RestoreDumpFromFileSystemTest.php b/tests/RestoreDumpFromFileSystemTest.php new file mode 100644 index 0000000..0be03a4 --- /dev/null +++ b/tests/RestoreDumpFromFileSystemTest.php @@ -0,0 +1,175 @@ +fixtureScript('fake-mysql.sh'); + } + + protected function fixtureScript(string $name): string + { + $path = __DIR__.'/fixtures/'.$name; + if (is_file($path)) { + $contents = file_get_contents($path); + file_put_contents($path, str_replace("\r\n", "\n", $contents)); + @chmod($path, 0755); + } + + return $path; + } + + private function mysqlCliOption(): array + { + return ['--mysqlcli' => $this->fixtureScript('fake-mysql.sh')]; + } + + public function test_mount_copies_remote_stream_to_local_disk(): void + { + Storage::disk('remote')->put('backups/dump.tar.gz', 'remote-archive'); + + $command = $this->makeBoundCommand(new RestoreDumpFromFileSystem, $this->commandArguments()); + $path = $this->invokePrivateMethod($command, 'mount'); + + $this->assertStringEndsWith('backups/dump.tar.gz', $path); + $this->assertSame('remote-archive', Storage::disk('local')->get('backups/dump.tar.gz')); + } + + public function test_mount_throws_when_remote_stream_is_unreadable(): void + { + $remote = \Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $remote->shouldReceive('readStream')->with('missing.tar.gz')->andReturn(false); + + Storage::extend('unreadable', function () use ($remote) { + return $remote; + }); + + $this->app['config']->set('filesystems.disks.unreadable', [ + 'driver' => 'unreadable', + ]); + + $command = $this->makeBoundCommand(new RestoreDumpFromFileSystem, [ + 'database' => 'testing', + 'disk' => 'unreadable', + 'path' => 'missing.tar.gz', + ]); + + $this->expectException(\RuntimeException::class); + $this->invokePrivateMethod($command, 'mount'); + } + + public function test_uncompress_extracts_tar_gz_archive(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('tar is not available on Windows'); + } + + $dir = storage_path('framework/testing/restore'); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $archive = $dir.'/payload.tar.gz'; + $source = $dir.'/payload.sql'; + file_put_contents($source, '-- sql dump'); + exec(sprintf('tar -czf %s -C %s payload.sql', escapeshellarg($archive), escapeshellarg($dir))); + + $command = $this->makeBoundCommand(new RestoreDumpFromFileSystem, $this->commandArguments()); + $extracted = $this->invokePrivateMethod($command, 'uncompress', [$archive]); + + $this->assertSame($dir.'/payload', $extracted); + $this->assertFileExists($dir.'/payload.sql'); + $this->assertFileDoesNotExist($archive); + } + + public function test_restore_pipes_dump_to_mysql_client(): void + { + $dump = storage_path('framework/testing/restore/restore.sql'); + if (! is_dir(dirname($dump))) { + mkdir(dirname($dump), 0777, true); + } + file_put_contents($dump, 'SELECT 1;'); + + $command = $this->makeBoundCommand(new RestoreDumpFromFileSystem, $this->commandArguments(), $this->mysqlCliOption()); + $this->invokePrivateMethod($command, 'restore', [$dump]); + + $this->assertFileDoesNotExist($dump); + } + + public function test_restore_includes_force_flag_when_requested(): void + { + $dump = storage_path('framework/testing/restore/force.sql'); + if (! is_dir(dirname($dump))) { + mkdir(dirname($dump), 0777, true); + } + file_put_contents($dump, 'SELECT 1;'); + + $command = $this->makeBoundCommand(new RestoreDumpFromFileSystem, $this->commandArguments(), array_merge( + $this->mysqlCliOption(), + ['--force' => true] + )); + $this->invokePrivateMethod($command, 'restore', [$dump]); + + $this->assertFileDoesNotExist($dump); + } + + public function test_handle_runs_mount_uncompress_and_restore(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('tar is not available on Windows'); + } + + $dir = storage_path('framework/testing/restore-e2e'); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $sql = $dir.'/table.sql'; + file_put_contents($sql, 'SELECT 1;'); + $archive = $dir.'/table.sql.tar.gz'; + exec(sprintf('tar -czf %s -C %s table.sql', escapeshellarg($archive), escapeshellarg($dir))); + $payload = file_get_contents($archive); + + Storage::disk('remote')->put('table.sql.tar.gz', $payload); + + $this->runCommand(new RestoreDumpFromFileSystem, [ + 'database' => 'testing', + 'disk' => 'remote', + 'path' => 'table.sql.tar.gz', + ], $this->mysqlCliOption()); + + $this->assertFalse(Storage::disk('local')->exists('table.sql.tar.gz')); + } + + private function commandArguments(): array + { + return [ + 'database' => 'testing', + 'disk' => 'remote', + 'path' => 'backups/dump.tar.gz', + ]; + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/SupervisorCompleteTest.php b/tests/SupervisorCompleteTest.php new file mode 100644 index 0000000..1332f20 --- /dev/null +++ b/tests/SupervisorCompleteTest.php @@ -0,0 +1,418 @@ +laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn($state = new SupervisorState()); + $this->cache->shouldReceive('get')->once()->with('illuminate:queue:restart')->andReturn(null); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + + $this->events->shouldReceive('until')->once()->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof RunFailed && $event->exception instanceof \RuntimeException; + }))->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof SupervisorStopping; + }))->andThrow(new \Exception('stopped')); + + $options = new SupervisorOptions; + $options->stopOnError = true; + + try { + $this->supervisor->supervise(function () use (&$runs) { + $runs++; + throw new \RuntimeException('boom'); + }, $options); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + + $this->assertSame(1, $runs); + $this->assertTrue($state->shouldQuit); + } + + public function test_supervise_resolves_string_commands_via_bus(): void + { + $job = new \stdClass; + $this->laravel->shouldReceive('make')->once()->with('JobClass')->andReturn($job); + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn($state = new SupervisorState()); + $this->cache->shouldReceive('get')->twice()->with('illuminate:queue:restart')->andReturn(null); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + $this->bus->shouldReceive('dispatch')->once()->with($job); + + $this->events->shouldReceive('until')->once()->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(\Halaei\Helpers\Supervisor\Events\RunSucceed::class))->andReturnNull(); + $this->events->shouldReceive('until')->once()->with(Mockery::type(LoopCompleting::class))->andReturn(false); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(SupervisorStopping::class))->andThrow(new \Exception('stopped')); + + try { + $this->supervisor->supervise('JobClass'); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + } + + public function test_supervise_returns_exit_status_when_dont_die_is_enabled(): void + { + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn($state = new SupervisorState()); + $this->cache->shouldReceive('get')->twice()->with('illuminate:queue:restart')->andReturn(null); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + + $this->events->shouldReceive('until')->once()->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(\Halaei\Helpers\Supervisor\Events\RunSucceed::class))->andReturnNull(); + $this->events->shouldReceive('until')->once()->with(Mockery::type(LoopCompleting::class))->andReturn(false); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(SupervisorStopping::class))->andReturnNull(); + + $options = new SupervisorOptions; + $options->dontDie = true; + + $status = $this->supervisor->supervise(function () { + }, $options); + + $this->assertSame(0, $status); + $this->assertSame(0, $state->exitStatus); + } + + public function test_memory_limit_triggers_stop_with_non_zero_status(): void + { + $memorySpy = new SupervisorMemorySpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn($state = new SupervisorState()); + $this->cache->shouldReceive('get')->once()->with('illuminate:queue:restart')->andReturn(null); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(false); + + $this->events->shouldReceive('until')->once()->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(\Halaei\Helpers\Supervisor\Events\RunSucceed::class))->andReturnNull(); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::on(function ($event) { + return $event instanceof SupervisorStopping && $event->status === 12; + }))->andReturnNull(); + + $options = new SupervisorOptions; + $options->dontDie = true; + $options->memory = 1; + + $status = $memorySpy->supervise(function () { + }, $options); + + $this->assertSame(12, $status); + } + + public function test_listen_for_signals_sets_should_quit_on_sigterm(): void + { + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('pcntl extension is required'); + } + + $spy = new SupervisorSignalSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $state = new SupervisorState; + $spy->exposeListenForSignals($state); + $spy->exposeSignalHandlers()[SIGTERM](); + + $this->assertTrue($state->shouldQuit); + } + + public function test_listen_for_signals_sets_paused_and_resumes(): void + { + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('pcntl extension is required'); + } + + $spy = new SupervisorSignalSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $state = new SupervisorState; + $spy->exposeListenForSignals($state); + $handlers = $spy->exposeSignalHandlers(); + + $handlers[SIGUSR2](); + $this->assertTrue($state->paused); + + $handlers[SIGCONT](); + $this->assertFalse($state->paused); + } + + public function test_stop_with_exit_invokes_exit_process_hook(): void + { + $spy = new SupervisorStopSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $state = new SupervisorState; + $this->events->shouldReceive('dispatch')->once()->andReturnNull(); + $spy->exposeStop(12, true, $state); + + $this->assertSame(12, $state->exitStatus); + $this->assertSame(12, $spy->exitStatus); + } + + public function test_kill_invokes_terminate_process_hook(): void + { + $spy = new SupervisorStopSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $spy->exposeKill(9); + + $this->assertSame(9, $spy->exitStatus); + } + + public function test_kill_delegates_to_terminate_process_hook(): void + { + $spy = new SupervisorKillTestSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $spy->exposeKill(); + + $this->assertSame(1, $spy->capturedStatus); + } + + public function test_register_timeout_handler_triggers_kill_on_alarm(): void + { + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('pcntl extension is required'); + } + + $spy = new SupervisorStopSpy( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $options = new SupervisorOptions; + $options->timeout = 1; + $spy->exposeRegisterTimeoutHandler($options); + + $handler = pcntl_signal_get_handler(SIGALRM); + $this->assertIsCallable($handler); + $handler(); + + $this->assertSame(1, $spy->exitStatus); + } + + public function test_supervise_pauses_when_maintenance_mode_is_enabled(): void + { + $this->laravel->shouldReceive('make')->once()->with(SupervisorState::class)->andReturn($state = new SupervisorState()); + $this->cache->shouldReceive('get')->twice()->with('illuminate:queue:restart')->andReturn(null); + $this->laravel->shouldReceive('isDownForMaintenance')->once()->andReturn(true); + $this->events->shouldReceive('until')->once()->with(Mockery::type(LoopCompleting::class))->andReturn(false); + $this->events->shouldReceive('dispatch')->once()->with(Mockery::type(SupervisorStopping::class))->andThrow(new \Exception('stopped')); + + $options = new SupervisorOptions; + $options->force = false; + + try { + $this->supervisor->supervise(function () { + $this->fail('Command should not run while in maintenance mode'); + }, $options); + } catch (\Exception $e) { + $this->assertSame('stopped', $e->getMessage()); + } + + $this->assertSame(1, $this->supervisor->paused); + } + + public function test_pause_sleeps_for_one_second(): void + { + $supervisor = new Supervisor( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + + $method = new \ReflectionMethod(Supervisor::class, 'pause'); + $method->setAccessible(true); + $method->invoke($supervisor); + + $this->assertTrue(true); + } +} + +trait SupervisorMocks +{ + protected MockInterface|Application $laravel; + protected MockInterface|Cache $cache; + protected MockInterface|Bus $bus; + protected MockInterface|Events $events; + protected MockInterface|ExceptionHandler $exceptions; + protected SupervisorStub $supervisor; + + protected function setUp(): void + { + parent::setUp(); + + $app = new \Illuminate\Foundation\Application; + $this->exceptions = Mockery::mock(ExceptionHandler::class); + $this->exceptions->shouldReceive('report')->byDefault(); + $app->instance(ExceptionHandler::class, $this->exceptions); + \Illuminate\Container\Container::setInstance($app); + + $this->laravel = Mockery::mock(Application::class); + $this->cache = Mockery::mock(Cache::class); + $this->bus = Mockery::mock(Bus::class); + $this->events = Mockery::mock(Events::class); + + $this->supervisor = new SupervisorStub( + $this->laravel, + $this->cache, + $this->bus, + $this->events, + $this->exceptions + ); + } + + protected function tearDown(): void + { + Mockery::close(); + \Illuminate\Container\Container::setInstance(null); + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + parent::tearDown(); + } +} + +class SupervisorMemorySpy extends SupervisorStub +{ + protected function memoryExceeded($memoryLimit) + { + return true; + } +} + +class SupervisorSignalSpy extends SupervisorStub +{ + private array $handlers = []; + + public function exposeListenForSignals(SupervisorState $state): void + { + $this->listenForSignals($state); + $this->handlers = [ + SIGTERM => pcntl_signal_get_handler(SIGTERM), + SIGUSR2 => pcntl_signal_get_handler(SIGUSR2), + SIGCONT => pcntl_signal_get_handler(SIGCONT), + ]; + } + + public function exposeSignalHandlers(): array + { + return $this->handlers; + } +} + +class SupervisorStopSpy extends SupervisorStub +{ + public ?int $exitStatus = null; + + protected function kill($status = 0) + { + $this->terminateProcess($status); + } + + protected function exitProcess(int $status): void + { + $this->exitStatus = $status; + } + + protected function terminateProcess(int $status = 0): void + { + $this->exitStatus = $status; + } + + public function exposeRegisterTimeoutHandler(SupervisorOptions $options): void + { + $this->registerTimeoutHandler($options); + } + + public function exposeStop(int $status, bool $exit, SupervisorState $state): void + { + $this->stop($status, $exit, $state); + } + + public function exposeKill(int $status = 0): void + { + $this->kill($status); + } +} + +class SupervisorKillTestSpy extends Supervisor +{ + public ?int $capturedStatus = null; + + public function __construct( + Application $laravel, + Cache $cache, + Bus $bus, + Events $events, + ExceptionHandler $exceptions + ) { + parent::__construct($laravel, $cache, $bus, $events, $exceptions); + } + + protected function terminateProcess(int $status = 0): void + { + $this->capturedStatus = $status; + } + + public function exposeKill(): void + { + parent::kill(1); + } +} diff --git a/tests/SupervisorStub.php b/tests/SupervisorStub.php new file mode 100644 index 0000000..44f9742 --- /dev/null +++ b/tests/SupervisorStub.php @@ -0,0 +1,20 @@ +paused++; + } +} diff --git a/tests/SupervisorTest.php b/tests/SupervisorTest.php index 21016bc..9fbd733 100644 --- a/tests/SupervisorTest.php +++ b/tests/SupervisorTest.php @@ -65,10 +65,13 @@ public function setUp(): void $this->supervisor = new SupervisorStub($this->laravel, $this->cache, $this->bus, $this->events, $this->exceptions); } - public function tearDown(): void + protected function tearDown(): void { Mockery::close(); - pcntl_alarm(0); + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + parent::tearDown(); } public function test_it_pause_when_app_is_down_then_stop_on_queue_restart() @@ -156,17 +159,3 @@ public function test_it_runs_once_before_queue_restart() } } -class SupervisorStub extends Supervisor -{ - public $paused = 0; - - protected function kill($status = 0) - { - throw new \Exception('killed'); - } - - protected function pause() - { - $this->paused++; - } -} diff --git a/tests/Support/InvokesPrivateMethods.php b/tests/Support/InvokesPrivateMethods.php new file mode 100644 index 0000000..95e35dd --- /dev/null +++ b/tests/Support/InvokesPrivateMethods.php @@ -0,0 +1,14 @@ +setAccessible(true); + + return $reflection->invoke($object, ...$args); + } +} diff --git a/tests/Support/RedisConfig.php b/tests/Support/RedisConfig.php new file mode 100644 index 0000000..095688f --- /dev/null +++ b/tests/Support/RedisConfig.php @@ -0,0 +1,18 @@ + getenv('REDIS_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('REDIS_PORT') ?: 6379), + 'database' => 5, + 'timeout' => 10.0, + ], $overrides)); + } +} diff --git a/tests/Support/RunsConsoleCommands.php b/tests/Support/RunsConsoleCommands.php new file mode 100644 index 0000000..d73a03f --- /dev/null +++ b/tests/Support/RunsConsoleCommands.php @@ -0,0 +1,36 @@ +setLaravel($this->app); + + $input = new ArrayInput(array_merge($arguments, $options)); + $input->bind($command->getDefinition()); + + $output = new BufferedOutput; + $command->run($input, new \Illuminate\Console\OutputStyle($input, $output)); + + return $output; + } + + protected function makeBoundCommand(Command $command, array $arguments = [], array $options = []): Command + { + $command->setLaravel($this->app); + + $input = new ArrayInput(array_merge($arguments, $options)); + $input->bind($command->getDefinition()); + $output = new BufferedOutput; + $command->setInput($input); + $command->setOutput(new \Illuminate\Console\OutputStyle($input, $output)); + + return $command; + } +} diff --git a/tests/TestAssets.php b/tests/TestAssets.php index f6bf068..8ab1274 100644 --- a/tests/TestAssets.php +++ b/tests/TestAssets.php @@ -86,6 +86,8 @@ class UserModel extends Model { use HasCastables; + protected $table = 'user_models'; + protected $fillable = ['mobile']; protected static $castables = [ diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..1be7e06 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,79 @@ +set('database.default', 'testing'); + + if ($this->usesMysqlFromEnvironment()) { + $app['config']->set('database.connections.testing', [ + 'driver' => 'mysql', + 'host' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => getenv('DB_PORT') ?: '3306', + 'database' => getenv('DB_DATABASE') ?: 'helpers_test', + 'username' => getenv('DB_USERNAME') ?: 'helpers', + 'password' => getenv('DB_PASSWORD') ?: 'secret', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + ]); + } else { + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + $app['config']->set('cache.default', 'array'); + $app['config']->set('view.paths', [__DIR__.'/fixtures/views']); + } + + protected function usesMysqlFromEnvironment(): bool + { + $host = getenv('DB_HOST'); + + return $host !== false && $host !== ''; + } + + protected function setUp(): void + { + parent::setUp(); + + if (! is_dir(__DIR__.'/fixtures/views')) { + mkdir(__DIR__.'/fixtures/views', 0777, true); + } + } + + protected function createTestTable(): void + { + $this->recreateTable('test_models', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('status')->nullable(); + }); + } + + protected function recreateTable(string $table, callable $callback): void + { + $schema = $this->app['db']->connection()->getSchemaBuilder(); + $schema->dropIfExists($table); + $schema->create($table, $callback); + } +} diff --git a/tests/fixtures/fake-mysql.sh b/tests/fixtures/fake-mysql.sh new file mode 100644 index 0000000..661f0ee --- /dev/null +++ b/tests/fixtures/fake-mysql.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cat > /dev/null +exit 0 diff --git a/tests/fixtures/fake-mysqldump.sh b/tests/fixtures/fake-mysqldump.sh new file mode 100644 index 0000000..26e0390 --- /dev/null +++ b/tests/fixtures/fake-mysqldump.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +OUT="" +for arg in "$@"; do + case "$arg" in + --result-file=*) OUT="${arg#*=}" ;; + esac +done +if [ -z "$OUT" ]; then + echo "fake-mysqldump: missing --result-file" >&2 + exit 1 +fi +printf '%s\n' '-- fake sql dump' > "$OUT" +exit 0 diff --git a/tests/fixtures/views/child.blade.php b/tests/fixtures/views/child.blade.php new file mode 100644 index 0000000..a50b733 --- /dev/null +++ b/tests/fixtures/views/child.blade.php @@ -0,0 +1,5 @@ +@extends('layout') +@section('body') +child-body +@parent +@endsection \ No newline at end of file diff --git a/tests/fixtures/views/layout.blade.php b/tests/fixtures/views/layout.blade.php new file mode 100644 index 0000000..c2db840 --- /dev/null +++ b/tests/fixtures/views/layout.blade.php @@ -0,0 +1,3 @@ +@section('body') +layout-body +@show \ No newline at end of file