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
-[](https://travis-ci.org/halaei/helpers)
+[](https://github.com/halaei/helpers/actions/workflows/tests.yml)
[](https://packagist.org/packages/halaei/helpers)
[](https://packagist.org/packages/halaei/helpers)
[](https://packagist.org/packages/halaei/helpers)
[](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