diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..aaa53f0c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +name: Pull Request Check + +on: [pull_request] + +jobs: + unit-test: + name: Unit testing + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('composer.lock') }} + + - name: Installing dependencies + uses: php-actions/composer@v6 + with: + php_version: "8.1" + php_extensions: "mbstring json zip dom curl libxml intl fileinfo bcmath" + + - name: Running unit test + uses: php-actions/phpunit@v3 + with: + version: "9.6" + php_version: "8.1" + php_extensions: "mbstring json zip dom curl libxml intl fileinfo bcmath" + args: "-d date.timezone=UTC" + memory_limit: 512M + configuration: phpunit.xml.dist + + static-analysis: + name: Static analysis (PHPStan) + runs-on: ubuntu-latest + # Non-blocking: the legacy code currently has many findings. This job reports + # them on the PR without failing the check until they are progressively cleaned up. + continue-on-error: true + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + extensions: mbstring, dom, curl, libxml, intl, zip, fileinfo + coverage: none + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ hashFiles('composer.json') }} + + - name: Run composer install + run: composer install -n --prefer-dist --no-progress + + - name: Run PHPStan + run: php -d memory_limit=768M vendor/bin/phpstan analyse --no-progress --error-format=github \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a709758d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +The Themosis framework: the **core APIs** package (`themosis/framework`) of a larger WordPress +development stack. It re-implements a Laravel-style application layer (container, service providers, +routing, views, validation, console) on top of WordPress, using Laravel's `illuminate/*` 8.x +components as building blocks. This repo is the framework only — the runnable app skeleton, theme, +and plugin boilerplates live in separate `themosis/*` repositories (see `.github/CONTRIBUTING.md`). + +PHP code is PSR-4 autoloaded as `Themosis\` from `src/`; tests as `Themosis\Tests\` from `tests/`. +Front-end assets compile from `resources/` into `dist/` and ship with the package. + +## Commands + +PHP: +- `composer test` — run the PHPUnit suite (alias for `./vendor/bin/phpunit`). +- `./vendor/bin/phpunit --filter MethodOrClassName` — run a single test or test class. +- `./vendor/bin/phpunit tests/Forms/FormCreationTest.php` — run one test file. +- `composer fix` — apply PHP-CS-Fixer formatting (config: `.php-cs-fixer.dist.php`). +- `vendor/bin/phpstan analyse` — static analysis (level 2, `src/` only; see `phpstan.neon.dist`). + Provided by the `phpstan/phpstan` + `szepeviktor/phpstan-wordpress` dev deps; the config's + `phar://phpstan.phar/...` include resolves through the Composer-installed binary. Currently + reports a baseline of ~130 findings on the legacy code (mostly Illuminate contract drift and + `new static()`); it is **not yet clean**, so treat new errors — not the absolute count — as the + signal. (`composer update --classmap-authoritative` first, per `phpstan.neon.dist`, if needed.) + +JavaScript / TypeScript assets: +- `yarn dev` / `yarn watch` / `yarn production` — Laravel Mix (webpack) builds into `dist/js`. +- `yarn test` — Jest (ts-jest); test files match `*.test.*` / `*.spec.*` / `__tests__`. + +## Architecture + +**`Themosis\Core\Application` (`src/Core/Application.php`)** is the heart: it extends Illuminate's +`Container` and implements Laravel's `Foundation\Application`, `CachesConfiguration`, `CachesRoutes`, +and `HttpKernelInterface` contracts. It is the Laravel application object adapted to run inside a +WordPress request lifecycle. Almost everything is resolved through this container. + +**Service providers** wire each subsystem into the container. Every top-level feature directory +under `src/` ships its own provider (e.g. `Asset/AssetServiceProvider`, `Route/RouteServiceProvider`, +`PostType/PostTypeServiceProvider`, `Field/FieldServiceProvider`, `Forms/FormServiceProvider`, +`View/ViewServiceProvider` + `BladeServiceProvider`). `src/Core/Providers/` holds the framework-level +providers; `CoreServiceProvider` is an `AggregateServiceProvider` and registers request macros. +When adding a feature, follow this pattern: a provider that `register()`s bindings and `boot()`s +behavior, often `publishes()`ing assets/views when `runningInConsole()`. + +**WordPress integration via the Hookable pattern (`src/Hook/`, `src/Core/HooksRepository.php`)**: +classes extending `Themosis\Hook\Hookable` are registered through the application +(`$app->registerHook(...)`) and bound to WordPress actions/filters. `Hook`, `ActionBuilder`, and +`FilterBuilder` are the OO wrappers around WordPress's `add_action`/`add_filter`. This is the main +bridge between the Laravel-style container world and WordPress's global hook system. + +**Subsystems** (each a directory under `src/` with a matching test dir under `tests/`): +- `PostType`, `Taxonomy`, `Metabox`, `Page`, `User` — OO builders for WordPress entities; these + register custom post types, taxonomies, meta boxes, admin/option pages, and user fields. +- `Field` + `Forms` — field definitions and form building/validation (`FormBuilder`, `FormFactory`, + data mappers and transformers). `Metabox`, `Page`, and `User` consume `Field` instances. +- `Route` — a custom `Router`/`RouteCollection` over `illuminate/routing`, including `AdminRoute` + and WordPress-condition route matching (`Route/Matching/`, `Route/Bindings/`). +- `View` — Blade (`BladeServiceProvider`) **and** Twig (`twig/twig`) view engines. +- `Asset`, `Html`, `Ajax`, `Auth` — asset management, HTML/form helpers, AJAX endpoints, auth. + +**Console / Artisan**: `src/Core/Console/` plus `ArtisanServiceProvider` / `ConsoleCoreServiceProvider` +provide Laravel-style `artisan` commands adapted for WordPress. + +**Global helpers** are loaded via Composer `files` autoload from `src/Core/helpers.php` (path +helpers like `web_path`, `content_path`, `resource_path`, etc.). + +## Testing conventions + +Tests extend PHPUnit's `TestCase` directly (not a Laravel base TestCase) and **manually construct** +the Illuminate components they need (view factory, validation factory, events dispatcher, etc.) — see +`tests/Page/PageTest.php` and `tests/bootstrap.php`. `tests/bootstrap.php` defines WordPress-style +constants (`WP_CONTENT_DIR`, etc.) and pulls in `tests/functions.php` which stubs WordPress functions, +so tests run without a live WordPress install. `tests/deprecated` is excluded from the suite +(`phpunit.xml.dist`). Per the contribution guide, **every PR must include unit tests**. + +CI runs on pull requests via `.github/workflows/tests.yml`: a `unit-test` job (PHP 8.1, the +PHPUnit suite) and a non-blocking `static-analysis` job (PHPStan, `continue-on-error` while the +legacy findings are cleaned up). `composer.lock` is gitignored, so CI resolves dependencies fresh +from `composer.json` on each run. + +### Checklist for Complete Testing Workflow + +- [ ] **Analyze existing patterns** in similar test files +- [ ] **Create comprehensive test coverage** for all methods and scenarios +- [ ] **Extract magic strings** to constants in both source and test files +- [ ] **Organize constants alphabetically** with descriptive names +- [ ] **Create default test fixtures** for consistent setup +- [ ] **Mock all dependencies** properly with verified method calls +- [ ] **Test all error cases** and edge conditions +- [ ] **Generate coverage reports** (`composer coverage`) to verify 100% method/line coverage +- [ ] **Fix code style** with `composer fix` (PHP-CS-Fixer — this repo does not use phpcbf) before committing +- [ ] **Create atomic commits** with descriptive messages +- [ ] **Validate final implementation** with full test run (`composer test`) + +This workflow ensures high-quality, maintainable code with comprehensive test coverage and excellent development practices. + +## Branching + +Current default branch is `3.0`. Bug fixes and minor backwards-compatible features target the latest +stable branch; major features target the upcoming-release branch. Do not target `main` for bug fixes +unless they fix features that exist only in the upcoming release. \ No newline at end of file diff --git a/composer.json b/composer.json index 6054aa58..3f3f04dd 100644 --- a/composer.json +++ b/composer.json @@ -28,47 +28,47 @@ } }, "require": { - "php": "^8.0", + "php": "^8.1", "composer/installers": "^1.9", "dragonmantank/cron-expression": "^3.0.2", "filp/whoops": "^2.1", "guzzlehttp/guzzle": "^7.2", - "illuminate/auth": "^8.83", - "illuminate/broadcasting": "^8.83", - "illuminate/bus": "^8.83", - "illuminate/cache": "^8.83", - "illuminate/config": "^8.83", - "illuminate/console": "^8.83", - "illuminate/container": "^8.83", - "illuminate/cookie": "^8.83", - "illuminate/database": "^8.83", - "illuminate/encryption": "^8.83", - "illuminate/events": "^8.83", - "illuminate/filesystem": "^8.83", - "illuminate/hashing": "^8.83", - "illuminate/http": "^8.83", - "illuminate/log": "^8.83", - "illuminate/mail": "^8.83", - "illuminate/notifications": "^8.83", - "illuminate/pagination": "^8.83", - "illuminate/queue": "^8.83", - "illuminate/redis": "^8.83", - "illuminate/routing": "^8.83", - "illuminate/session": "^8.83", - "illuminate/support": "^8.83", - "illuminate/testing": "^8.83", - "illuminate/validation": "^8.83", - "illuminate/view": "^8.83", - "laravel/tinker": "^2.5", - "laravel/ui": "^3.1", - "league/flysystem": "^1.1", + "illuminate/auth": "^9.0", + "illuminate/broadcasting": "^9.0", + "illuminate/bus": "^9.0", + "illuminate/cache": "^9.0", + "illuminate/config": "^9.0", + "illuminate/console": "^9.0", + "illuminate/container": "^9.0", + "illuminate/cookie": "^9.0", + "illuminate/database": "^9.0", + "illuminate/encryption": "^9.0", + "illuminate/events": "^9.0", + "illuminate/filesystem": "^9.0", + "illuminate/hashing": "^9.0", + "illuminate/http": "^9.0", + "illuminate/log": "^9.0", + "illuminate/mail": "^9.0", + "illuminate/notifications": "^9.0", + "illuminate/pagination": "^9.0", + "illuminate/queue": "^9.0", + "illuminate/redis": "^9.0", + "illuminate/routing": "^9.0", + "illuminate/session": "^9.0", + "illuminate/support": "^9.0", + "illuminate/testing": "^9.0", + "illuminate/validation": "^9.0", + "illuminate/view": "^9.0", + "laravel/tinker": "^2.8", + "laravel/ui": "^4.0", + "league/flysystem": "^3.0", "league/fractal": "^0.20", "paragonie/sodium_compat": "^1.15", - "predis/predis": "^1.1", + "predis/predis": "^2.0", "ramsey/uuid": "^4.1", "spatie/enum": "^3.13", - "symfony/property-access": "^5.4", - "twig/twig": " ^3.1", + "symfony/property-access": "^6.0", + "twig/twig": "^3.1", "vlucas/phpdotenv": "^5.2" }, "require-dev": { @@ -76,12 +76,14 @@ "fakerphp/faker": "^1.9.1", "friendsofphp/php-cs-fixer": "^3.8", "johnpbloch/wordpress-core": "^6.0", + "phpstan/phpstan": "~1.11.0", "phpunit/phpunit": "^9.0", - "symfony/var-dumper": "^5.4" + "symfony/var-dumper": "^6.0", + "szepeviktor/phpstan-wordpress": "^1.3" }, "scripts": { - "test": "phpunit", - "fix": "php-cs-fixer fix" + "test": "./vendor/bin/phpunit", + "fix": "./vendor/bin/php-cs-fixer fix" }, "config": { "sort-packages": true, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..0bbc85a8 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,491 @@ +parameters: + ignoreErrors: + - + message: "#^Method Themosis\\\\Core\\\\AliasLoader\\:\\:load\\(\\) should return bool\\|null but return statement is missing\\.$#" + count: 1 + path: src/Core/AliasLoader.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/Core/AliasLoader.php + + - + message: "#^Access to an undefined property Themosis\\\\Core\\\\Application\\:\\:\\$config\\.$#" + count: 2 + path: src/Core/Application.php + + - + message: "#^Access to an undefined property hasMethod\\(register\\)\\:\\:\\$priority\\.$#" + count: 1 + path: src/Core/Application.php + + - + message: "#^Class Twig_Environment not found\\.$#" + count: 1 + path: src/Core/Application.php + + - + message: "#^Constant THEMOSIS_PUBLIC_DIR not found\\.$#" + count: 1 + path: src/Core/Application.php + + - + message: "#^PHPDoc tag @return has invalid value \\(\\$this;\\)\\: Unexpected token \";\", expected TOKEN_HORIZONTAL_WS at offset 259 on line 8$#" + count: 1 + path: src/Core/Application.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:detectEnvironment\\(\\)\\.$#" + count: 1 + path: src/Core/Bootstrap/ConfigurationLoader.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedConfigPath\\(\\)\\.$#" + count: 1 + path: src/Core/Bootstrap/ConfigurationLoader.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:configurationIsCached\\(\\)\\.$#" + count: 1 + path: src/Core/Bootstrap/EnvironmentLoader.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:environmentFile\\(\\)\\.$#" + count: 3 + path: src/Core/Bootstrap/EnvironmentLoader.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:environmentPath\\(\\)\\.$#" + count: 2 + path: src/Core/Bootstrap/EnvironmentLoader.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:loadEnvironmentFrom\\(\\)\\.$#" + count: 1 + path: src/Core/Bootstrap/EnvironmentLoader.php + + - + message: "#^Instantiated class Illuminate\\\\Queue\\\\SerializableClosure not found\\.$#" + count: 1 + path: src/Core/Bus/PendingChain.php + + - + message: "#^PHPDoc tag @param references unknown parameter\\: \\$class$#" + count: 1 + path: src/Core/Bus/PendingChain.php + + - + message: "#^Call to method getComposer\\(\\) on an unknown class Composer\\\\Script\\\\Event\\.$#" + count: 3 + path: src/Core/ComposerScripts.php + + - + message: "#^Constant CONTENT_DIR not found\\.$#" + count: 1 + path: src/Core/ComposerScripts.php + + - + message: "#^Constant THEMOSIS_PUBLIC_DIR not found\\.$#" + count: 1 + path: src/Core/ComposerScripts.php + + - + message: "#^Constant THEMOSIS_ROOT not found\\.$#" + count: 1 + path: src/Core/ComposerScripts.php + + - + message: "#^Parameter \\$event of method Themosis\\\\Core\\\\ComposerScripts\\:\\:postAutoloadDump\\(\\) has invalid type Composer\\\\Script\\\\Event\\.$#" + count: 2 + path: src/Core/ComposerScripts.php + + - + message: "#^Parameter \\$event of method Themosis\\\\Core\\\\ComposerScripts\\:\\:postInstall\\(\\) has invalid type Composer\\\\Script\\\\Event\\.$#" + count: 2 + path: src/Core/ComposerScripts.php + + - + message: "#^Parameter \\$event of method Themosis\\\\Core\\\\ComposerScripts\\:\\:postUpdate\\(\\) has invalid type Composer\\\\Script\\\\Event\\.$#" + count: 2 + path: src/Core/ComposerScripts.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedPackagesPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/ClearCompiledCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedServicesPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/ClearCompiledCommand.php + + - + message: "#^Method Themosis\\\\Core\\\\Console\\\\ComponentMakeCommand\\:\\:handle\\(\\) should return bool\\|null but return statement is missing\\.$#" + count: 2 + path: src/Core/Console/ComponentMakeCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedConfigPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/ConfigCacheCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedConfigPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/ConfigClearCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedEventsPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/EventCacheCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedEventsPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/EventClearCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:environmentFilePath\\(\\)\\.$#" + count: 2 + path: src/Core/Console/KeyGenerateCommand.php + + - + message: "#^Method Themosis\\\\Core\\\\Console\\\\MailMakeCommand\\:\\:handle\\(\\) should return bool\\|null but return statement is missing\\.$#" + count: 2 + path: src/Core/Console/MailMakeCommand.php + + - + message: "#^Method Themosis\\\\Core\\\\Console\\\\ModelMakeCommand\\:\\:handle\\(\\) should return bool\\|null but return statement is missing\\.$#" + count: 2 + path: src/Core/Console/ModelMakeCommand.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 6 + path: src/Core/Console/QueueCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedRoutesPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/RouteCacheCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedRoutesPath\\(\\)\\.$#" + count: 1 + path: src/Core/Console/RouteClearCommand.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:environmentFilePath\\(\\)\\.$#" + count: 2 + path: src/Core/Console/SaltsGenerateCommand.php + + - + message: "#^Call to an undefined method Exception\\:\\:getHeaders\\(\\)\\.$#" + count: 2 + path: src/Core/Exceptions/Handler.php + + - + message: "#^Call to an undefined method Exception\\:\\:getStatusCode\\(\\)\\.$#" + count: 2 + path: src/Core/Exceptions/Handler.php + + - + message: "#^PHPDoc tag @return has invalid value \\(\\\\Symfony\\\\Component\\\\HttpFoundation\\\\Response;\\)\\: Unexpected token \";\", expected TOKEN_HORIZONTAL_WS at offset 261 on line 8$#" + count: 1 + path: src/Core/Exceptions/Handler.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:registerHook\\(\\)\\.$#" + count: 1 + path: src/Core/HooksRepository.php + + - + message: "#^Instantiated class Symfony\\\\Component\\\\Debug\\\\Exception\\\\FatalThrowableError not found\\.$#" + count: 1 + path: src/Core/Http/Kernel.php + + - + message: "#^Comparison operation \"\\>\" between array\\|string\\|null and int\\<1, max\\> results in an error\\.$#" + count: 1 + path: src/Core/Http/Middleware/ValidatePostSize.php + + - + message: "#^PHPDoc tag @var with type SplFileInfo is not subtype of native type Symfony\\\\Component\\\\Finder\\\\SplFileInfo\\.$#" + count: 1 + path: src/Core/PluginManager.php + + - + message: "#^Method Themosis\\\\Core\\\\PluginsRepository\\:\\:getPlugin\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: src/Core/PluginsRepository.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:addDeferredServices\\(\\)\\.$#" + count: 1 + path: src/Core/ProviderRepository.php + + - + message: "#^Method Themosis\\\\Core\\\\ProviderRepository\\:\\:loadManifest\\(\\) should return array\\|null but return statement is missing\\.$#" + count: 2 + path: src/Core/ProviderRepository.php + + - + message: "#^Class Illuminate\\\\Database\\\\Console\\\\Migrations\\\\MigrateCommand constructor invoked with 1 parameter, 2 required\\.$#" + count: 1 + path: src/Core/Providers/ArtisanServiceProvider.php + + - + message: "#^Call to an undefined method Themosis\\\\Core\\\\Providers\\\\CoreServiceProvider\\:\\:all\\(\\)\\.$#" + count: 1 + path: src/Core/Providers/CoreServiceProvider.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:eventsAreCached\\(\\)\\.$#" + count: 1 + path: src/Core/Support/Providers/EventServiceProvider.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedEventsPath\\(\\)\\.$#" + count: 1 + path: src/Core/Support/Providers/EventServiceProvider.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:path\\(\\)\\.$#" + count: 1 + path: src/Core/Support/Providers/EventServiceProvider.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:getCachedRoutesPath\\(\\)\\.$#" + count: 1 + path: src/Core/Support/Providers/RouteServiceProvider.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Foundation\\\\Application\\:\\:routesAreCached\\(\\)\\.$#" + count: 1 + path: src/Core/Support/Providers/RouteServiceProvider.php + + - + message: "#^Constant CONTENT_DIR not found\\.$#" + count: 1 + path: src/Core/ThemeManager.php + + - + message: "#^Constant SUBDOMAIN_INSTALL not found\\.$#" + count: 2 + path: src/Core/ThemeManager.php + + - + message: "#^PHPDoc tag @var with type SplFileInfo is not subtype of native type Symfony\\\\Component\\\\Finder\\\\SplFileInfo\\.$#" + count: 1 + path: src/Core/ThemeManager.php + + - + message: "#^Class Gate not found\\.$#" + count: 1 + path: src/Core/helpers.php + + - + message: "#^Instantiated class CallQueuedClosure not found\\.$#" + count: 1 + path: src/Core/helpers.php + + - + message: "#^Instantiated class Illuminate\\\\Queue\\\\SerializableClosure not found\\.$#" + count: 1 + path: src/Core/helpers.php + + - + message: "#^Instantiated class Symfony\\\\Component\\\\Debug\\\\Exception\\\\FatalThrowableError not found\\.$#" + count: 1 + path: src/Core/helpers.php + + - + message: "#^PHPDoc tag @param has invalid value \\(dynamic key\\|key,default\\|data,expiration\\|null\\)\\: Unexpected token \"key\", expected variable at offset 158 on line 6$#" + count: 1 + path: src/Core/helpers.php + + - + message: "#^Call to an undefined method Themosis\\\\Field\\\\Factory\\:\\:make\\(\\)\\.$#" + count: 1 + path: src/Field/Factory.php + + - + message: "#^Method Themosis\\\\Field\\\\Factory\\:\\:date\\(\\) has invalid return type Themosis\\\\Field\\\\Fields\\\\DateField\\.$#" + count: 1 + path: src/Field/Factory.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Validation\\\\Validator\\:\\:valid\\(\\)\\.$#" + count: 1 + path: src/Forms/Form.php + + - + message: "#^Variable \\$field in PHPDoc tag @var does not match any variable in the foreach loop\\: \\$attr, \\$message$#" + count: 1 + path: src/Forms/Form.php + + - + message: "#^PHPDoc tag @var with type Themosis\\\\Forms\\\\Contracts\\\\FieldTypeInterface\\|Themosis\\\\Forms\\\\Contracts\\\\FormInterface is not subtype of native type Themosis\\\\Forms\\\\Contracts\\\\FieldTypeInterface\\.$#" + count: 2 + path: src/Forms/Resources/Transformers/FormTransformer.php + + - + message: "#^PHPDoc tag @param has invalid value \\(string\\|array\\|\\\\WP_Screen\\)\\: Unexpected token \"\\\\n \\* \", expected variable at offset 79 on line 4$#" + count: 1 + path: src/Metabox/Contracts/MetaboxInterface.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Validation\\\\Validator\\:\\:valid\\(\\)\\.$#" + count: 1 + path: src/Metabox/Manager.php + + - + message: "#^Call to an undefined method Themosis\\\\Forms\\\\Contracts\\\\FieldTypeInterface\\|Themosis\\\\Forms\\\\Fields\\\\Contracts\\\\CanHandleMetabox\\:\\:setErrorMessageBag\\(\\)\\.$#" + count: 1 + path: src/Metabox/Manager.php + + - + message: "#^PHPDoc tag @var has invalid value \\(\\$validator Validator\\)\\: Unexpected token \"\\$validator\", expected type at offset 9 on line 1$#" + count: 1 + path: src/Metabox/Manager.php + + - + message: "#^Variable \\$field in PHPDoc tag @var does not match any variable in the foreach loop\\: \\$attr, \\$message$#" + count: 1 + path: src/Metabox/Manager.php + + - + message: "#^PHPDoc tag @return with type array is incompatible with native type Illuminate\\\\Support\\\\Collection\\.$#" + count: 2 + path: src/Page/Contracts/SettingsRepositoryInterface.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\View\\\\Factory\\:\\:getContainer\\(\\)\\.$#" + count: 3 + path: src/Page/Page.php + + - + message: "#^Call to an undefined method Themosis\\\\Support\\\\Contracts\\\\UIContainerInterface\\:\\:setViewInstance\\(\\)\\.$#" + count: 1 + path: src/Page/Page.php + + - + message: "#^Call to an undefined method Themosis\\\\Support\\\\Contracts\\\\UIContainerInterface\\:\\:useShortPath\\(\\)\\.$#" + count: 1 + path: src/Page/Page.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\View\\\\Factory\\:\\:getContainer\\(\\)\\.$#" + count: 2 + path: src/Page/PageFactory.php + + - + message: "#^PHPDoc tag @return with type array is incompatible with native type Illuminate\\\\Support\\\\Collection\\.$#" + count: 2 + path: src/Page/PageSettingsRepository.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Route\\:\\:getCondition\\(\\)\\.$#" + count: 1 + path: src/Route/Matching/ConditionValidator.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Route\\:\\:getConditionParameters\\(\\)\\.$#" + count: 1 + path: src/Route/Matching/ConditionValidator.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Routing\\\\Registrar\\:\\:addWordPressBindings\\(\\)\\.$#" + count: 1 + path: src/Route/Middleware/WordPressBindings.php + + - + message: "#^Cannot call method hasCondition\\(\\) on object\\|string\\.$#" + count: 1 + path: src/Route/Middleware/WordPressHeaders.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Route\\:\\:getConditionParameters\\(\\)\\.$#" + count: 2 + path: src/Route/RouteCollection.php + + - + message: "#^Call to an undefined method Illuminate\\\\Routing\\\\Route\\:\\:hasCondition\\(\\)\\.$#" + count: 1 + path: src/Route/RouteCollection.php + + - + message: "#^Variable \\$method might not be defined\\.$#" + count: 1 + path: src/Route/RouteCollection.php + + - + message: "#^Call to an undefined method Themosis\\\\Route\\\\Router\\:\\:emailVerification\\(\\)\\.$#" + count: 1 + path: src/Route/Router.php + + - + message: "#^Call to an undefined method Themosis\\\\Route\\\\Router\\:\\:resetPassword\\(\\)\\.$#" + count: 1 + path: src/Route/Router.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Validation\\\\Validator\\:\\:valid\\(\\)\\.$#" + count: 1 + path: src/Taxonomy/TaxonomyField.php + + - + message: "#^Call to an undefined method Themosis\\\\Forms\\\\Contracts\\\\FieldTypeInterface\\|Themosis\\\\Forms\\\\Fields\\\\Contracts\\\\CanHandleTerms\\:\\:setErrorMessageBag\\(\\)\\.$#" + count: 1 + path: src/Taxonomy/TaxonomyField.php + + - + message: "#^Variable \\$term in PHPDoc tag @var does not match any variable in the foreach loop\\: \\$field$#" + count: 1 + path: src/Taxonomy/TaxonomyField.php + + - + message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Validation\\\\Validator\\:\\:valid\\(\\)\\.$#" + count: 1 + path: src/User/UserField.php + + - + message: "#^Call to an undefined method Themosis\\\\Forms\\\\Contracts\\\\FieldTypeInterface\\|Themosis\\\\Forms\\\\Fields\\\\Contracts\\\\CanHandleUsers\\:\\:setErrorMessageBag\\(\\)\\.$#" + count: 1 + path: src/User/UserField.php + + - + message: "#^Call to method render\\(\\) on an unknown class Twig_Environment\\.$#" + count: 1 + path: src/View/Engines/Twig.php + + - + message: "#^Property Themosis\\\\View\\\\Engines\\\\Twig\\:\\:\\$twig has unknown class Twig_Environment as its type\\.$#" + count: 1 + path: src/View/Engines/Twig.php + + - + message: "#^Method Themosis\\\\View\\\\Extensions\\\\WordPress\\:\\:getFilters\\(\\) has invalid return type Twig_Filter\\.$#" + count: 1 + path: src/View/Extensions/WordPress.php + + - + message: "#^Method Themosis\\\\View\\\\Extensions\\\\WordPress\\:\\:getFunctions\\(\\) has invalid return type Twig_Function\\.$#" + count: 1 + path: src/View/Extensions/WordPress.php + + - + message: "#^PHPDoc tag @var with type array\\ is not subtype of native type mixed\\.$#" + count: 1 + path: src/View/FileViewFinder.php + + - + message: "#^Class WP_Post referenced with incorrect case\\: WP_post\\.$#" + count: 1 + path: src/View/Loop.php + + - + message: "#^PHPDoc tag @param has invalid value \\(int\\|\\\\WP_Post The post ID or WP_Post object\\)\\: Unexpected token \"The\", expected variable at offset 157 on line 5$#" + count: 1 + path: src/View/Loop.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e233d89a..dc9b6224 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,11 +4,12 @@ includes: # https://github.com/phpstan/phpstan/blob/master/conf/bleedingEdge.neon - phar://phpstan.phar/conf/bleedingEdge.neon - vendor/szepeviktor/phpstan-wordpress/extension.neon + - phpstan-baseline.neon parameters: level: 2 paths: - %currentWorkingDirectory%/src/ - excludes_analyse: + excludePaths: - %currentWorkingDirectory%/src/Page/views - %currentWorkingDirectory%/src/Core/Exceptions/views - %currentWorkingDirectory%/src/Taxonomy/views diff --git a/src/Auth/Console/AuthMakeCommand.php b/src/Auth/Console/AuthMakeCommand.php index c6363ec6..096ecb76 100644 --- a/src/Auth/Console/AuthMakeCommand.php +++ b/src/Auth/Console/AuthMakeCommand.php @@ -3,13 +3,10 @@ namespace Themosis\Auth\Console; use Illuminate\Console\Command; -use Illuminate\Console\DetectsApplicationNamespace; use Illuminate\Filesystem\Filesystem; class AuthMakeCommand extends Command { - use DetectsApplicationNamespace; - /** * The console command name and signature. * @@ -193,7 +190,7 @@ protected function compileStub(string $content) { return str_replace( '{{namespace}}', - $this->getAppNamespace(), + $this->laravel->getNamespace(), $content, ); } diff --git a/src/Core/Application.php b/src/Core/Application.php index ea257537..34c76eb4 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -741,6 +741,18 @@ public function runningInConsole() return php_sapi_name() == 'cli' || php_sapi_name() == 'phpdbg'; } + /** + * Get the maintenance mode manager. + * + * @return \Illuminate\Contracts\Foundation\MaintenanceMode + */ + public function maintenanceMode() + { + return new \Themosis\Core\Maintenance\WordPressMaintenanceMode( + $this->wordpressPath('.maintenance'), + ); + } + /** * Determine if the application is currently down for maintenance. * @@ -750,13 +762,11 @@ public function runningInConsole() */ public function isDownForMaintenance() { - $filePath = $this->wordpressPath('.maintenance'); - - if (function_exists('wp_installing') && ! file_exists($filePath)) { + if (function_exists('wp_installing') && ! $this->maintenanceMode()->active()) { return \wp_installing(); } - return file_exists($filePath); + return $this->maintenanceMode()->active(); } /** @@ -984,14 +994,14 @@ public function getCachedConfigPath() * * @param SymfonyRequest $request A Request instance * @param int $type The type of the request - * (one of HttpKernelInterface::MASTER_REQUEST or HttpKernelInterface::SUB_REQUEST) + * (one of HttpKernelInterface::MAIN_REQUEST or HttpKernelInterface::SUB_REQUEST) * @param bool $catch Whether to catch exceptions or not * * @throws \Exception When an Exception occurs during processing * * @return Response A Response instance */ - public function handle(SymfonyRequest $request, $type = self::MASTER_REQUEST, $catch = true) + public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response { return $this[HttpKernelContract::class]->handle(Request::createFromBase($request)); } @@ -1422,7 +1432,7 @@ public function abort($code, $message = '', array $headers = []) * * @return $this */ - public function terminating(Closure $callback) + public function terminating($callback) { $this->terminatingCallbacks[] = $callback; @@ -1576,6 +1586,29 @@ public function getLocale() return $this['config']->get('app.locale'); } + /** + * Get the application fallback locale. + * + * @return string + */ + public function getFallbackLocale() + { + return $this['config']->get('app.fallback_locale'); + } + + /** + * Set the application fallback locale. + * + * @param string $fallbackLocale + * + * @return void + */ + public function setFallbackLocale(string $fallbackLocale) + { + $this['config']->set('app.fallback_locale', $fallbackLocale); + $this['translator']->setFallback($fallbackLocale); + } + /** * Check if passed locale is current locale. * diff --git a/src/Core/Console/VendorPublishCommand.php b/src/Core/Console/VendorPublishCommand.php index 048346b4..379aab4c 100644 --- a/src/Core/Console/VendorPublishCommand.php +++ b/src/Core/Console/VendorPublishCommand.php @@ -6,7 +6,7 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Illuminate\Support\ServiceProvider; -use League\Flysystem\Adapter\Local; +use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\MountManager; class VendorPublishCommand extends Command @@ -195,14 +195,12 @@ protected function publishFile($from, $to) * * @param string $from * @param string $to - * - * @throws \League\Flysystem\FileNotFoundException */ protected function publishDirectory($from, $to) { $this->moveManagedFiles(new MountManager([ - 'from' => new \League\Flysystem\Filesystem(new Local($from)), - 'to' => new \League\Flysystem\Filesystem(new Local($to)), + 'from' => new \League\Flysystem\Filesystem(new LocalFilesystemAdapter($from)), + 'to' => new \League\Flysystem\Filesystem(new LocalFilesystemAdapter($to)), ])); $this->status($from, $to, 'Directory'); @@ -212,14 +210,14 @@ protected function publishDirectory($from, $to) * Move all the files in the given MountManager. * * @param \League\Flysystem\MountManager $manager - * - * @throws \League\Flysystem\FileNotFoundException */ protected function moveManagedFiles($manager) { foreach ($manager->listContents('from://', true) as $file) { - if ($file['type'] === 'file' && (! $manager->has('to://' . $file['path']) || $this->option('force'))) { - $manager->put('to://' . $file['path'], $manager->read('from://' . $file['path'])); + $toPath = preg_replace('{^from://}', 'to://', $file['path']); + + if ($file['type'] === 'file' && (! $manager->has($toPath) || $this->option('force'))) { + $manager->write($toPath, $manager->read($file['path'])); } } } diff --git a/src/Core/Maintenance/WordPressMaintenanceMode.php b/src/Core/Maintenance/WordPressMaintenanceMode.php new file mode 100644 index 00000000..2cc99fbf --- /dev/null +++ b/src/Core/Maintenance/WordPressMaintenanceMode.php @@ -0,0 +1,40 @@ +filePath, json_encode($payload)); + } + + public function deactivate(): void + { + if (file_exists($this->filePath)) { + unlink($this->filePath); + } + } + + public function active(): bool + { + return file_exists($this->filePath); + } + + public function data(): array + { + if (! file_exists($this->filePath)) { + return []; + } + + $data = json_decode(file_get_contents($this->filePath), true); + + return is_array($data) ? $data : []; + } +} diff --git a/tests/Core/ApplicationLifecycleTest.php b/tests/Core/ApplicationLifecycleTest.php new file mode 100644 index 00000000..5dcad6e4 --- /dev/null +++ b/tests/Core/ApplicationLifecycleTest.php @@ -0,0 +1,257 @@ +basePath = realpath(__DIR__ . '/../'); + } + + /** + * Default fixture: a fresh application rooted at the tests directory. + */ + private function makeApplication(): Application + { + $app = new Application($this->basePath); + + // The filesystem is normally bound by the FilesystemServiceProvider; the + // cache contracts (routesAreCached/eventsAreCached) resolve 'files' directly. + $app->instance('files', new Filesystem()); + + return $app; + } + + /** + * Resolve a bootstrap-relative cache path to its expected absolute form. + */ + private function expectedPath(string $relative): string + { + return $this->basePath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative); + } + + /* + |-------------------------------------------------------------------------- + | Boot lifecycle + |-------------------------------------------------------------------------- + */ + + public function testApplicationIsNotBootedByDefault() + { + $this->assertFalse($this->makeApplication()->isBooted()); + } + + public function testBootFiresBootingThenBootedCallbacksAndMarksApplicationBooted() + { + $app = $this->makeApplication(); + $sequence = []; + + $app->booting(function ($passed) use (&$sequence, $app) { + $sequence[] = 'booting'; + $this->assertSame($app, $passed, 'Booting callback should receive the application.'); + }); + $app->booted(function ($passed) use (&$sequence, $app) { + $sequence[] = 'booted'; + $this->assertSame($app, $passed, 'Booted callback should receive the application.'); + }); + + $app->boot(); + + $this->assertTrue($app->isBooted()); + $this->assertSame(['booting', 'booted'], $sequence); + } + + public function testBootOnlyRunsOnce() + { + $app = $this->makeApplication(); + $bootingCount = 0; + + $app->booting(function () use (&$bootingCount) { + $bootingCount++; + }); + + $app->boot(); + $app->boot(); + + $this->assertSame(1, $bootingCount); + } + + public function testBootedCallbackFiresImmediatelyWhenApplicationAlreadyBooted() + { + $app = $this->makeApplication(); + $app->boot(); + + $fired = false; + $app->booted(function () use (&$fired) { + $fired = true; + }); + + $this->assertTrue($fired); + } + + /* + |-------------------------------------------------------------------------- + | Caching contracts + |-------------------------------------------------------------------------- + */ + + public function testConfigurationIsNotCachedByDefault() + { + $this->assertFalse($this->makeApplication()->configurationIsCached()); + } + + public function testRoutesAreNotCachedByDefault() + { + $this->assertFalse($this->makeApplication()->routesAreCached()); + } + + public function testEventsAreNotCachedByDefault() + { + $this->assertFalse($this->makeApplication()->eventsAreCached()); + } + + public function testCachedConfigPathPointsToBootstrapCacheLocation() + { + $this->assertSame( + $this->expectedPath(self::CONFIG_CACHE_PATH), + $this->makeApplication()->getCachedConfigPath(), + ); + } + + public function testCachedRoutesPathPointsToBootstrapCacheLocation() + { + $this->assertSame( + $this->expectedPath(self::ROUTES_CACHE_PATH), + $this->makeApplication()->getCachedRoutesPath(), + ); + } + + public function testCachedEventsPathPointsToBootstrapCacheLocation() + { + $this->assertSame( + $this->expectedPath(self::EVENTS_CACHE_PATH), + $this->makeApplication()->getCachedEventsPath(), + ); + } + + /* + |-------------------------------------------------------------------------- + | WordPress hook registration + |-------------------------------------------------------------------------- + */ + + public function testRegisterHookCallsRegisterOnHookableWithoutBoundHooks() + { + HookableWithoutHooks::$registered = false; + + $this->makeApplication()->registerHook(HookableWithoutHooks::class); + + $this->assertTrue(HookableWithoutHooks::$registered); + } + + public function testRegisterHookIgnoresHookableWithoutRegisterMethod() + { + $this->assertNull( + $this->makeApplication()->registerHook(HookableWithoutRegister::class), + ); + } + + /* + |-------------------------------------------------------------------------- + | Console / unit-test context + |-------------------------------------------------------------------------- + */ + + public function testRunningInConsoleReturnsTrueWhenInCliSapi() + { + // PHPUnit executes via the CLI SAPI, so this is always true in this suite. + $this->assertTrue($this->makeApplication()->runningInConsole()); + } + + public function testRunningUnitTestsReturnsTrueWhenEnvironmentIsTesting() + { + $app = $this->makeApplication(); + $app->detectEnvironment(fn () => 'testing'); + + $this->assertTrue($app->runningUnitTests()); + } + + public function testRunningUnitTestsReturnsFalseForNonTestingEnvironment() + { + $app = $this->makeApplication(); + $app->detectEnvironment(fn () => 'production'); + + $this->assertFalse($app->runningUnitTests()); + } + + /* + |-------------------------------------------------------------------------- + | registerConfiguredProviders + |-------------------------------------------------------------------------- + */ + + public function testRegisterConfiguredProvidersLoadsProvidersFromConfig() + { + $app = $this->makeApplication(); + + $app->instance('config', new ConfigRepository([ + 'app' => ['providers' => [EventServiceProvider::class]], + ])); + + $manifest = $this->createMock(PackageManifest::class); + $manifest->method('providers')->willReturn([]); + $app->instance(PackageManifest::class, $manifest); + + $cacheDir = sys_get_temp_dir() . '/themosis_providers_' . uniqid(); + mkdir($cacheDir, 0755, true); + putenv("APP_SERVICES_CACHE={$cacheDir}/services.php"); + + try { + $app->registerConfiguredProviders(); + + $this->assertArrayHasKey(EventServiceProvider::class, $app->getLoadedProviders()); + } finally { + putenv('APP_SERVICES_CACHE'); + + if (file_exists("{$cacheDir}/services.php")) { + unlink("{$cacheDir}/services.php"); + } + + rmdir($cacheDir); + } + } +} + +class HookableWithoutHooks extends Hookable +{ + public static $registered = false; + + public function register() + { + static::$registered = true; + } +} + +class HookableWithoutRegister extends Hookable +{ +} diff --git a/tests/Core/Console/CommandsSmokeTest.php b/tests/Core/Console/CommandsSmokeTest.php new file mode 100644 index 00000000..52521e1b --- /dev/null +++ b/tests/Core/Console/CommandsSmokeTest.php @@ -0,0 +1,67 @@ +basePath = realpath(__DIR__ . '/../../'); + } + + private function makeApplication(): Application + { + $app = new Application($this->basePath); + $app->instance('files', new Filesystem()); + + return $app; + } + + /** + * @dataProvider commandProvider + */ + public function testCommandInstantiatesAndHasNameAndDescription( + string $class, + array $args, + ): void { + $command = new $class(...$args); + $command->setLaravel($this->makeApplication()); + + $this->assertInstanceOf(SymfonyCommand::class, $command); + $this->assertNotEmpty($command->getName(), "{$class} must have a non-empty name"); + $this->assertNotEmpty($command->getDescription(), "{$class} must have a non-empty description"); + } + + public static function commandProvider(): array + { + $files = new Filesystem(); + + return [ + 'ProviderMakeCommand (generator)' => [ProviderMakeCommand::class, [$files]], + 'VendorPublishCommand (publish)' => [VendorPublishCommand::class, [$files]], + 'EventMakeCommand (generator)' => [EventMakeCommand::class, [$files]], + 'StubPublishCommand (no-arg command)' => [StubPublishCommand::class, []], + 'KeyGenerateCommand (no-arg command)' => [KeyGenerateCommand::class, []], + 'EventCacheCommand (no-arg command)' => [EventCacheCommand::class, []], + 'ConfigCacheCommand (filesystem command)' => [ConfigCacheCommand::class, [$files]], + 'RouteCacheCommand (filesystem command)' => [RouteCacheCommand::class, [$files]], + ]; + } +} diff --git a/tests/Core/Console/VendorPublishCommandTest.php b/tests/Core/Console/VendorPublishCommandTest.php new file mode 100644 index 00000000..f1c54b32 --- /dev/null +++ b/tests/Core/Console/VendorPublishCommandTest.php @@ -0,0 +1,143 @@ +app = new Application(sys_get_temp_dir()); + $this->app->instance('files', new Filesystem()); + + $id = uniqid(); + $this->from = sys_get_temp_dir() . '/vp_from_' . $id; + $this->to = sys_get_temp_dir() . '/vp_to_' . $id; + } + + protected function tearDown(): void + { + ServiceProvider::$publishes = []; + ServiceProvider::$publishGroups = []; + + $this->deletePath($this->from); + $this->deletePath($this->to); + + parent::tearDown(); + } + + private function makeTester(): CommandTester + { + $command = new VendorPublishCommand(new Filesystem()); + $command->setLaravel($this->app); + + return new CommandTester($command); + } + + private function registerPaths(array $paths): void + { + ServiceProvider::$publishes[PublishStubServiceProvider::class] = $paths; + } + + public function testPublishesFileToDestination(): void + { + file_put_contents($this->from, 'hello'); + $dest = $this->to . '/file.txt'; + + $this->registerPaths([$this->from => $dest]); + + $this->makeTester()->execute(['--provider' => PublishStubServiceProvider::class]); + + $this->assertFileExists($dest); + $this->assertSame('hello', file_get_contents($dest)); + } + + public function testPublishesDirectoryRecursively(): void + { + mkdir($this->from . '/sub', 0755, true); + mkdir($this->to, 0755, true); + file_put_contents($this->from . '/top.txt', 'top'); + file_put_contents($this->from . '/sub/nested.txt', 'nested'); + + $this->registerPaths([$this->from => $this->to]); + + $this->makeTester()->execute(['--provider' => PublishStubServiceProvider::class]); + + $this->assertFileExists($this->to . '/top.txt'); + $this->assertFileExists($this->to . '/sub/nested.txt'); + $this->assertSame('top', file_get_contents($this->to . '/top.txt')); + $this->assertSame('nested', file_get_contents($this->to . '/sub/nested.txt')); + } + + public function testSkipsExistingFileWithoutForce(): void + { + mkdir($this->from, 0755, true); + mkdir($this->to, 0755, true); + file_put_contents($this->from . '/file.txt', 'new'); + file_put_contents($this->to . '/file.txt', 'original'); + + $this->registerPaths([$this->from => $this->to]); + + $this->makeTester()->execute(['--provider' => PublishStubServiceProvider::class]); + + $this->assertSame('original', file_get_contents($this->to . '/file.txt')); + } + + public function testOverwritesExistingFileWithForce(): void + { + mkdir($this->from, 0755, true); + mkdir($this->to, 0755, true); + file_put_contents($this->from . '/file.txt', 'new'); + file_put_contents($this->to . '/file.txt', 'original'); + + $this->registerPaths([$this->from => $this->to]); + + $this->makeTester()->execute([ + '--provider' => PublishStubServiceProvider::class, + '--force' => true, + ]); + + $this->assertSame('new', file_get_contents($this->to . '/file.txt')); + } + + private function deletePath(string $path): void + { + if (! file_exists($path)) { + return; + } + + if (is_file($path)) { + unlink($path); + + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($path); + } +} + +class PublishStubServiceProvider extends ServiceProvider +{ + public function register(): void {} +} diff --git a/tests/Core/ExceptionHandlerTest.php b/tests/Core/ExceptionHandlerTest.php index 35505fdb..acd0b5d7 100644 --- a/tests/Core/ExceptionHandlerTest.php +++ b/tests/Core/ExceptionHandlerTest.php @@ -2,20 +2,28 @@ namespace Themosis\Tests\Core; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\View\Factory; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Redirector; use Illuminate\Routing\ResponseFactory; +use Illuminate\Session\TokenMismatchException; +use Illuminate\Support\Facades\Facade; use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Themosis\Core\Exceptions\Handler; @@ -71,9 +79,18 @@ function () use ($viewFactory, $redirector) { }, ); + Facade::setFacadeApplication($this->container); + $this->handler = new Handler($this->container); } + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + parent::tearDown(); + } + public function testHandlerReportExceptionAsContext() { $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); @@ -218,8 +235,8 @@ public function testValidateFileMethod() $file = $this->createMock(UploadedFile::class); $file->method('getPathname')->willReturn('photo.jpg'); $file->method('getClientOriginalName')->willReturn('photo.jpg'); - $file->method('getClientMimeType')->willReturn(null); - $file->method('getError')->willReturn(null); + $file->method('getClientMimeType')->willReturn('image/jpeg'); + $file->method('getError')->willReturn(UPLOAD_ERR_OK); $request = Request::create('/', 'POST', $argumentExpected, [], ['photo' => $file]); @@ -233,6 +250,193 @@ public function testValidateFileMethod() $this->assertEquals($argumentExpected, $argumentActual); } + + /* + |-------------------------------------------------------------------------- + | prepareException mapping + |-------------------------------------------------------------------------- + */ + + /** + * @dataProvider exceptionMappingProvider + */ + public function testPrepareExceptionMapsToExpectedHttpStatus( + \Throwable $exception, + int $expectedStatus, + ): void { + $this->config->method('get')->willReturn(false); + $this->request->method('expectsJson')->willReturn(true); + + $response = $this->handler->render($this->request, $exception); + + $this->assertSame($expectedStatus, $response->getStatusCode()); + } + + public static function exceptionMappingProvider(): array + { + return [ + 'ModelNotFoundException maps to 404' => [new ModelNotFoundException(), 404], + 'AuthorizationException maps to 403' => [new AuthorizationException(), 403], + 'TokenMismatchException maps to 419' => [new TokenMismatchException(), 419], + 'SuspiciousOperationException maps to 404' => [new SuspiciousOperationException(), 404], + ]; + } + + /* + |-------------------------------------------------------------------------- + | unauthenticated + |-------------------------------------------------------------------------- + */ + + public function testUnauthenticatedWithJsonRequestReturns401(): void + { + $this->request->method('expectsJson')->willReturn(true); + + $response = $this->handler->render( + $this->request, + new AuthenticationException('Unauthenticated.'), + ); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertSame('Unauthenticated.', $content['message']); + } + + public function testUnauthenticatedWithNonJsonRequestRedirectsToGuestUrl(): void + { + $this->request->method('expectsJson')->willReturn(false); + + $redirectResponse = $this->createMock(RedirectResponse::class); + $redirector = $this->createMock(Redirector::class); + $redirector->expects($this->once()) + ->method('guest') + ->with('/login') + ->willReturn($redirectResponse); + $this->container->instance('redirect', $redirector); + + $response = $this->handler->render( + $this->request, + new AuthenticationException('Unauthenticated.', [], '/login'), + ); + + $this->assertInstanceOf(RedirectResponse::class, $response); + } + + /* + |-------------------------------------------------------------------------- + | invalidJson + |-------------------------------------------------------------------------- + */ + + public function testValidationExceptionWithJsonRequestReturns422WithErrorsAndMessage(): void + { + $this->request->method('expectsJson')->willReturn(true); + + $validator = $this->createMock(Validator::class); + $validator->method('errors')->willReturn(new MessageBag(['name' => ['Required.']])); + + $response = $this->handler->render( + $this->request, + new ValidationException($validator), + ); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(422, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('message', $content); + $this->assertArrayHasKey('errors', $content); + } + + /* + |-------------------------------------------------------------------------- + | Registration API — renderable / reportable / map / ignore + |-------------------------------------------------------------------------- + */ + + public function testRenderableCallbackIsHonoredDuringRender(): void + { + $called = false; + $this->handler->renderable(function (\RuntimeException $e, $request) use (&$called) { + $called = true; + + return new JsonResponse(['custom' => true]); + }); + + $response = $this->handler->render($this->request, new \RuntimeException('test')); + + $this->assertTrue($called); + $content = json_decode($response->getContent(), true); + $this->assertSame(true, $content['custom']); + } + + public function testReportableCallbackFiresOnReport(): void + { + $fired = false; + $this->handler->reportable(function (\RuntimeException $e) use (&$fired) { + $fired = true; + }); + + $logger = $this->createMock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + + $this->handler->report(new \RuntimeException('test')); + + $this->assertTrue($fired); + } + + public function testMapConvertsExceptionTypeForRendering(): void + { + $this->config->method('get')->willReturn(false); + $this->request->method('expectsJson')->willReturn(true); + + $this->handler->map(\RuntimeException::class, ModelNotFoundException::class); + + $response = $this->handler->render($this->request, new \RuntimeException('test')); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testIgnoredExceptionIsNotReported(): void + { + $handler = new HandlerThatIgnoresRuntimeException($this->container); + + $this->assertFalse($handler->shouldReport(new \RuntimeException('test'))); + } + + /* + |-------------------------------------------------------------------------- + | Non-JSON prepareResponse + |-------------------------------------------------------------------------- + */ + + public function testNonJsonRequestReturnsSymfonyResponseForGenericException(): void + { + $this->config->method('get') + ->willReturnMap([ + ['app.debug', null, false], + ['view.paths', null, null], + ]); + $this->request->method('expectsJson')->willReturn(false); + + $viewFactory = $this->getMockBuilder(Factory::class)->getMock(); + $viewFactory->method('exists')->willReturn(false); + $this->container->instance('view', $viewFactory); + $this->container->instance(Factory::class, $viewFactory); + + $response = $this->handler->render($this->request, new \RuntimeException('test')); + + $this->assertInstanceOf(SymfonyResponse::class, $response); + $this->assertSame(500, $response->getStatusCode()); + } +} + +class HandlerThatIgnoresRuntimeException extends Handler +{ + public function register() + { + $this->ignore(\RuntimeException::class); + } } class CustomException extends \Exception implements Responsable diff --git a/tests/Route/RoutesTest.php b/tests/Route/RoutesTest.php index 6abf7eb3..9a2dd102 100644 --- a/tests/Route/RoutesTest.php +++ b/tests/Route/RoutesTest.php @@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Themosis\Route\Middleware\WordPressBindings; +use Themosis\Route\Route; use Themosis\Route\Router; class RoutesTest extends TestCase @@ -699,9 +700,88 @@ protected function getRouter() $container->singleton(Registrar::class, function () use ($router) { return $router; }); + $container->singleton( + \Illuminate\Routing\Contracts\CallableDispatcher::class, + fn ($app) => new \Illuminate\Routing\CallableDispatcher($app), + ); + $container->singleton( + \Illuminate\Routing\Contracts\ControllerDispatcher::class, + fn ($app) => new \Illuminate\Routing\ControllerDispatcher($app), + ); return $router; } + + /* + |-------------------------------------------------------------------------- + | Themosis Route override — condition parsing and WordPress matching + |-------------------------------------------------------------------------- + */ + + public function testSetConditionsParsesUriToWordPressFunctionName() + { + $router = $this->getWordPressRouter(); + $route = $router->get('home', function () { + return 'hello'; + }); + + $this->assertSame('is_home', $route->getCondition()); + } + + public function testGetConditionsReturnsTheRegisteredConditionsMap() + { + $router = $this->getWordPressRouter(); + $route = $router->get('home', function () { + return 'hello'; + }); + + $this->assertArrayHasKey('is_home', $route->getConditions()); + $this->assertContains('home', $route->getConditions()['is_home']); + } + + public function testRouteHasNoConditionWhenUriIsNotInConditionsMap() + { + $router = $this->getWordPressRouter(); + $route = $router->get('not-a-wp-condition', function () { + return 'hello'; + }); + + $this->assertSame('', $route->getCondition()); + $this->assertFalse($route->hasCondition()); + } + + public function testRouteMatchesTrueWhenWordPressConditionIsTrue() + { + // is_home() always returns true in the test stub (tests/functions.php) + $router = $this->getWordPressRouter(); + $route = $router->get('home', function () { + return 'hello'; + }); + + $this->assertTrue($route->matches(Request::create('/', 'GET'))); + } + + public function testAddWordPressBindingsSetsPostAndWpQueryOnRoute() + { + $router = $this->getWordPressRouter(); + $route = $router->get('home', function () { + return 'hello'; + }); + + // Bind the route so parameters can be set. + $route->bind(Request::create('/', 'GET')); + + global $post, $wp_query; + $post = null; + $wpQuery = new stdClass(); + $wp_query = $wpQuery; + + $router->addWordPressBindings($route); + + // WP_Post does not exist in test bootstrap, so null post stays null. + $this->assertNull($route->parameter('post')); + $this->assertSame($wpQuery, $route->parameter('wp_query')); + } } class FooController extends Controller diff --git a/tests/View/FileViewFinderTest.php b/tests/View/FileViewFinderTest.php index 88c8be75..a1d720d2 100644 --- a/tests/View/FileViewFinderTest.php +++ b/tests/View/FileViewFinderTest.php @@ -21,4 +21,39 @@ public function test_add_view_location_with_priority() $this->assertEquals('first/resources', $finder->getPaths()[0]); $this->assertEquals('child/resources', $finder->getPaths()[1]); } + + public function testFindLocatesViewInRegisteredPath() + { + $dir = sys_get_temp_dir() . '/themosis_views_' . uniqid(); + mkdir($dir, 0755, true); + file_put_contents("{$dir}/hello.blade.php", ''); + + try { + $finder = new FileViewFinder(new Filesystem(), [$dir]); + $path = $finder->find('hello'); + + $this->assertStringContainsString('hello.blade.php', $path); + } finally { + unlink("{$dir}/hello.blade.php"); + rmdir($dir); + } + } + + public function testFindReturnsNamespacedViewFromRegisteredNamespace() + { + $dir = sys_get_temp_dir() . '/themosis_views_' . uniqid(); + mkdir($dir, 0755, true); + file_put_contents("{$dir}/greeting.blade.php", ''); + + try { + $finder = new FileViewFinder(new Filesystem(), []); + $finder->addNamespace('vendor', $dir); + $path = $finder->find('vendor::greeting'); + + $this->assertStringContainsString('greeting.blade.php', $path); + } finally { + unlink("{$dir}/greeting.blade.php"); + rmdir($dir); + } + } } diff --git a/tests/View/ViewServiceProviderTest.php b/tests/View/ViewServiceProviderTest.php new file mode 100644 index 00000000..07b72d98 --- /dev/null +++ b/tests/View/ViewServiceProviderTest.php @@ -0,0 +1,51 @@ +instance('files', new Filesystem()); + + return $app; + } + + public function testViewServiceProviderBindsViewFinderSingleton() + { + $app = $this->makeApplication(); + $app->instance('config', new ConfigRepository([ + 'view' => ['paths' => [__DIR__]], + ])); + + $provider = new ViewServiceProvider($app); + $provider->registerViewFinder(); + + $this->assertInstanceOf(FileViewFinder::class, $app->make('view.finder')); + } + + public function testViewFinderResolvedFromContainerIsSingleton() + { + $app = $this->makeApplication(); + $app->instance('config', new ConfigRepository([ + 'view' => ['paths' => [__DIR__]], + ])); + + $provider = new ViewServiceProvider($app); + $provider->registerViewFinder(); + + $finderA = $app->make('view.finder'); + $finderB = $app->make('view.finder'); + + $this->assertSame($finderA, $finderB); + } +}