diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d70725d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +/.github export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e191f4f..035f627 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,31 +2,35 @@ name: Tests on: push: - branches: [master, 2.x] + branches: [master, 3.x] pull_request: - branches: [master, 2.x] + branches: [master, 3.x] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] - laravel: ['10.*', '11.*', '12.*', '13.*'] - exclude: - - { php: '8.1', laravel: '11.*' } - - { php: '8.1', laravel: '12.*' } - - { php: '8.1', laravel: '13.*' } - - { php: '8.2', laravel: '13.*' } - - { php: '8.5', laravel: '10.*' } - - { php: '8.5', laravel: '11.*' } - - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + os: [ubuntu-latest] + php: ['8.3', '8.4', '8.5'] + dependencies: [stable] + experimental: [false] + include: + - { os: ubuntu-latest, php: '8.3', dependencies: lowest, experimental: false } + - { os: windows-latest, php: '8.4', dependencies: stable, experimental: false } + - { os: ubuntu-latest, php: '8.6', dependencies: stable, experimental: true } + + name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}${{ matrix.os == 'windows-latest' && ' - Windows' || '' }} steps: - name: Checkout code @@ -38,34 +42,20 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, json + extensions: dom, curl, fileinfo, libxml, mbstring, zip, json, pdo_sqlite coverage: none - # Laravel 10 and 11 are no longer supported and get no security fixes, so the advisories block them. - - name: Allow unsupported Laravel versions - if: matrix.laravel == '10.*' || matrix.laravel == '11.*' - run: composer remove --dev roave/security-advisories --no-update --no-interaction - - # PHP 8.1 is no longer supported either: the test tools that still run on it have advisories, - # and the later releases of the snapshot plugin that allow it are broken. - - name: Use the last test tools for PHP 8.1 - if: matrix.php == '8.1' - run: | - composer config audit.block-insecure false - composer require --dev "spatie/pest-plugin-snapshots:2.2.1" "spatie/phpunit-snapshot-assertions:5.2.3" --no-update --no-interaction - + # Packages declare the PHP versions they support, and a PHP release in development is newer than all of them. - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update - composer update --prefer-dist --no-interaction + run: composer update --prefer-dist --no-interaction ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} ${{ matrix.php == '8.6' && '--ignore-platform-req=php+' || '' }} - name: Run tests - run: composer test + run: vendor/bin/pest --parallel --order-by=random - static-analysis: + coverage: runs-on: ubuntu-latest - name: Static Analysis + name: Coverage steps: - name: Checkout code @@ -76,23 +66,23 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: - php-version: '8.3' - extensions: dom, curl, libxml, mbstring, zip, json - coverage: none + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, json, pdo_sqlite + coverage: pcov - name: Install dependencies run: composer update --prefer-dist --no-interaction - - name: Run PHPStan - run: composer analyse + - name: Check the coverage + run: vendor/bin/pest --coverage --min=95 - - name: Audit dependencies - run: composer audit + - name: Check the mutation score + run: vendor/bin/pest --mutate --everything --covered-only --parallel --min=85 - code-style: + static-analysis: runs-on: ubuntu-latest - name: Code Style + name: Static Analysis steps: - name: Checkout code @@ -110,5 +100,11 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-interaction + - name: Run PHPStan + run: composer analyse + - name: Check code style - run: ./vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes + run: composer lint + + - name: Audit dependencies + run: composer audit diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php deleted file mode 100644 index 3012836..0000000 --- a/.php-cs-fixer.dist.php +++ /dev/null @@ -1,39 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->exclude([ - '__snapshots__', - '.pest', - ]) - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..55ad3c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## 3.0.0 - 2026-09-24 + +Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. + +### Added + +- `--module` for every generator: the Laravel `make:*` commands, `make:migration`, the table migration generators + and the generators of other packages that extend `GeneratorCommand` (`make:action`, `make:data`, ...). +- Module resources are loaded by convention: config, translations (JSON included), views, class and anonymous + Blade components, migrations, factories, routes and commands. Modules no longer need their own service + provider. +- Tinker aliases the module classes. +- Route groups in `config/modules.php`; nested route directories are appended to the group prefix. +- The `generators` config maps generator commands to namespaces inside the modules. +- `Modules::seeders()` returns the seeders of all modules, in the order of their `_N` suffix. +- Module templates in `stubs/module/` with `{{ variable|filter }}` placeholders in paths and contents. +- `module:doctor` checks the modules and how they are installed; `module:list -v` shows what each module loads. +- `Modules::all()`, `Modules::find()` and `Modules::get()` find modules by their directory name. +- The `modules-config` and `modules-stubs` publish tags. +- `--no-update` for `module:make`, `module:sync` and `module:delete`: edit `composer.json` and print the + Composer command instead of running it. +- `module:sync` adds the `autoload-dev` of the module tests and updates only the modules that changed. +- Packagist mirrors defined in the project get the exclusion of the module vendor too: repositories with a + `packagist.org` URL, repositories under the `packagist` or `packagist.org` key, and list entries named + `packagist` or `packagist.org`. +- The module manifest is cached by `php artisan optimize` and cleared by `optimize:clear`; `MODULES_CACHE` + changes the cache path. +- The modules path is added to `octane.watch`. +- The `about` command shows the modules. + +### Changed + +- Requires PHP 8.3 and Laravel 13.12. +- The facade is `Laraneat\Modules\Facades\Modules`; modules are identified by their directory name. +- The four providers of `Laraneat\Modules\Providers` are replaced by `Laraneat\Modules\ModulesServiceProvider`, + and the config publish tag `config` by `modules-config`. +- `Laraneat\Modules\Module` is a read-only value object with public properties. +- The views and translations namespace of a module is its directory name. Every `config/*.php` file of a + module is merged, not only `config/.php`. +- Route files of a directory are loaded before its subdirectories. +- An invalid module `composer.json` fails the application boot, and two modules can not share a namespace. + 2.x skipped a module without a name. +- `module:make` requires new modules with `*@dev` and has no `--force` option. +- `ModuleNotFound` is created with `ModuleNotFound::named()`; every exception implements `ModulesException`. +- `module:delete` deletes one module, found by its directory name, asks for confirmation and needs `--force` + in non-interactive mode. +- The manifest cache is written only by `php artisan optimize` and `module:cache`, not automatically in + production, to `bootstrap/cache/modules.php` instead of `bootstrap/cache/laraneat-modules.php`. +- `Laraneat\Modules\ModulesRepository` is renamed to `Laraneat\Modules\ModuleRepository`. +- The `composer.vendor` config key is renamed to `vendor`. +- Migrations are registered only in the console. + +### Removed + +- The `module:make:*` component generators and their stubs, `module:stub:publish` and the `custom_stubs` config. +- `module:migrate` and the `module:migrate:*` commands: use `migrate` and the `migrate:*` commands. +- `Laraneat\Modules\Support\ModuleServiceProvider`, `CanLoadRoutesFromDirectory` and `CanRunModuleSeeders`. +- The methods of `ModulesRepository` and the getters, macros and `toArray()` of `Module`: UPGRADE.md maps them + to 3.0. +- `Laraneat\Modules\Enums`, `Laraneat\Modules\Support\Generator`, `Support\Composer`, `Support\ComposerJsonFile`, + `Support\ModuleConfigWriter`, `Support\Migrations` and the 2.x exceptions except `ModuleNotFound`. +- `module:make --preset=plain|base|api` and `--entity`: module templates replace them. +- `InteractsWithTestUser` and `WithJsonResponseHelpers`: UPGRADE.md has versions to copy into the application. +- The `components`, `composer.author`, `user_model`, `create_permission` and `cache` config keys. +- The `composer/composer` dependency. diff --git a/LICENSE b/LICENSE index 2225be4..d595b92 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2025 Salavat Salakhutdinov +Copyright (c) 2021 Salakhutdinov Salavat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index caac0d3..5bf88d7 100644 --- a/README.md +++ b/README.md @@ -1,883 +1,611 @@ # Laraneat Modules -A Laravel package that provides a powerful modular architecture system for organizing large-scale applications into self-contained, reusable modules. +Modular monolith infrastructure for Laravel. Every module is a Composer path package in `modules/`; +one service provider loads its config, routes, views, translations, migrations and commands by convention. +Any `make:*` command can generate into a module with `--module`. -## Table of Contents - -- [Overview](#overview) -- [Performance Comparison](#performance-comparison-with-nwidartlaravel-modules) -- [Architecture Diagram](#architecture-diagram) +```bash +php artisan module:make blog +php artisan make:model Post --module=blog -mfs +php artisan make:controller PostController --module=blog --api +``` + +## Why + +In a Laravel application the code is grouped by type: one feature is spread over `app/Models`, +`app/Http/Controllers`, `database/migrations`, `routes/api.php` and `resources/views`, next to every other +feature. A modular monolith groups the code by feature instead and is still one application with one deploy. +Laravel has no built-in support for it: something has to autoload every module, load its routes, views, +translations, migrations and commands, and make the generators write into it. This package does that, and +stays out of the module code: + +- **Modules are plain Composer packages.** A module needs only a `composer.json` with its autoload and + dependencies. Its code does not use this package: no base classes, no `module.json`, no required service + provider. It gets a provider only when it has bindings, event listeners or gates to register. +- **Laravel-native.** Modules use the Laravel generators, stubs and conventions (`Models`, `Http\Controllers`, ...) + unless you map them elsewhere. `migrate`, `route:cache`, `optimize`, Tinker and Octane work as usual. +- **Fast.** The package scans the modules once and caches the result with `php artisan optimize`. A cached + application does no filesystem scans, and HTTP requests never run the console-only parts. +- **Safe with Composer.** Modules are installed as symlinked path packages; their vendor is excluded from + Packagist, so a missing module cannot be replaced by a public package with the same name. + `module:doctor` checks the whole setup. + +### Compared to other packages + +As of September 2026: + +| | Laraneat Modules | [InterNACHI/modular](https://github.com/InterNACHI/modular) | [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules) | +|---------------------------|---------------------------------------------------------------|--------------------------------------------|------------------------------------------------| +| Laravel | 13.12+ | 11–13 | 13 (older majors: 5.4–12) | +| A module is | a Composer path package | a Composer path package | a directory with `module.json`; its `composer.json` is merged by `composer-merge-plugin` | +| Service provider per module | not needed | not needed | generated; extends a provider class of the package and loads the routes, views and config of the module | +| Generators | `--module` on every `GeneratorCommand`, of Laravel and of other packages | `--module` on a list of Laravel generators | own `module:make-*` commands and stubs | +| Routes | route groups with a prefix and middleware from the config | `routes/*.php` loaded as they are | route service provider of the module | +| Module config files | merged | not loaded | loaded by the module provider | +| Composer | `module:make`, `module:sync` and `module:delete` run it; Packagist exclusion | the application `composer.json` is edited, you run Composer | `composer-merge-plugin` | +| Event discovery | no | yes | yes, in the generated event provider | +| Enable and disable modules | no | no | yes | + +Choose nWidart/laravel-modules to enable and disable modules or to build module assets with their own Vite +config, and InterNACHI/modular on Laravel 11 or 12. + +## Contents + +- [Why](#why) +- [Requirements](#requirements) - [Installation](#installation) -- [Quick Start](#quick-start) -- [Module Structure](#module-structure) -- [Core Concepts](#core-concepts) +- [Quick start](#quick-start) +- [Modules](#modules) +- [Conventions](#conventions) +- [Routes](#routes) +- [Generators](#generators) +- [Module service providers](#module-service-providers) +- [Seeders](#seeders) +- [Tests](#tests) +- [Commands](#commands) - [Configuration](#configuration) -- [Artisan Commands](#artisan-commands) -- [Component Types](#component-types) -- [Module Presets](#module-presets) -- [Best Practices](#best-practices) - -## Overview - -Laraneat Modules helps you build maintainable, scalable Laravel applications. Inspired by the [Porto SAP (Software Architectural Pattern)](https://github.com/Mahmoudz/Porto), it encourages organizing code by business domains (modules) instead of technical layers. - -### Why Modular Architecture? - -| Traditional Laravel | Modular Approach | -|---------------------|------------------| -| All controllers in `app/Http/Controllers` | Each module has its own controllers | -| All models in `app/Models` | Each module has its own models | -| Coupled, hard to maintain | Decoupled, easy to maintain | -| Difficult to reuse | Easy to extract and reuse | -| Complex routing | Module-scoped routing | - -## Performance Comparison with nWidart/laravel-modules - -This package is designed with performance in mind. **With caching enabled, it adds virtually zero overhead** — the cached manifest is a simple PHP array that loads in microseconds, and all core services use lazy loading. - -Here's how it compares to the popular [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules): - -### Key Differences - -| Feature | Laraneat Modules | nWidart/laravel-modules | -|---------|------------------|-------------------------| -| **Module manifest** | `composer.json` only | `module.json` + `composer.json` | -| **Cache type** | Persistent file cache | In-memory only (per request) | -| **Service providers** | DeferrableProvider (lazy) | Eager loading | -| **Enable/disable modules** | Not supported | Supported via JSON file | -| **Architecture pattern** | Domain-driven (Porto-inspired) | Flexible structure | - -### Performance Impact +- [Performance and caching](#performance-and-caching) +- [Octane](#octane) +- [Upgrading](#upgrading) -#### Production (with cache enabled) +## Requirements -| Metric | Laraneat | nWidart | -|--------|----------|---------| -| File operations (first request) | 1 (cached manifest) | N (module.json × modules) | -| File operations (subsequent) | 1 | N | -| Providers loaded | On-demand | All modules | +- PHP 8.3 or newer +- Laravel 13.12 or newer +- Composer 2 -#### Development (without cache) +## Installation -Both packages scan the filesystem on each request. However, Laraneat uses `DeferrableProvider`, so the `ModulesRepository` is only instantiated when actually needed. +```bash +composer require laraneat/modules +``` -### Why Laraneat is Faster +The service provider and the `Modules` facade are registered by package discovery. Publish the config +when you want to change the defaults: -1. **Persistent manifest cache** — Module metadata is cached to `bootstrap/cache/laraneat-modules.php`, eliminating filesystem scans in production. +```bash +php artisan vendor:publish --tag=modules-config +``` -2. **DeferrableProvider** — Core services (`ModulesRepository`, `Composer`, console commands) implement Laravel's `DeferrableProvider` interface, loading only when requested. +## Quick start -3. **Single manifest file** — Uses existing `composer.json` instead of requiring an additional `module.json` per module. +Create a module, a model with its migration, factory and seeder, a controller and a test: -4. **No status file I/O** — No `modules_statuses.json` reads on every request (unlike nWidart's enabled/disabled feature). +```bash +php artisan module:make blog +php artisan make:model Post --module=blog -mfs +php artisan make:controller PostController --module=blog --api --model=Post +php artisan make:test PostTest --module=blog +``` -### Recommendations +Add a route file, then migrate: ```php -// config/modules.php - Enable cache in production -'cache' => [ - 'enabled' => env('APP_ENV') === 'production', -], +// modules/blog/routes/api/posts.php +use Illuminate\Support\Facades\Route; +use Modules\Blog\Http\Controllers\PostController; + +Route::apiResource('posts', PostController::class); // /api/posts ``` -After deployment: ```bash -php artisan module:cache +php artisan migrate ``` -For development with many modules, consider enabling cache manually to avoid repeated filesystem scans. - -## Architecture Diagram +The module now looks like this, and nothing in it depends on this package: -``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ LARAVEL APPLICATION │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ ModulesRepository │ │ -│ │ - Discovers modules by scanning configured paths │ │ -│ │ - Manages module manifest (cached in production) │ │ -│ │ - Provides find, filter, delete operations │ │ -│ └────────────────────────────────────┬───────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────┼─────────────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ -│ │ MODULE: Users │ │ MODULE: Articles │ │ MODULE: Orders │ │ -│ │ │ │ │ │ │ │ -│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ -│ │ │ composer.json │ │ │ │ composer.json │ │ │ │ composer.json │ │ │ -│ │ │ - providers │ │ │ │ - providers │ │ │ │ - providers │ │ │ -│ │ │ - aliases │ │ │ │ - aliases │ │ │ │ - aliases │ │ │ -│ │ │ - namespace │ │ │ │ - namespace │ │ │ │ - namespace │ │ │ -│ │ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │ │ -│ │ │ │ │ │ │ │ -│ │ src/ │ │ src/ │ │ src/ │ │ -│ │ ├── Actions/ │ │ ├── Actions/ │ │ ├── Actions/ │ │ -│ │ ├── Models/ │ │ ├── Models/ │ │ ├── Models/ │ │ -│ │ ├── Providers/ │ │ ├── Providers/ │ │ ├── Providers/ │ │ -│ │ └── UI/ │ │ └── UI/ │ │ └── UI/ │ │ -│ │ ├── API/ │ │ ├── API/ │ │ ├── API/ │ │ -│ │ ├── WEB/ │ │ ├── WEB/ │ │ ├── WEB/ │ │ -│ │ └── CLI/ │ │ └── CLI/ │ │ └── CLI/ │ │ -│ │ │ │ │ │ │ │ -│ │ database/ │ │ database/ │ │ database/ │ │ -│ │ └── migrations/ │ │ └── migrations/ │ │ └── migrations/ │ │ -│ │ │ │ │ │ │ │ -│ │ tests/ │ │ tests/ │ │ tests/ │ │ -│ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ +```text +modules/blog/ +├── composer.json app/blog, namespace Modules\Blog +├── database/ +│ ├── factories/PostFactory.php +│ ├── migrations/2026_09_24_120000_create_posts_table.php +│ └── seeders/PostSeeder.php +├── routes/api/posts.php loaded with the "api" prefix and middleware +├── src/ +│ ├── Http/Controllers/PostController.php +│ └── Models/Post.php +└── tests/Feature/PostTest.php ``` -### Module Internal Architecture - -``` -┌───────────────────────────────────────────────────────────┐ -│ MODULE │ -├───────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────── UI LAYER ──────────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ API │ │ WEB │ │ CLI │ │ │ -│ │ │ Controllers │ │ Controllers │ │ Commands │ │ │ -│ │ │ Requests │ │ Requests │ │ │ │ │ -│ │ │ Resources │ │ Views │ │ │ │ │ -│ │ │ Routes │ │ Routes │ │ │ │ │ -│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ -│ │ │ │ │ │ │ -│ └─────────┼────────────────┼────────────────┼─────────┘ │ -│ │ │ │ │ -│ └────────────────┼────────────────┘ │ -│ ▼ │ -│ ┌─────────────────── DOMAIN LAYER ────────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Actions │ │ DTOs │ │ Events │ │ │ -│ │ │ (Business │ │ (Data │ │ (Domain │ │ │ -│ │ │ Logic) │ │ Transfer) │ │ Events) │ │ │ -│ │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Models │ │ Observers │ │ Rules │ │ │ -│ │ │ (Entities) │ │ │ │ (Validation)│ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────── INFRASTRUCTURE LAYER ────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Providers │ │ Middleware │ │ Policies │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Mails │ │Notifications│ │ Jobs │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────── DATABASE LAYER ───────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Migrations │ │ Seeders │ │ Factories │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────┘ -``` +Next steps: add the module tests to `phpunit.xml` ([Tests](#tests)), call `Modules::seeders()` from the +`DatabaseSeeder` ([Seeders](#seeders)), and cache the modules on deploy with `php artisan optimize`. -### Request Flow Through Module +## Modules -Actions serve as controllers using the `lorisleiva/laravel-actions` package. Each Action has two entry points: -- `handle()` - Core business logic (can be called from anywhere) -- `asController()` - HTTP entry point (receives Request, returns Response) +A module is a directory in `modules/` with a `composer.json`: -``` -┌──────────┐ ┌───────────┐ ┌────────────┐ -│ HTTP │────▶│ Routes │────▶│ Middleware │ -│ Request │ │ │ │ │ -└──────────┘ └───────────┘ └─────┬──────┘ - │ - ┌───────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ACTION │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ asController(Request $request) ◀── HTTP Entry │ │ -│ │ │ │ │ -│ │ ├── $request->toDTO() ─────────▶ DTO │ │ -│ │ │ │ │ -│ │ └── $this->handle($dto) │ │ -│ └─────────────────────┬───────────────────────────────────┘ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ handle(DTO $dto) ◀── Business Logic │ │ -│ │ │ │ │ -│ │ └── Model::create($dto->all()) ──▶ Database │ │ -│ └─────────────────────┬───────────────────────────────────┘ │ -│ │ │ -└────────────────────────┼────────────────────────────────────────┘ - ▼ - ┌────────────────┐ ┌─────────────┐ - │ Resource │────▶│ HTTP │ - │ (Format) │ │ Response │ - └────────────────┘ └─────────────┘ +```json +{ + "name": "app/blog", + "autoload": { + "psr-4": { + "Modules\\Blog\\": "src/", + "Modules\\Blog\\Database\\Factories\\": "database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "tests/" + } + } +} ``` -**Example Action:** +- The directory name (`blog`) is the module name. It is also the namespace of the module views, + translations and Blade components: `view('blog::index')`, `__('blog::messages.welcome')`, ``. +- The first `autoload.psr-4` entry is the root namespace of the module. +- Every module must have a valid `composer.json` with a unique package name and namespace. An invalid + module is an error while the application boots, for HTTP requests too. Artisan commands and the tests + fail the same way, so it is caught before a deploy. -```php -class CreatePostAction -{ - use AsAction; +The application requires every module like any other package. You do not have to write this by hand: +`module:make` and `module:sync` maintain it. - // Business logic - can be called from anywhere - public function handle(CreatePostDTO $dto): Post - { - return Post::create($dto->all()); +```json +{ + "require": { + "app/blog": "*@dev" + }, + "repositories": [ + {"type": "path", "url": "modules/*", "options": {"symlink": true}}, + {"type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"]} + ], + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "modules/blog/tests/" + } } +} +``` - // HTTP entry point - acts as controller - public function asController(CreatePostRequest $request): JsonResponse - { - $post = $this->handle($request->toDTO()); +- `*@dev` accepts the `dev-*` version of a path package with `"minimum-stability": "stable"`. +- The Packagist repository excludes the vendor of the modules, so Composer fails instead of installing + a public package when a module directory is missing (another branch, a partial checkout). Composer uses + this repository in place of the default one. Pick a vendor that you do not publish public packages under. +- Packagist mirrors defined in the project get the exclusion too: a repository with a `packagist.org` URL, + a mirror under the `packagist` or `packagist.org` key of `"repositories": {...}`, and a list entry named + `packagist` or `packagist.org`, which `composer config repo.packagist composer ` writes. Exclude the + vendor by hand in any other repository that proxies Packagist. +- Mirrors of the global Composer config (`~/.composer/config.json`) are not edited, so define your mirror in + the project `composer.json`. A global mirror under the `packagist.org` key is replaced by the Packagist + entry of the project and is no longer used. A global mirror that Composer 2.10 wrote as a named list entry + is still used, without the exclusion. - return (new PostResource($post))->created(); +### Dependencies between modules + +A module declares what it uses in its `composer.json`, other modules included: + +```json +{ + "name": "app/blog", + "require": { + "app/users": "*" } } ``` -## Installation +- Use `*`, not `*@dev`: Composer reads stability flags only from the application `composer.json`, which + already requires every module with `*@dev`. +- After a change of `require`, run `php artisan module:sync`: it runs `composer update` for the module. +- `module:delete` fails while another module requires the module, and nothing is deleted. With `--no-update` + Composer does not run, so this is not checked. -```bash -composer require laraneat/modules -``` +### Finding modules -Publish the configuration file: +The `Modules` facade finds modules by their directory name: -```bash -php artisan vendor:publish --provider="Laraneat\Modules\Providers\ModulesServiceProvider" +```php +use Laraneat\Modules\Facades\Modules; + +Modules::all(); // array of Module, keyed by name +Modules::find('blog'); // Module or null +Modules::get('blog'); // Module, or throws ModuleNotFound ``` -## Quick Start +`Laraneat\Modules\Module` is a read-only object with the `name` (`shop-order`), `package` (`app/shop-order`), +`namespace` (`Modules\ShopOrder`), `path` and `sourcePath` (the absolute directory of the root namespace) +properties. -### 1. Create Your First Module +### Creating a module ```bash -php artisan module:make Blog +php artisan module:make blog +php artisan module:make shop-order --preset=api +php artisan module:make blog --no-update ``` -This creates a new module at `modules/blog/` with basic structure. +`module:make`: -### 2. Create Module with Full API +1. renders the module template into `modules/blog`; +2. adds the module to the `composer.json` of the application: the path repository, the requirement, + the Packagist exclusion, and the `autoload-dev` entries of the module tests; +3. runs `composer update app/blog`. With `--no-update` it only prints the command, which is useful in CI + or when an agent drives the work. -```bash -php artisan module:make Blog --preset=api --entity=Post -``` +The name is normalized to kebab case: `ShopOrder` becomes `shop-order` (namespace `Modules\ShopOrder`), `API` +becomes `api`. A name in kebab case is kept as it is. -This creates a complete REST API module with: -- Controllers for CRUD operations -- Request validation classes -- API resources for JSON responses -- Database migrations and seeders -- Complete test suite +### Module templates -### 3. Generate Components +The built-in template has a `composer.json` and empty convention directories. Your own templates live +in `stubs/module/`: `--preset=api` uses `stubs/module/api`, and `stubs/module/default` replaces the +built-in template. Publish the built-in template to `stubs/module/default`, and copy it to +`stubs/module/` to start a named preset: ```bash -# Create a model -php artisan module:make:model Post app/blog - -# Create a controller -php artisan module:make:controller PostController app/blog --ui=api +php artisan vendor:publish --tag=modules-stubs +cp -R stubs/module/default stubs/module/api +``` + +- Files ending in `.stub` are rendered and lose the extension. Other files are copied as they are. +- Placeholders work in the contents of `.stub` files and in all paths: `{{ variable }}` or + `{{ variable|filter|filter }}`. +- Variables: + + | Variable | Example value | + |-------------|-------------------------------------------| + | `name` | `article-category` | + | `namespace` | `Modules\ArticleCategory` | + | `package` | `app/article-category` | + | `vendor` | `app` | + | `date` | `2026_09_23_120000` (for migration names) | + +- Filters: `studly`, `camel`, `snake`, `kebab`, `plural`, `singular`, `lower`, `upper`, `title`, and `json` + (escapes backslashes for JSON strings). They apply from left to right: `{{ name|plural|snake }}` is + `article_categories`. +- An unknown variable or filter is an error, and nothing is written. +- Rendered paths must stay inside the module directory. +- The template must have a `composer.json` (or `composer.json.stub`), and its package name must match the module. + +```text +stubs/module/api/ +├── composer.json.stub +├── routes/api/{{ name }}.php.stub +├── src/Models/{{ name|studly }}.php.stub +└── database/migrations/{{ date }}_create_{{ name|plural|snake }}_table.php.stub +``` + +## Conventions + +The package loads everything it finds in a module. A missing directory is skipped. + +| Module path | Loaded as | +|-------------------------------------|------------------------------------------------------------------------| +| `config/*.php` | config keyed by the file name: `config/blog.php` is `config('blog')`. The application config wins. | +| `lang/` | translations: `__('blog::messages.welcome')`, plus JSON files | +| `resources/views/` | views: `view('blog::posts.index')` | +| `src/View/Components/` | Blade components: ``, or the anonymous component `resources/views/components/alert.blade.php` | +| `database/migrations/` | migrations of `php artisan migrate` (console only) | +| `database/factories/` | factories of the module models (console only, see below) | +| `database/seeders/` | seeders of `Modules::seeders()` | +| `routes/api/`, `routes/web/` | [route groups](#routes) | +| `src/Console/Commands/` | Artisan commands (console only) | + +`src/` stands for the directory of the root namespace (the first `autoload.psr-4` entry): `View/Components` +and `Console/Commands` are looked up there, or in the [namespaces](#namespaces) of `make:component` and +`make:command`. + +A module config file can extend any key, `config/app.php` included. The merge is shallow: the top-level keys +of the application config replace those of the module. + +### Factories + +`Modules\Blog\Models\Post::factory()` uses `Modules\Blog\Database\Factories\PostFactory`, and the factory +knows its model, the way Laravel pairs `App\Models\Post` with `Database\Factories\PostFactory`. Classes outside +the modules keep the Laravel behavior. + +The resolver is global (`Factory::guessFactoryNamesUsing()`): a resolver that the application sets in the +`boot()` method of its provider replaces it. + +The factory resolver is registered only in the console (tests, seeders, Tinker), because factories are not +used while serving requests. If you create models with factories during HTTP requests, point the model at +its factory: -# Create a migration -php artisan module:make:migration create_posts_table app/blog +```php +use Illuminate\Database\Eloquent\Attributes\UseFactory; -# Create an action -php artisan module:make:action CreatePostAction app/blog +#[UseFactory(PostFactory::class)] +final class Post extends Model +{ + use HasFactory; +} ``` -### 4. Run Module Migrations +### Module commands -```bash -php artisan module:migrate -``` +Classes in the `make:command` namespace of a module (`src/Console/Commands`, including subdirectories) are +registered as Artisan commands. Abstract classes, classes that are not commands and files that do not declare +a class of their name are skipped. Commands +with the `#[AsCommand]` attribute are loaded lazily. Like migrations, module commands are registered only +in the console, so `Artisan::call()` cannot run them during an HTTP request. -## Module Structure +### Tinker -A complete module follows this structure: +Tinker aliases the module classes like the application classes: `Post::first()` works without the namespace. -``` -modules/blog/ -├── composer.json # Module package definition -├── src/ -│ ├── Actions/ # Business logic actions -│ │ ├── CreatePostAction.php -│ │ ├── UpdatePostAction.php -│ │ └── DeletePostAction.php -│ │ -│ ├── Models/ # Eloquent models -│ │ └── Post.php -│ │ -│ ├── DTO/ # Data Transfer Objects -│ │ ├── CreatePostDTO.php -│ │ └── UpdatePostDTO.php -│ │ -│ ├── Events/ # Domain events -│ │ └── PostCreated.php -│ │ -│ ├── Listeners/ # Event listeners -│ │ └── SendPostNotification.php -│ │ -│ ├── Jobs/ # Queued jobs -│ │ └── ProcessPost.php -│ │ -│ ├── Policies/ # Authorization policies -│ │ └── PostPolicy.php -│ │ -│ ├── Providers/ # Service providers -│ │ ├── BlogServiceProvider.php -│ │ └── RouteServiceProvider.php -│ │ -│ └── UI/ -│ ├── API/ -│ │ ├── Controllers/ -│ │ │ └── PostController.php -│ │ ├── Requests/ -│ │ │ ├── CreatePostRequest.php -│ │ │ └── UpdatePostRequest.php -│ │ ├── Resources/ -│ │ │ └── PostResource.php -│ │ ├── QueryWizards/ -│ │ │ └── PostsQueryWizard.php -│ │ └── routes/ -│ │ └── v1.php -│ │ -│ ├── WEB/ -│ │ ├── Controllers/ -│ │ ├── Requests/ -│ │ └── routes/ -│ │ -│ └── CLI/ -│ └── Commands/ -│ -├── database/ -│ ├── migrations/ -│ │ └── 2024_01_01_create_posts_table.php -│ ├── seeders/ -│ │ └── PostSeeder.php -│ └── factories/ -│ └── PostFactory.php -│ -├── resources/ -│ └── views/ -│ -├── lang/ -│ -├── config/ -│ -└── tests/ - ├── Unit/ - ├── Feature/ - └── API/ -``` +### Policies and events -## Core Concepts +Laravel already finds `Modules\Blog\Policies\PostPolicy` for `Modules\Blog\Models\Post`. Events and +listeners are not discovered: register them in a [module service provider](#module-service-providers). -### Module +## Routes -A **Module** represents a self-contained business domain. It's identified by its Composer package name (e.g., `app/blog`). +Route groups are defined in `config/modules.php`: ```php -use Laraneat\Modules\ModulesRepository; - -$repository = app(ModulesRepository::class); - -// Find a module -$module = $repository->find('app/blog'); - -// Get module properties -$module->getName(); // "blog" -$module->getStudlyName(); // "Blog" -$module->getNamespace(); // "Modules\Blog" -$module->getPath(); // "/path/to/modules/blog" -$module->getProviders(); // ["Modules\Blog\Providers\BlogServiceProvider"] +'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], +], ``` -### ModulesRepository - -The **ModulesRepository** discovers and manages all modules in your application. +- Every `*.php` file in the `path` directory of a module is loaded inside `Route::group()` with the + other attributes, so `prefix`, `middleware`, `as`, `domain`, `where` and the rest work as usual. +- Nested directories are appended to the prefix: `routes/api/v1/admin/stats.php` is loaded with the + `api/v1/admin` prefix. +- Files of a directory are loaded before its subdirectories, in alphabetical order. +- Groups apply to all modules. A module with its own layout can load its routes in its service provider. +- Nothing is loaded when the routes are cached: `php artisan route:cache` includes the module routes. +- Route files are loaded while the package boots, like the routes of other packages: before the providers + of the application boot. `Route::pattern()` and route macros from `AppServiceProvider::boot()` do not reach + them. Define those in the `register()` method of a provider, or use `->where()` in the route files. ```php -use Laraneat\Modules\ModulesRepository; - -$repository = app(ModulesRepository::class); +// modules/blog/routes/api/posts.php +use Illuminate\Support\Facades\Route; +use Modules\Blog\Http\Controllers\PostController; -// Get all modules -$modules = $repository->getModules(); +Route::apiResource('posts', PostController::class); // /api/posts +``` -// Check if module exists -$repository->has('app/blog'); +## Generators -// Find module by name -$repository->filterByName('Blog'); +Every generator accepts `--module`: all `make:*` commands of Laravel, `make:migration`, the table migrations +(`make:queue-table`, `make:session-table`, ...) and the generators of other packages that extend +`GeneratorCommand` (`make:action` of laravel-actions, `make:data` of laravel-data, ...). -// Delete a module -$repository->delete('app/blog'); +```bash +php artisan make:model Post --module=blog -mfs --policy +php artisan make:request StorePostRequest --module=blog +php artisan make:migration create_comments_table --module=blog +php artisan make:test PostTest --module=blog ``` -### Actions +Classes go where Laravel puts them in the application, relative to the root namespace of the module. +Some generators are adjusted to the module: -**Actions** are the core of the architecture. Using `lorisleiva/laravel-actions`, they serve dual purposes: -- **Business Logic** via `handle()` method - can be called from anywhere (other actions, jobs, commands) -- **HTTP Controller** via `asController()` method - handles HTTP requests directly +| Command | In the `blog` module | +|------------------------------|-----------------------------------------------------------------------------| +| `make:model` | `src/Models` (if the directory exists, like in Laravel). With `-f`, the factory goes to `database/factories`. | +| `make:factory`, `make:seeder` | `database/factories`, `database/seeders` with the `Modules\Blog\Database\*` namespaces | +| `make:migration` | `database/migrations` | +| `make:test` | `tests/Feature` or `tests/Unit` with the `Modules\Blog\Tests\*` namespaces | +| `make:view`, `make:component`, `make:mail`, `make:notification` | views in `resources/views`, referenced as `blog::...` | +| `make:provider` | `src/Providers`, registered in `extra.laravel.providers` of the module `composer.json` | +| `make:config` | `config` | -```php -// src/Actions/CreatePostAction.php -namespace Modules\Blog\Actions; +Nested commands run in the same module: `make:model Post --module=blog -mfs` creates the model, the +migration, the factory and the seeder in `blog`. -use Lorisleiva\Actions\Concerns\AsAction; -use Modules\Blog\DTO\CreatePostDTO; -use Modules\Blog\Models\Post; -use Modules\Blog\UI\API\Requests\CreatePostRequest; -use Modules\Blog\UI\API\Resources\PostResource; -use Illuminate\Http\JsonResponse; +`--model` and `--parent` name models of the module: `--model=Post` is `Modules\Blog\Models\Post`, or a class of +the `make:model` namespace (see below). As in the application, they cannot reference a model of another namespace. -class CreatePostAction -{ - use AsAction; +### Namespaces - // Core business logic - reusable from anywhere - public function handle(CreatePostDTO $dto): Post - { - return Post::create($dto->all()); - } - - // HTTP entry point - acts as controller - public function asController(CreatePostRequest $request): JsonResponse - { - $post = $this->handle($request->toDTO()); - - return (new PostResource($post))->created(); - } -} -``` - -**Routes point directly to Actions:** +To use another layout, map a command to a namespace of the module in `config/modules.php`: ```php -// routes/v1.php -Route::post('/posts', CreatePostAction::class); -Route::get('/posts', ListPostsAction::class); -Route::get('/posts/{post}', ViewPostAction::class); -Route::put('/posts/{post}', UpdatePostAction::class); -Route::delete('/posts/{post}', DeletePostAction::class); +'generators' => [ + 'make:controller' => 'UI\API\Controllers', + 'make:request' => 'UI\API\Requests', + 'make:command' => 'UI\CLI\Commands', +], ``` -### DTOs (Data Transfer Objects) +`make:controller PostController --module=blog` then creates `Modules\Blog\UI\API\Controllers\PostController`. +Nested commands use the config too: `make:model Post -a --module=blog` puts the controller and the form +requests there, and the controller imports the requests from `UI\API\Requests`. -**DTOs** are simple objects that carry data between layers. +- A namespace is relative to where the generator puts classes in a module: `Modules\Blog\Tests` for + `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` + for `make:seeder` and `Modules\Blog` for the others. +- A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. +- An empty namespace is the root namespace of the module: `'make:policy' => ''` creates `Modules\Blog\PostPolicy`. + The `make:command` namespace can not be empty, because module commands are discovered in it. +- The `make:model` namespace also applies to `--model` and `--parent` of the Laravel generators. The factory + of `make:model -f` goes where the factory resolver looks for it, whatever the `make:factory` namespace. +- `Modules::seeders()` reads only direct subdirectories of `database/seeders`, so a `make:seeder` namespace + has at most one segment. +- The `make:command` namespace is also where [module commands](#module-commands) are discovered, and the + `make:component` namespace is where `` components are looked up. -```php -// src/DTO/CreatePostDTO.php -namespace Modules\Blog\DTO; +The stubs of the generators are the Laravel ones: customize them with `php artisan stub:publish`. -class CreatePostDTO -{ - public function __construct( - public readonly string $title, - public readonly string $content, - public readonly int $authorId, - ) {} +## Module service providers - public static function fromRequest(CreatePostRequest $request): self - { - return new self( - title: $request->validated('title'), - content: $request->validated('content'), - authorId: $request->user()->id, - ); - } -} +A module does not need a service provider. Add one for bindings, event listeners, gates or anything the +conventions do not cover: + +```bash +php artisan make:provider BlogServiceProvider --module=blog +php artisan module:sync ``` -### Module Service Provider +The provider is registered in `extra.laravel.providers` of the module `composer.json`; `module:sync` installs +the changed module, so that Laravel package discovery picks it up. -Each module has a service provider that extends `ModuleServiceProvider`: +Module providers are loaded by package discovery, in the order of the package names. The module config, +views and translations are registered while the Laraneat provider registers, which may happen after the +`register()` of a module provider. In `register()`, do not read config or use the `Modules` facade directly: +do it in `boot()` or inside the closures of your bindings. -```php -// src/Providers/BlogServiceProvider.php -namespace Modules\Blog\Providers; +## Seeders + +`Modules::seeders()` returns the seeder classes of all modules, ready for `$this->call()`: -use Laraneat\Modules\Support\ModuleServiceProvider; +```php +use Laraneat\Modules\Facades\Modules; -class BlogServiceProvider extends ModuleServiceProvider +final class DatabaseSeeder extends Seeder { - public function boot(): void + public function run(): void { - // Load module commands - $this->loadCommandsFrom([ - 'Modules\\Blog\\UI\\CLI\\Commands' => __DIR__ . '/../UI/CLI/Commands', - ]); - - // Load migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Load views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'blog'); + $this->call(Modules::seeders()); } } ``` -## Configuration - -After publishing, edit `config/modules.php`: - -```php -return [ - // Where modules are stored - 'path' => base_path('modules'), +- Without arguments it returns the seeders of `database/seeders`. Pass subdirectories to add them: + `Modules::seeders('', 'Demo')` returns `database/seeders` and `database/seeders/Demo`, + `Modules::seeders('Demo')` only `database/seeders/Demo`. Only direct subdirectories are read: + `Modules::seeders('Demo/Extra')` returns nothing. +- Class names come from the file paths (PSR-4): a seeder whose namespace does not match its path is not found. +- Seeders run in the order of their integer `_N` class name suffix (`PermissionsSeeder_1` before + `UsersSeeder_2`); seeders without a suffix run last. Equal suffixes run in the order of the class names. +- Abstract classes and classes that do not extend `Seeder` are skipped. - // Base namespace for all modules - 'namespace' => 'Modules', - - // Custom stubs location (optional) - 'custom_stubs' => base_path('stubs/modules'), +## Tests - // Composer settings for generated modules - 'composer' => [ - 'vendor' => 'app', - 'author' => [ - 'name' => 'Your Name', - 'email' => 'your@email.com', - ], - ], +Module tests live in `modules//tests`. `module:make` and `module:sync` add their namespace to the +`autoload-dev` of the application, because Composer does not autoload the `autoload-dev` of dependencies. - // Component path/namespace mappings - 'components' => [ - 'action' => [ - 'path' => 'src/Actions', - 'namespace' => 'Actions', - ], - 'model' => [ - 'path' => 'src/Models', - 'namespace' => 'Models', - ], - // ... more components - ], +Add the module tests to `phpunit.xml`: - // Enable manifest caching (recommended for production) - 'cache' => [ - 'enabled' => env('APP_ENV') === 'production', - ], -]; +```xml + + tests/Feature + modules/*/tests/Feature + + + tests/Unit + modules/*/tests/Unit + ``` -## Artisan Commands - -### Module Management - -| Command | Description | -|---------|-------------| -| `module:list` | Display all registered modules | -| `module:sync` | Refresh manifest and sync with Composer | -| `module:delete {package}` | Delete a module completely | -| `module:cache` | Build module manifest cache | -| `module:cache:clear` | Clear module manifest cache | - -When a module is added to the application's `composer.json` (by `module:make` or `module:sync`), its vendor is also excluded from Packagist: +With Pest, also extend the test case in `tests/Pest.php`: -```json -"repositories": [ - { "type": "path", "url": "modules/*", "options": { "symlink": true } }, - { "type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"] }, - { "packagist.org": false } -] -``` - -This way a missing module fails the install instead of silently pulling a same-named package from Packagist. If you configure Packagist yourself (for example with `only`), make sure the modules vendor is not resolved from there. - -### Module Creation - -```bash -# Create module with interactive preset selection -php artisan module:make Blog - -# Create with specific preset -php artisan module:make Blog --preset=api --entity=Post -``` - -### Component Generators - -| Command | Description | -|---------|-------------| -| `module:make:action` | Create an Action class | -| `module:make:controller` | Create a Controller (--ui=api\|web) | -| `module:make:model` | Create an Eloquent Model | -| `module:make:migration` | Create a database migration | -| `module:make:request` | Create a Form Request | -| `module:make:resource` | Create an API Resource | -| `module:make:dto` | Create a DTO class | -| `module:make:event` | Create an Event class | -| `module:make:listener` | Create an Event Listener | -| `module:make:job` | Create a Job class | -| `module:make:policy` | Create a Policy class | -| `module:make:provider` | Create a Service Provider | -| `module:make:middleware` | Create Middleware | -| `module:make:command` | Create a Console Command | -| `module:make:factory` | Create a Model Factory | -| `module:make:seeder` | Create a Database Seeder | -| `module:make:test` | Create a Test class | -| `module:make:observer` | Create a Model Observer | -| `module:make:notification` | Create a Notification | -| `module:make:mail` | Create a Mailable | -| `module:make:rule` | Create a Validation Rule | -| `module:make:query-wizard` | Create a QueryWizard | -| `module:make:route` | Create a Route file | -| `module:make:exception` | Create an Exception class | - -### Migration Commands - -| Command | Description | -|---------|-------------| -| `module:migrate` | Run module migrations | -| `module:migrate:rollback` | Rollback module migrations | -| `module:migrate:reset` | Reset all module migrations | -| `module:migrate:refresh` | Refresh module migrations | -| `module:migrate:status` | Show migration status | - -## Component Types - -The package supports 30+ component types organized by architectural layers: - -### UI Layer - -**API Components:** -- `ApiController` - REST API controllers -- `ApiRequest` - API form requests -- `ApiResource` - API JSON resources -- `ApiRoute` - API route files -- `ApiQueryWizard` - Query builder wrappers -- `ApiTest` - API integration tests - -**WEB Components:** -- `WebController` - Web controllers -- `WebRequest` - Web form requests -- `WebRoute` - Web route files -- `WebTest` - Web integration tests - -**CLI Components:** -- `CliCommand` - Artisan commands -- `CliTest` - Command tests - -### Domain Layer - -- `Action` - Business logic actions -- `Model` - Eloquent models -- `Dto` - Data Transfer Objects -- `Event` - Domain events -- `Listener` - Event listeners -- `Job` - Queued jobs -- `Rule` - Validation rules -- `Observer` - Model observers - -### Infrastructure Layer - -- `Provider` - Service providers -- `Middleware` - HTTP middleware -- `Policy` - Authorization policies -- `Mail` - Mailable classes -- `Notification` - Notifications - -### Database Layer - -- `Migration` - Database migrations -- `Seeder` - Database seeders -- `Factory` - Model factories - -## Module Presets - -### Plain Preset (Default) - -Basic module with minimal structure: -- Service providers only -- Empty directory structure - -```bash -php artisan module:make Blog --preset=plain -``` - -### Base Preset - -Includes database layer components: -- Model with migrations -- Factory for testing -- Seeder with permissions -- Authorization policy - -```bash -php artisan module:make Blog --preset=base --entity=Post +```php +pest()->extend(Tests\TestCase::class)->in('Feature', '../modules/*/tests/Feature'); ``` -### API Preset - -Complete REST API module: -- All base preset components -- CRUD controllers -- Form requests (create, update, delete, list, view) -- API resources -- QueryWizard for filtering/sorting -- DTOs for data transfer -- Complete route file -- Full test coverage +Run the tests of one module by its path: ```bash -php artisan module:make Blog --preset=api --entity=Post +php artisan test modules/blog/tests ``` -## Best Practices +## Commands -### 1. Keep Modules Independent +| Command | Description | +|---------------------------------|-------------| +| `module:make ` | Create a module. `--preset=` picks the template, `--no-update` skips `composer update`. | +| `module:sync` | Add every module to the `composer.json` of the application and run `composer update` for the modules that changed. `--no-update` only prints the command. | +| `module:delete ` | Remove the `autoload-dev` entries of the module, run `composer remove` for it, then delete its directory. Asks for confirmation; `--force` skips it and is required in non-interactive mode. With `--no-update`, it edits `composer.json` instead of running Composer, deletes the directory and prints the `composer update` command to run. A symlinked module directory is never deleted. | +| `module:doctor` | Check the modules and how they are installed. Read-only; fails when it finds errors. | +| `module:list` | List the modules. With `-v`, also what is loaded from each of them. | +| `module:cache` | Cache the module manifest. Part of `php artisan optimize`. | +| `module:clear` | Remove the module manifest cache. Part of `php artisan optimize:clear`. | -Modules should be loosely coupled. If module A depends on module B, consider: -- Using events for communication -- Creating shared interfaces -- Moving shared code to a separate package +Commands and `--module` take the module directory name (`blog`), not the package name (`app/blog`). +Composer runs in the application directory: `composer.phar` there if it exists, otherwise `composer` from `PATH`. -### 2. Separate HTTP Logic from Business Logic +`module:doctor` checks: -Keep `asController()` thin - it should only handle HTTP concerns. Put business logic in `handle()`: +- that every module is required, excluded from Packagist and installed, and that the installed package + links to the module directory; +- that the installed package matches the module `composer.json` (providers, autoload); +- the `autoload-dev` entries of the module tests; +- that the root namespace directory exists and that factories and seeders are autoloaded; +- that the `extra.laravel.providers` classes exist; +- route files outside of the configured route groups; +- unknown keys in `config/modules.php`, an outdated manifest cache, and a modules path that + `octane:start --watch` cannot watch. -```php -class CreatePostAction -{ - use AsAction; +Most problems are fixed by `php artisan module:sync`. - // Business logic - reusable, testable - public function handle(CreatePostDTO $dto): Post - { - $post = Post::create($dto->all()); - event(new PostCreated($post)); - - return $post; - } +The `about` command shows the number of modules, their path and whether the manifest is cached. - // HTTP concerns only - request/response handling - public function asController(CreatePostRequest $request): JsonResponse - { - $post = $this->handle($request->toDTO()); - - return (new PostResource($post))->created(); - } -} -``` - -### 3. Reuse Actions Across Contexts - -Actions can be called from multiple places: +## Configuration ```php -// From another Action -class ImportPostsAction -{ - public function __construct(private CreatePostAction $createPost) {} - - public function handle(array $posts): void - { - foreach ($posts as $postData) { - $this->createPost->handle(new CreatePostDTO(...$postData)); - } - } -} - -// From a Job -class ProcessImportJob implements ShouldQueue -{ - public function handle(CreatePostAction $action): void - { - $action->handle($this->dto); - } -} - -// From a Command -class SeedPostsCommand extends Command -{ - public function handle(CreatePostAction $action): void - { - $action->handle(new CreatePostDTO(...)); - } -} -``` - -### 4. Use DTOs for Data Transfer +return [ + // The directory of the modules. Every direct subdirectory with a composer.json is a module. + 'path' => base_path('modules'), -DTOs provide type safety and clear contracts: + // The namespace prefix and the Composer vendor of new modules. + 'namespace' => 'Modules', + 'vendor' => 'app', -```php -class CreatePostDTO -{ - public function __construct( - public readonly string $title, - public readonly string $content, - public readonly int $authorId, - ) {} + // Route groups, see "Routes". + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], - public function all(): array - { - return [ - 'title' => $this->title, - 'content' => $this->content, - 'author_id' => $this->authorId, - ]; - } -} + // Namespaces of the generators inside a module, see "Generators". + 'generators' => [], +]; ``` -### 5. Organize Routes by Version - -For APIs, version your routes: +## Performance and caching -``` -routes/ -├── v1.php # Version 1 routes -└── v2.php # Version 2 routes -``` +The package builds a manifest of the modules: their packages, namespaces and the files it loads. The +provider loads everything from the manifest and does not look for module files by itself. -### 6. Write Tests +- `php artisan optimize` (or `module:cache`) writes the manifest to `bootstrap/cache/modules.php`, next to + the config and route caches. A cached application loads it with one `require`. Set `MODULES_CACHE` to + store it elsewhere, like `APP_CONFIG_CACHE`. It must be a real environment variable: `.env` is not read + when the config is cached. +- Without the cache file, the manifest is built once per process, so it is never stale. The cache is written + only by `optimize` and `module:cache`: a deploy that skips them scans the modules in every process. +- The cached manifest lists the files it loads. After `php artisan optimize` on a development machine, new + modules, new `lang`, `resources/views` and `database/migrations` directories, and new route, config, seeder + and command files are ignored until `php artisan optimize:clear` (or `module:clear`); deleted files are + skipped. +- `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. +- The config and route caches include the module config and routes, so the package skips them when they + are cached. +- Migrations, commands, factories, Tinker aliases and the Octane watcher are registered only in the console. -Each module should have comprehensive tests: +Cache the application when you deploy: ```bash -# Run all module tests -./vendor/bin/pest modules/blog/tests - -# Run specific test -./vendor/bin/pest --filter "can create post" +php artisan optimize ``` -### 7. Use Caching in Production +## Octane -Enable manifest caching for better performance: +The package keeps no state that changes after boot, so it is safe for Octane workers. -```php -// config/modules.php -'cache' => [ - 'enabled' => env('APP_ENV') === 'production', -], -``` +`php artisan octane:start --watch` restarts the workers when module files change: the modules path is +added to `octane.watch` automatically, if the modules are inside the application. -Run after deployment: -```bash -php artisan module:cache -``` +## Upgrading + +See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x, and [CHANGELOG.md](CHANGELOG.md) for the changes. ## License -MIT License. See [LICENSE](LICENSE) for details. +The MIT License (MIT). See [LICENSE](LICENSE). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..e2ffdd8 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,517 @@ +# Upgrade Guide + +## From 2.x to 3.0 + +Version 3 is a smaller package: it loads modules by convention and adds `--module` to the Laravel generators. +The component generators, stubs, migration commands and test helpers of 2.x are gone. + +The steps below are written so that a developer or an AI agent can follow them one by one. Commit after +every step, and run the checks at the end. + +### Before you start + +- The application runs Laravel 13.12 or newer and PHP 8.3 or newer, with `laraneat/modules` 2.1 or newer: the + first 2.x release that supports Laravel 13. The steps compare 3.0 with it. +- The test suite passes. +- Save the routes of the application to compare them later: + + ```bash + php artisan route:list --json \ + | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;' \ + > /tmp/routes-before.json + ``` + +### 1. Update the package + +```bash +composer require laraneat/modules:^3.0 --no-update +composer update laraneat/modules --with-all-dependencies --no-scripts +``` + +Artisan does not work again until [step 8](#8-reinstall-the-modules-and-sync-them): the old config and +the module providers use classes that are removed. Steps 2 to 7 only edit files. + +`composer/composer` is no longer a dependency of the package. Keep it only if the application uses it. + +Delete the manifest cache of 2.x, it is not used anymore: + +```bash +rm -f bootstrap/cache/laraneat-modules.php +``` + +### 2. Update the config + +Copy the new config next to the old one and move your values into it: + +```bash +mv config/modules.php config/modules.php.v2 +cp vendor/laraneat/modules/config/modules.php config/modules.php +``` + +Laravel loads only the `.php` files of `config/`, so the old file, which references removed classes, is not +loaded anymore. + +| 2.x key | 3.0 | +|----------------------------------|------------------------------------------------------------------------------| +| `path`, `namespace` | the same keys | +| `composer.vendor` | `vendor` | +| `components.*` with a namespace | `generators`, see below | +| `components.api-route`, `web-route` | `routes`, see [step 4](#4-move-the-routes-into-route-groups) | +| `components` without a namespace (`config`, `lang`, `migration`, `view`) | fixed: `config`, `lang`, `database/migrations`, `resources/views` | +| `custom_stubs` | removed, see [step 9](#9-replace-the-generators) | +| `composer.author` | removed: put authors in the module templates | +| `user_model` | removed, see [step 6](#6-copy-the-test-and-response-helpers-into-the-application) | +| `create_permission` | removed: only the 2.x generators used it | +| `cache.enabled` | removed: the cache is built by `php artisan optimize` | + +`generators` maps a generator command to a namespace inside the module. It replaces the `namespace` of the +2.x components. Map only the commands whose namespace differs from the Laravel one. For the default 2.x +layout: + +```php +'generators' => [ + 'make:controller' => 'UI\API\Controllers', + 'make:request' => 'UI\API\Requests', + 'make:resource' => 'UI\API\Resources', + 'make:command' => 'UI\CLI\Commands', + 'make:data' => 'DTO', + 'make:mail' => 'Mails', + 'make:middleware' => 'Middleware', +], +``` + +Laravel puts models in `Models` only when the module has a `src/Models` directory, and in the root namespace +otherwise. Add `'make:model' => 'Models'` to always use `Models`, as 2.x did. + +One command has one namespace. 2.x had separate API and WEB controllers and requests: generate the other +kind with a fully qualified name, for example +`make:controller "Modules\Blog\UI\WEB\Controllers\PostController" --module=blog`. The namespaces also apply +to the files that options such as `make:model -a` create. + +The `make:command` namespace is also where module commands are discovered, so it must match the directory +of the existing commands. + +Delete `config/modules.php.v2` when everything is moved. + +### 3. Remove the module service providers that only load resources + +The package now loads the resources of every module. For each module in `modules/*`: + +1. Find its providers in `extra.laravel.providers` of `modules//composer.json`. +2. A provider that extends `Laraneat\Modules\Support\ModuleServiceProvider`: + - Remove the `loadConfigurations()`, `loadMigrations()`, `loadCommands()`, `loadTranslations()` and + `loadViews()` methods and their calls. + - If nothing else is left, delete the provider and remove it from `extra.laravel.providers`. + - Otherwise extend `Illuminate\Support\ServiceProvider` instead. `loadCommandsFrom()`, `loadFiles()`, + `loadAllFiles()` and `getPublishableViewPaths()` do not exist anymore. +3. Delete the route service provider that uses `Laraneat\Modules\Support\Concerns\CanLoadRoutesFromDirectory` + and remove it from `extra.laravel.providers`. Keep anything it does besides loading routes (route + patterns, model bindings, rate limiters) in another provider. +4. Remove the calls that load the module directories themselves: `mergeConfigFrom()`, `loadMigrationsFrom()`, + `loadViewsFrom()` and `loadTranslationsFrom()` with paths of the module. + +The publish tags of the 2.x module providers (`-config`, `-migrations`, `-translations`, `-views`) +are gone with them: update the scripts that call `vendor:publish` with these tags. + +What 3.0 loads, and how it differs from the 2.x providers: + +- **Config**: every `config/*.php` file of the module, keyed by its file name. In 2.x only + `config/.php` was merged, and only when `loadConfigurations()` was called. Check that the other + files of `config/` are meant to be config. The application config still wins; module config files are + not publishable anymore. +- **Views and translations**: the namespace is the module directory name (`blog::`). In 2.x it was the kebab + case name of the module; rename the directory or the references if they differ. Views published into + `resources/views/modules/` or `resources/views/vendor/` do not override the module views + anymore: move the changes into the module. Translation overrides in `lang/vendor/` still work. +- **Migrations and commands**: registered only in the console. 2.x registered migrations for HTTP requests + too: `Artisan::call('migrate')` during an HTTP request does not see the module migrations anymore. +- **Everything is loaded**: the 2.x provider stub had `loadCommands()`, `loadTranslations()` and `loadViews()` + commented out. 3.0 loads the commands, translations and views of every module that has them. + +Search for leftovers: + +```bash +grep -rn "Laraneat\\\\Modules\\\\Support\\\\ModuleServiceProvider\|CanLoadRoutesFromDirectory" app modules tests +``` + +### 4. Move the routes into route groups + +Module routes are loaded by the route groups of `config/modules.php`. For the default 2.x layout: + +```php +'routes' => [ + 'api' => ['path' => 'src/UI/API/routes', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'src/UI/WEB/routes', 'middleware' => ['web']], +], +``` + +Use the prefix, middleware and other attributes of the deleted route service providers. A module whose routes +do not fit the groups loads them in its own service provider with `Route::group()`. + +Three differences from `CanLoadRoutesFromDirectory`: + +- Nested directories are appended to the prefix. In 2.x, `routes/v1/admin/stats.php` got the `api/admin` + prefix; now it gets `api/v1/admin`. Routes one directory deep keep their URIs. +- The files of a directory are loaded before its subdirectories (2.x loaded subdirectories first), and + groups are loaded for all modules in turn: first every `api` route, then every `web` route. +- Route files are loaded while the package provider boots. 2.x loaded them right after the module route + service provider booted, so `Route::pattern()` calls and route macros in its `boot()` applied to its routes. + When you move them to another provider, define them in its `register()` method, or use `->where()` in the + route files: the `boot()` of another provider may run after the module routes are loaded. + +When two routes can match the same URL (`posts/{post}` and `posts/export`), make sure the specific one is +still registered first. + +### 5. Replace the module seeders trait + +`Laraneat\Modules\Support\Concerns\CanRunModuleSeeders` is replaced by `Modules::seeders()`: + +```php +// Before +use CanRunModuleSeeders; + +$this->runSeedersFromModules(); +$this->runSeedersFromModules('Deployment'); +$this->runSeedersFromModules(['Deployment', 'Demo']); + +// After +use Laraneat\Modules\Facades\Modules; + +$this->call(Modules::seeders()); +$this->call(Modules::seeders('', 'Deployment')); +$this->call(Modules::seeders('', 'Deployment', 'Demo')); +``` + +`runSeedersFromModules($directories)` always included `database/seeders`; with `Modules::seeders()`, `''` is +`database/seeders` and you list it yourself. The order is the same: by the `_N` class name suffix, then by +class name. Differences: + +- Abstract classes and classes that are not seeders are skipped. +- Only direct subdirectories of `database/seeders` are read: `Modules::seeders('Demo/Extra')` returns nothing. +- Class names come from the file paths, not from the namespace in the file: a seeder whose namespace does + not match its path is not found. + +If you overrode methods of the trait (`sortSeederClasses()`, `getSeederClassesFromModule()`), apply the same +logic to the array that `Modules::seeders()` returns. + +### 6. Copy the test and response helpers into the application + +`InteractsWithTestUser` and `WithJsonResponseHelpers` were not related to modules and are removed. Copy them +into the application: + +
+tests/Concerns/InteractsWithTestUser.php + +```php +|null, roles?: string|array|null} + */ + protected array $testUserAccess = [ + 'permissions' => '', + 'roles' => '', + ]; + + protected ?string $testUserGuard = null; + + public function beTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static + { + return $this->actingAsTestUserWithoutAccess($userDetails, $guard); + } + + /** + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function beTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static + { + return $this->actingAsTestUser($userDetails, $access, $guard); + } + + public function actingAsTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static + { + return $this->actingAs($this->createTestUserWithoutAccess($userDetails), $guard ?? $this->testUserGuard); + } + + /** + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function actingAsTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static + { + return $this->actingAs($this->createTestUser($userDetails, $access), $guard ?? $this->testUserGuard); + } + + public function createTestUserWithoutAccess(?array $userDetails = null): Authenticatable + { + return $this->createTestUser($userDetails, []); + } + + /** + * $access replaces $testUserAccess: null gives the default access, [] gives none. + * + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function createTestUser(?array $userDetails = null, ?array $access = null): Authenticatable + { + $class = $this->testUserClass ?? config('auth.providers.users.model'); + + if (! is_string($class) || ! method_exists($class, 'factory')) { + throw new LogicException('Set $testUserClass to a user model that has a factory.'); + } + + $user = $class::factory()->create($this->testUserAttributes($userDetails ?? [])); + $access ??= $this->testUserAccess; + + if (! empty($access['permissions'])) { + if (! method_exists($user, 'givePermissionTo')) { + throw new LogicException('The user model must use spatie/laravel-permission to give permissions.'); + } + + $user->givePermissionTo($access['permissions']); + } + + if (! empty($access['roles'])) { + if (! method_exists($user, 'assignRole')) { + throw new LogicException('The user model must use spatie/laravel-permission to assign roles.'); + } + + $user->assignRole($access['roles']); + } + + return $user; + } + + /** + * The attributes of a test user. Override it when the user model has other columns. + * + * @param array $details + * @return array + */ + protected function testUserAttributes(array $details): array + { + $attributes = [ + 'name' => 'Testing user', + 'email' => 'testing@test.com', + 'password' => 'testing-password', + ...$details, + ]; + + $attributes['password'] = Hash::make($attributes['password']); + + return $attributes; + } +} +``` + +
+ +
+app/Support/Concerns/WithJsonResponseHelpers.php + +```php + + +Then replace the imports. The calls stay the same: + +```bash +grep -rl --null "Laraneat\\\\Modules\\\\Support\\\\Concerns\\\\\(InteractsWithTestUser\|WithJsonResponseHelpers\)" app modules tests \ + | xargs -0 perl -pi -e 's/Laraneat\\Modules\\Support\\Concerns\\InteractsWithTestUser/Tests\\Concerns\\InteractsWithTestUser/g; s/Laraneat\\Modules\\Support\\Concerns\\WithJsonResponseHelpers/App\\Support\\Concerns\\WithJsonResponseHelpers/g' +``` + +Two changes in `InteractsWithTestUser`: + +- The user model comes from `$testUserClass` or the `users` auth provider, not from `modules.user_model`. +- An explicit empty access now creates a user without access: `actingAsTestUser(null, [])` is the same as + `actingAsTestUserWithoutAccess()`. In 2.x it gave the default access. Find such calls with + `grep -rn "TestUser([^)]*\[\])" modules tests`. + +### 7. Update the code that uses the package API + +- The facade is `Laraneat\Modules\Facades\Modules` (was `Laraneat\Modules\Support\Facades\Modules`). The + global `Modules` alias points to the new facade. +- `Laraneat\Modules\ModulesRepository` is `Laraneat\Modules\ModuleRepository`. The classes of + `Laraneat\Modules\Providers` are replaced by one `Laraneat\Modules\ModulesServiceProvider`. +- The 2.x exceptions are removed, except `ModuleNotFound`: its `make()`, `makeForName()` and + `makeForNameOrPackageName()` are replaced by `ModuleNotFound::named()`. Every exception of 3.0 implements the + `Laraneat\Modules\Exceptions\ModulesException` interface. +- Modules are identified by their directory name, not by the package name. + +| 2.x | 3.0 | +|--------------------------------------------|-----------------------------------------------| +| `Modules::getModules()` | `Modules::all()` (keyed by the directory name) | +| `Modules::find('app/blog')` | `Modules::find('blog')` | +| `Modules::findOrFail('app/blog')` | `Modules::get('blog')` | +| `Modules::has('app/blog')` | `Modules::find('blog') !== null` | +| `Modules::count()` | `count(Modules::all())` | +| `Modules::filterByName('blog')`, `filterByNameOrFail()` | `Modules::find('blog')`, `Modules::get('blog')` | +| `Modules::getScanPaths()`, `addScanPath()` | the `path` config key: one directory of modules | +| `Modules::toArray()`, `$module->toArray()` | `Modules::all()`, the public properties of `Module` | +| `Modules::delete()`, `syncWithComposer()` | `php artisan module:delete`, `module:sync` | +| `Modules::buildModulesManifest()`, `pruneModulesManifest()` | `php artisan module:cache`, `module:clear` | +| `$module->getName()`, `getPackageName()`, `getNamespace()`, `getPath()` | `$module->name`, `->package`, `->namespace`, `->path` | +| `$module->subPath('src')`, `subNamespace('Models')` | `$module->path.'/src'`, `$module->namespace.'\\Models'` | +| `$module->getStudlyName()`, `getKebabName()`, `getSnakeName()` | `Str::studly($module->name)`, `$module->name`, `Str::snake(Str::camel($module->name))` | +| `$module->getProviders()`, `getAliases()` | `extra.laravel` of the module `composer.json` | +| `(string) $module` | `$module->package` | +| `Module::macro()` | removed: `Module` is a read-only object | + +```bash +grep -rnE 'Laraneat\\Modules\\(Support|Enums|Exceptions|Providers|Commands|ModulesRepository|Module;)|Modules::' \ + app modules tests database routes config bootstrap +``` + +### 8. Reinstall the modules and sync them + +Composer keeps a copy of every module `composer.json` in `vendor/composer/installed.json`, and Laravel +discovers the providers there. Reinstall the modules, so that the deleted providers are forgotten; this also +rebuilds the package manifest, and Artisan works again. Use the vendor of your modules: + +```bash +composer update "app/*" +php artisan module:sync +``` + +`module:sync` excludes the module vendor from Packagist and adds the `autoload-dev` of the module tests. +Existing requirements (`"app/blog": "*"` or `"^1.0"`) are kept. + +### 9. Replace the generators + +The `module:make:*` commands are replaced by the Laravel generators with `--module`: + +| 2.x | 3.0 | +|------------------------------------------------|--------------------------------------------------------------| +| `module:make:model Post blog` | `make:model Post --module=blog` | +| `module:make:controller PostController blog` | `make:controller PostController --module=blog` | +| `module:make:migration create_posts_table blog` | `make:migration create_posts_table --module=blog` | +| `module:make:test PostTest blog` | `make:test PostTest --module=blog` | +| `module:make:action CreatePost blog` | `make:action CreatePost --module=blog` (lorisleiva/laravel-actions) | +| `module:make:dto PostData blog` | `make:data PostData --module=blog` (spatie/laravel-data) | +| the other `module:make:` | `make: ... --module=` | +| `module:make:route`, `module:make:query-wizard` | removed: create the files by hand or with your own generator | +| `module:stub:publish`, `custom_stubs` | `php artisan stub:publish`, which customizes the Laravel stubs | + +`make:data` adds the `Data` suffix to names that do not end with it: `make:data CreatePostDTO` creates +`CreatePostDTOData`. Pass `--suffix=` to keep the name as given, or set `data.commands.make.suffix` (for +example to `DTO`) in the laravel-data config. + +The generated code follows the Laravel stubs, not the Porto stubs of 2.x. If you relied on the 2.x stubs, +put your versions in `stubs/` with `php artisan stub:publish`; they apply to the application and to the +modules alike. + +### 10. Replace the migration commands + +Module migrations are registered with the Laravel migrator: + +| 2.x | 3.0 | +|----------------------------------|--------------------------------------------------------------| +| `module:migrate` | `migrate` | +| `module:migrate blog` | `migrate --path=modules/blog/database/migrations` | +| `module:migrate:rollback`, `:refresh`, `:reset`, `:status` | `migrate:rollback`, `migrate:refresh`, `migrate:reset`, `migrate:status` | + +`migrate` also runs the migrations of the application, in one order by file name. In production it asks for +confirmation, like `module:migrate` did: keep `--force` in deployment scripts. + +Update scripts, CI jobs and deployment that call the old commands. `module:delete` now deletes one module +at a time, takes the directory name (`blog`, not `app/blog`), and needs `--force` in non-interactive mode. + +### 11. Replace the module templates + +`module:make --preset=plain|base|api --entity=...` is replaced by module templates: `module:make blog +--preset=` renders `stubs/module/`. Start from the built-in template, which is published to +`stubs/module/default`, and add the files your modules begin with: + +```bash +php artisan vendor:publish --tag=modules-stubs +cp -R stubs/module/default stubs/module/api +``` + +The placeholders are described in the [README](README.md#module-templates). `--entity` is gone: use the +module name with filters, for example `{{ name|studly }}`. + +### 12. Clean up + +- Models can drop their `newFactory()` method when the factory is in `database/factories` with the same + name: the package resolves it in the console. Keep `newFactory()` (or add `#[UseFactory]`) for models + that create factories while serving HTTP requests. +- Remove the deployment steps that cleared `bootstrap/cache` to refresh the module cache. Run + `php artisan optimize` when you deploy; it caches the modules together with the config and routes. + 2.x wrote the cache by itself in production, 3.0 does not: without `optimize`, every process scans the modules. +- `octane.watch` does not need the modules path anymore; it is added automatically. + +### Check the upgrade + +```bash +php artisan optimize:clear +php artisan module:doctor +php artisan route:list --json \ + | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;' \ + > /tmp/routes-after.json +diff /tmp/routes-before.json /tmp/routes-after.json +php artisan test +php artisan optimize && php artisan optimize:clear +``` + +- `module:doctor` reports no errors. +- The routes are the same, except for the prefixes of nested route directories (step 4). +- The tests pass. diff --git a/composer.json b/composer.json index 7ca25b2..420d75a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "laraneat/modules", - "description": "Laraneat modules.", + "description": "Modular monolith infrastructure for Laravel: modules as Composer path packages, wired by conventions.", "homepage": "https://github.com/laraneat/modules/", "support": { "issues": "https://github.com/laraneat/modules/issues", @@ -13,74 +13,65 @@ } ], "keywords": [ - "laraneat", - "modules", - "core", - "laraneat modules", - "laraneat-modules", "laravel", - "Porto", - "Porto SAP" + "modules", + "modular monolith", + "laraneat" ], "license": "MIT", "require": { - "php": "^8.1", + "php": "^8.3", "ext-json": "*", - "composer/composer": "^2.1", - "laravel/framework": "^10.0 || ^11.0 || ^12.0 || ^13.0" + "laravel/framework": "^13.12" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.48", - "larastan/larastan": "^2.0 || ^3.0", - "mockery/mockery": "^1.0", - "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", - "pestphp/pest": "^2.31 || ^3.0 || ^4.0 || ^5.0", - "pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0 || ^5.0", - "phpstan/phpstan": "^1.10.58 || ^2.0", + "larastan/larastan": "^3.0", + "laravel/octane": "^2.0", + "laravel/pint": "^1.0", + "lorisleiva/laravel-actions": "^2.0", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0 || ^5.0", + "phpstan/phpstan": "^2.0", "roave/security-advisories": "dev-latest", - "spatie/pest-plugin-snapshots": "^2.1" + "spatie/laravel-data": "^4.0" }, "autoload": { "psr-4": { - "Laraneat\\Modules\\": "src" + "Laraneat\\Modules\\": "src/" } }, "autoload-dev": { "psr-4": { - "Laraneat\\Modules\\Tests\\": "tests", - "App\\": "tests/fixtures/laravel/app", - "Modules\\": "tests/fixtures/laravel/modules" + "Laraneat\\Modules\\Tests\\": "tests/", + "Modules\\Blog\\": "tests/Fixtures/app/modules/blog/src/", + "Modules\\Blog\\Database\\Factories\\": "tests/Fixtures/app/modules/blog/database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "tests/Fixtures/app/modules/blog/database/seeders/", + "Modules\\ShopOrder\\": "tests/Fixtures/app/modules/shop-order/src/", + "Modules\\ShopOrder\\Database\\Seeders\\": "tests/Fixtures/app/modules/shop-order/database/seeders/" } }, - "suggest": { - "lorisleiva/laravel-actions": "Laravel components that take care of one specific task", - "jackardios/laravel-query-wizard": "Easily build Eloquent queries from API requests", - "spatie/laravel-data": "Powerful data objects for Laravel" - }, "extra": { "laravel": { "providers": [ - "Laraneat\\Modules\\Providers\\ComposerServiceProvider", - "Laraneat\\Modules\\Providers\\ConsoleServiceProvider", - "Laraneat\\Modules\\Providers\\ModulesRepositoryServiceProvider", - "Laraneat\\Modules\\Providers\\ModulesServiceProvider" + "Laraneat\\Modules\\ModulesServiceProvider" ], "aliases": { - "Modules": "Laraneat\\Modules\\Support\\Facades\\Modules" + "Modules": "Laraneat\\Modules\\Facades\\Modules" } } }, "scripts": { - "analyse" : "./vendor/bin/phpstan analyse --memory-limit=256M", - "test" : "./vendor/bin/pest --no-coverage", - "test-coverage" : "./vendor/bin/pest --coverage-html coverage", - "update-snapshots": "./vendor/bin/pest --no-coverage --update-snapshots", - "format": "./vendor/bin/php-cs-fixer fix --allow-risky=yes", + "test": "pest --parallel --order-by=random", + "test:coverage": "pest --coverage --min=95", + "test:mutate": "pest --mutate --everything --covered-only --parallel --min=85", + "analyse": "phpstan analyse --memory-limit=1G", + "format": "pint", + "lint": "pint --test", "post-autoload-dump": [ "@php ./vendor/bin/testbench package:discover --ansi" ] }, - "config" : { + "config": { "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true diff --git a/config/config.php b/config/config.php deleted file mode 100755 index 7848b1c..0000000 --- a/config/config.php +++ /dev/null @@ -1,229 +0,0 @@ - base_path('modules'), - - /* - |-------------------------------------------------------------------------- - | Module namespace prefix - |-------------------------------------------------------------------------- - | - | Prefix for the namespace of the generated modules - | - */ - 'namespace' => 'Modules', - - /* - |-------------------------------------------------------------------------- - | Custom generator stubs path - |-------------------------------------------------------------------------- - | - | Place your custom stubs in this folder - | - */ - 'custom_stubs' => base_path('stubs/modules'), - - /* - |-------------------------------------------------------------------------- - | Composer File Template - |-------------------------------------------------------------------------- - | - | Configuration composer.json of generated modules - | - */ - 'composer' => [ - 'vendor' => 'app', - 'author' => [ - 'name' => 'Example', - 'email' => 'example@example.com' - ], - ], - - /* - |-------------------------------------------------------------------------- - | User model class - |-------------------------------------------------------------------------- - | - | Customize the User model of the application - | - */ - 'user_model' => null, - - /* - |-------------------------------------------------------------------------- - | "Create permission" classes - |-------------------------------------------------------------------------- - | - | Customize "create permission" classes - | - */ - 'create_permission' => [ - 'action' => null, - 'dto' => null - ], - - /* - |-------------------------------------------------------------------------- - | Component paths - |-------------------------------------------------------------------------- - | - | Customize the paths where the folders will be generated. - | - */ - 'components' => [ - ModuleComponentType::Action->value => [ - 'path' => 'src/Actions', - 'namespace' => 'Actions' - ], - ModuleComponentType::ApiController->value => [ - 'path' => 'src/UI/API/Controllers', - 'namespace' => 'UI\\API\\Controllers' - ], - ModuleComponentType::ApiQueryWizard->value => [ - 'path' => 'src/UI/API/QueryWizards', - 'namespace' => 'UI\\API\\QueryWizards' - ], - ModuleComponentType::ApiRequest->value => [ - 'path' => 'src/UI/API/Requests', - 'namespace' => 'UI\\API\\Requests' - ], - ModuleComponentType::ApiResource->value => [ - 'path' => 'src/UI/API/Resources', - 'namespace' => 'UI\\API\\Resources' - ], - ModuleComponentType::ApiRoute->value => [ - 'path' => 'src/UI/API/routes', - 'namespace' => 'UI\\API\\Routes' - ], - ModuleComponentType::ApiTest->value => [ - 'path' => 'tests/UI/API', - 'namespace' => 'Tests\\UI\\API' - ], - ModuleComponentType::CliCommand->value => [ - 'path' => 'src/UI/CLI/Commands', - 'namespace' => 'UI\\CLI\\Commands' - ], - ModuleComponentType::CliTest->value => [ - 'path' => 'tests/UI/CLI', - 'namespace' => 'Tests\\UI\\CLI' - ], - ModuleComponentType::Config->value => [ - 'path' => 'config' - ], - ModuleComponentType::Dto->value => [ - 'path' => 'src/DTO', - 'namespace' => 'DTO' - ], - ModuleComponentType::Event->value => [ - 'path' => 'src/Events', - 'namespace' => 'Events' - ], - ModuleComponentType::Exception->value => [ - 'path' => 'src/Exceptions', - 'namespace' => 'Exceptions' - ], - ModuleComponentType::Factory->value => [ - 'path' => 'database/factories', - 'namespace' => 'Database\\Factories' - ], - ModuleComponentType::FeatureTest->value => [ - 'path' => 'tests/Feature', - 'namespace' => 'Tests\\Feature' - ], - ModuleComponentType::Job->value => [ - 'path' => 'src/Jobs', - 'namespace' => 'Jobs' - ], - ModuleComponentType::Lang->value => [ - 'path' => 'lang' - ], - ModuleComponentType::Listener->value => [ - 'path' => 'src/Listeners', - 'namespace' => 'Listeners' - ], - ModuleComponentType::Mail->value => [ - 'path' => 'src/Mails', - 'namespace' => 'Mails' - ], - ModuleComponentType::Middleware->value => [ - 'path' => 'src/Middleware', - 'namespace' => 'Middleware' - ], - ModuleComponentType::Migration->value => [ - 'path' => 'database/migrations' - ], - ModuleComponentType::Model->value => [ - 'path' => 'src/Models', - 'namespace' => 'Models' - ], - ModuleComponentType::Notification->value => [ - 'path' => 'src/Notifications', - 'namespace' => 'Notifications' - ], - ModuleComponentType::Observer->value => [ - 'path' => 'src/Observers', - 'namespace' => 'Observers' - ], - ModuleComponentType::Policy->value => [ - 'path' => 'src/Policies', - 'namespace' => 'Policies' - ], - ModuleComponentType::Provider->value => [ - 'path' => 'src/Providers', - 'namespace' => 'Providers' - ], - ModuleComponentType::Rule->value => [ - 'path' => 'src/Rules', - 'namespace' => 'Rules' - ], - ModuleComponentType::Seeder->value => [ - 'path' => 'database/seeders', - 'namespace' => 'Database\\Seeders' - ], - ModuleComponentType::UnitTest->value => [ - 'path' => 'tests/Unit', - 'namespace' => 'Tests\\Unit' - ], - ModuleComponentType::View->value => [ - 'path' => 'resources/views' - ], - ModuleComponentType::WebController->value => [ - 'path' => 'src/UI/WEB/Controllers', - 'namespace' => 'UI\\WEB\\Controllers' - ], - ModuleComponentType::WebRequest->value => [ - 'path' => 'src/UI/WEB/Requests', - 'namespace' => 'UI\\WEB\\Requests' - ], - ModuleComponentType::WebRoute->value => [ - 'path' => 'src/UI/WEB/routes', - 'namespace' => 'UI\\WEB\\Routes' - ], - ModuleComponentType::WebTest->value => [ - 'path' => 'tests/UI/WEB', - 'namespace' => 'Tests\\UI\\WEB' - ], - ], - - /* - |-------------------------------------------------------------------------- - | Caching - |-------------------------------------------------------------------------- - | - | Here is the config for setting up caching feature for scanned modules. - | - */ - 'cache' => [ - 'enabled' => env('APP_ENV', 'production') === 'production', - ], -]; diff --git a/config/modules.php b/config/modules.php new file mode 100644 index 0000000..c563eff --- /dev/null +++ b/config/modules.php @@ -0,0 +1,69 @@ + base_path('modules'), + + /* + |-------------------------------------------------------------------------- + | Namespace And Vendor Of New Modules + |-------------------------------------------------------------------------- + | + | "module:make blog" creates the "Modules\Blog" namespace and the + | "app/blog" Composer package. The vendor is excluded from Packagist, + | so choose one that you do not publish public packages under. + | + */ + + 'namespace' => 'Modules', + + 'vendor' => 'app', + + /* + |-------------------------------------------------------------------------- + | Route Groups + |-------------------------------------------------------------------------- + | + | Every PHP file found in a module's "path" directory is loaded inside a + | route group with the other attributes. Nested directories are added to + | the prefix: "routes/api/v1/posts.php" is loaded with the "api/v1" prefix. + | + */ + + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], + + /* + |-------------------------------------------------------------------------- + | Generator Namespaces + |-------------------------------------------------------------------------- + | + | "make:* --module=blog" places classes where Laravel places them in the + | application ("Models", "Http\Controllers", ...). Map a command to + | another sub-namespace of the module here. Module commands are + | discovered in the namespace of "make:command". + | + | Example: 'make:controller' => 'UI\API\Controllers' + | + */ + + 'generators' => [ + // + ], + +]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 4a797fd..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,3 +0,0 @@ -parameters: - ignoreErrors: - - "#Unsafe usage of new static#" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index db492d2..500bd5a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,22 +1,10 @@ includes: - - phpstan-baseline.neon - vendor/larastan/larastan/extension.neon parameters: - level: 5 + level: max paths: - src + - config tmpDir: build/phpstan - reportUnmatchedIgnoredErrors: true checkOctaneCompatibility: true - checkModelProperties: true - ignoreErrors: - # These traits are used in stubs for generated code - - identifier: trait.unused - path: src/Support/Concerns/CanLoadRoutesFromDirectory.php - - identifier: trait.unused - path: src/Support/Concerns/CanRunModuleSeeders.php - - identifier: trait.unused - path: src/Support/Concerns/InteractsWithTestUser.php - - identifier: trait.unused - path: src/Support/Concerns/WithJsonResponseHelpers.php diff --git a/phpunit.xml b/phpunit.xml index 417198b..6332a36 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,11 +1,23 @@ - + ./tests @@ -14,4 +26,8 @@ ./src + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9a25e3c --- /dev/null +++ b/pint.json @@ -0,0 +1,10 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true, + "final_class": true + }, + "exclude": [ + "tests/Fixtures" + ] +} diff --git a/resources/stubs/module/default/composer.json.stub b/resources/stubs/module/default/composer.json.stub new file mode 100644 index 0000000..88e5095 --- /dev/null +++ b/resources/stubs/module/default/composer.json.stub @@ -0,0 +1,17 @@ +{ + "name": "{{ package }}", + "description": "The {{ name }} module.", + "type": "library", + "autoload": { + "psr-4": { + "{{ namespace|json }}\\": "src/", + "{{ namespace|json }}\\Database\\Factories\\": "database/factories/", + "{{ namespace|json }}\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "{{ namespace|json }}\\Tests\\": "tests/" + } + } +} diff --git a/tests/fixtures/laravel/app/.gitkeep b/resources/stubs/module/default/database/factories/.gitkeep similarity index 100% rename from tests/fixtures/laravel/app/.gitkeep rename to resources/stubs/module/default/database/factories/.gitkeep diff --git a/tests/fixtures/laravel/database/factories/.gitkeep b/resources/stubs/module/default/database/migrations/.gitkeep similarity index 100% rename from tests/fixtures/laravel/database/factories/.gitkeep rename to resources/stubs/module/default/database/migrations/.gitkeep diff --git a/tests/fixtures/laravel/database/migrations/.gitkeep b/resources/stubs/module/default/database/seeders/.gitkeep similarity index 100% rename from tests/fixtures/laravel/database/migrations/.gitkeep rename to resources/stubs/module/default/database/seeders/.gitkeep diff --git a/tests/fixtures/laravel/database/seeders/.gitkeep b/resources/stubs/module/default/src/Models/.gitkeep similarity index 100% rename from tests/fixtures/laravel/database/seeders/.gitkeep rename to resources/stubs/module/default/src/Models/.gitkeep diff --git a/tests/fixtures/laravel/storage/app/.gitignore b/resources/stubs/module/default/tests/.gitkeep similarity index 100% rename from tests/fixtures/laravel/storage/app/.gitignore rename to resources/stubs/module/default/tests/.gitkeep diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php deleted file mode 100644 index 5bb23b9..0000000 --- a/src/Commands/BaseCommand.php +++ /dev/null @@ -1,187 +0,0 @@ -|Module - * - * @throws ModuleNotFound - * @throws ModuleHasNonUniquePackageName - * @throws ModuleHasNoNamespace - */ - protected function getModuleArgumentOrFail(): array|Module - { - $allPackageNames = array_keys($this->modulesRepository->getModules()); - $moduleArgument = $this->input->getArgument('module'); - $multipleModuleMode = is_array($moduleArgument); - - if ($multipleModuleMode) { - $moduleArgument = array_values(array_unique($moduleArgument)); - - if (empty($moduleArgument)) { - $moduleArgument = multiselect( - label: 'Select one or more module', - options: [ - 'all' => 'All modules', - ...array_combine($allPackageNames, $allPackageNames), - ], - required: 'You must select at least one module', - ); - } - - if (in_array('all', $moduleArgument, true)) { - $moduleArgument = $allPackageNames; - } - } else { - if (empty($moduleArgument)) { - $moduleArgument = select( - label: 'Select one module', - options: $allPackageNames, - required: 'You must select a module', - ); - } - } - - $this->input->setArgument('module', value: $moduleArgument); - - if (! $multipleModuleMode) { - return $this->findModuleByNameOrPackageNameOrFail($moduleArgument); - } - - return collect($moduleArgument) - ->map(fn (string $moduleNameOrPackageName) - => $this->findModuleByNameOrPackageNameOrFail($moduleNameOrPackageName)) - ->unique(fn (Module $module) => $module->getPackageName()) - ->values() - ->all(); - } - - /** - * @throws ModuleNotFound - * @throws ModuleHasNonUniquePackageName - * @throws ModuleHasNoNamespace - */ - protected function findModuleByNameOrPackageNameOrFail($moduleNameOrPackageName): Module - { - if ($foundModule = $this->modulesRepository->find($moduleNameOrPackageName)) { - return $foundModule; - } - - $foundModules = collect($this->modulesRepository->filterByName($moduleNameOrPackageName)); - $numberOfFoundModules = $foundModules->count(); - - if ($numberOfFoundModules === 0) { - throw ModuleNotFound::makeForNameOrPackageName($moduleNameOrPackageName); - } - - if ($numberOfFoundModules === 1) { - return $foundModules->first(); - } - - $selectedPackageName = $this->choice( - "$numberOfFoundModules modules with name '{$moduleNameOrPackageName}' found, please select one module from those found", - $foundModules->keys()->all(), - ); - - - return $this->modulesRepository->findOrFail($selectedPackageName); - } - - /** - * Checks if the option is set (via CLI), otherwise asks the user for a value - * - * @throws InvalidOptionException - */ - protected function getOptionOrAsk( - string $optionName, - string $question, - ?string $default = null, - bool $required = true - ): ?string { - $value = $this->option($optionName); - - if ($value === '' || $value === null) { - $value = trim($this->ask($question, $default)); - } - - if ($required && $value === '') { - throw new InvalidOptionException( - sprintf("The '%s' option is required", $optionName) - ); - } - - return $value; - } - - /** - * Checks if the option is set (via CLI), otherwise proposes choices to the user - * - * @throws InvalidOptionException - */ - protected function getOptionOrChoice( - string $optionName, - string $question, - array $choices, - ?string $default = null - ): ?string { - $value = $this->option($optionName); - - if ($value === '' || $value === null) { - $value = $this->choice($question, $choices, $default); - } elseif (! in_array($value, $choices, true)) { - throw new InvalidOptionException( - sprintf( - "Wrong '%s' option value provided. Value should be one of '%s'.", - $optionName, - implode("' or '", $choices) - ) - ); - } - - return $value; - } - - /** - * Checks if the option is set (via CLI) - * - * @throws InvalidOptionException - */ - protected function getOptionOneOf( - string $optionName, - array $choices, - ?string $default = null - ): ?string { - $value = $this->option($optionName) ?: $default; - - if (! in_array($value, $choices, true)) { - throw new InvalidOptionException( - sprintf( - "Wrong '%s' option value provided. Value should be one of '%s'.", - $optionName, - implode("' or '", $choices) - ) - ); - } - - return $value; - } -} diff --git a/src/Commands/BaseMigrationCommand.php b/src/Commands/BaseMigrationCommand.php deleted file mode 100644 index 6d5fba3..0000000 --- a/src/Commands/BaseMigrationCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -requiresConfirmation && ! $this->confirmToProceed()) { - return self::FAILURE; - } - - try { - $modulesToHandle = $this->getModuleArgumentOrFail(); - } catch (ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - foreach ($modulesToHandle as $module) { - $this->line('Running for module: ' . $module->getPackageName() . ''); - $this->executeForModule($module); - } - - return self::SUCCESS; - } - - /** - * Execute the migration operation for a single module. - */ - abstract protected function executeForModule(Module $module): void; - - /** - * Get migration paths for a module. - * - * @return array - */ - protected function getMigrationPaths(Module $module): array - { - /** @var Migrator|null $migrator */ - $migrator = $this->laravel['migrator'] ?? null; - - if ($migrator === null) { - return []; - } - - return collect($migrator->paths()) - ->filter(fn (string $path) => Str::startsWith($path, $module->getPath())) - ->values() - ->toArray(); - } -} diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/CacheClearCommand.php deleted file mode 100644 index 641ecff..0000000 --- a/src/Commands/CacheClearCommand.php +++ /dev/null @@ -1,31 +0,0 @@ -modulesRepository->pruneModulesManifest(); - $this->components->info("Modules manifest cache cleared!"); - - return self::SUCCESS; - } -} diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php index f065224..227f2d9 100644 --- a/src/Commands/CacheCommand.php +++ b/src/Commands/CacheCommand.php @@ -1,30 +1,23 @@ modulesRepository->buildModulesManifest(); - $this->components->info("Modules manifest cached!"); + $modules->cache(); + + $this->components->info('Modules cached successfully.'); return self::SUCCESS; } diff --git a/src/Commands/ClearCommand.php b/src/Commands/ClearCommand.php new file mode 100644 index 0000000..43da7c3 --- /dev/null +++ b/src/Commands/ClearCommand.php @@ -0,0 +1,24 @@ +clearCache(); + + $this->components->info('Module cache cleared successfully.'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php new file mode 100644 index 0000000..ad91487 --- /dev/null +++ b/src/Commands/DeleteCommand.php @@ -0,0 +1,118 @@ +input->getArgument('name'); + $module = $modules->get(is_string($name) ? $name : ''); + + if (is_link($module->path)) { + $this->components->error("[{$composer->relative($module->path)}] is a symbolic link. Remove the module by hand."); + + return self::FAILURE; + } + + if (! $this->confirmed($module->name)) { + return self::FAILURE; + } + + $root = $composer->composerJson(); + $original = $files->get($composer->composerJsonPath()); + $root->removeAutoloadDev(array_keys($composer->autoloadDev($module))); + + if ($this->option('no-update')) { + $root->removeRequire($module->package); + } + + $vendorLink = $composer->installed($root)->path($module->package); + $root->save(); + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + // Uninstall first: when Composer fails, the module and composer.json are left as they were. + if (! $this->option('no-update') && ! $runner->remove([$module->package], $this->streamOutput(...))) { + $files->put($composer->composerJsonPath(), $original); + $this->components->error('Composer failed to remove the module, nothing was deleted.'); + + return self::FAILURE; + } + + // deleteDirectory() reports success even when some files could not be deleted. + $files->deleteDirectory($module->path); + $modules->refresh(); + + if (file_exists($module->path)) { + $this->components->error("[{$composer->relative($module->path)}] could not be deleted completely. Delete it by hand."); + + return self::FAILURE; + } + + // Composer does not remove the link of a path package whose directory is gone. + // On Windows, file_exists() is true for a link to a deleted directory, and such a link, like the + // junction Composer creates there, is removed with rmdir(). + if (is_link($vendorLink) && ! is_dir($vendorLink) && ! $files->delete($vendorLink)) { + @rmdir($vendorLink); + } + + $this->components->info("Module [{$module->name}] deleted."); + + if ($this->option('no-update')) { + $this->components->warn('Uninstall it with: '.$runner->commandLine('update', [$module->package])); + } + + return self::SUCCESS; + } + + private function confirmed(string $name): bool + { + if ($this->option('force')) { + return true; + } + + if (! $this->input->isInteractive()) { + $this->components->error('Use --force to delete a module in non-interactive mode.'); + + return false; + } + + if ($this->components->confirm("Delete the [{$name}] module and all of its files?")) { + return true; + } + + $this->components->warn('The module was not deleted.'); + + return false; + } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } +} diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php new file mode 100644 index 0000000..df7ba43 --- /dev/null +++ b/src/Commands/DoctorCommand.php @@ -0,0 +1,230 @@ +errors = $this->warnings = 0; + + try { + $root = $composer->composerJson(); + $installed = $composer->installed($root); + $manifest = $modules->manifest(); + + $this->report('Configuration', $this->checkConfig($modules, $composer)); + + foreach ($modules->all() as $name => $module) { + $this->report($name, $this->checkModule($module, $manifest[$name], $root, $installed, $composer)); + } + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->newLine(); + + if ($this->errors > 0) { + $this->components->error("Found {$this->errors} error(s) and {$this->warnings} warning(s)."); + + return self::FAILURE; + } + + $this->warnings > 0 + ? $this->components->warn("Found {$this->warnings} warning(s).") + : $this->components->info('No problems found.'); + + return self::SUCCESS; + } + + /** + * @return list Problems: whether it is an error, and the message. + */ + private function checkConfig(ModuleRepository $modules, ApplicationComposer $composer): array + { + $config = $this->config(); + $problems = []; + + if (($unknown = array_diff(array_keys((array) $config->get('modules', [])), self::CONFIG_KEYS)) !== []) { + $problems[] = [false, 'Unknown keys in config/modules.php: '.implode(', ', $unknown).'.']; + } + + if ($modules->isCacheStale()) { + $problems[] = [false, 'The module cache is outdated: run "php artisan module:cache".']; + } + + if (is_array($config->get('octane.watch')) && $composer->relative($modules->modulesPath()) === $modules->modulesPath()) { + $problems[] = [false, 'The modules path is outside of the application, so "octane:start --watch" does not watch it.']; + } + + return $problems; + } + + /** + * @param ModuleManifest $manifest + * @return list + */ + private function checkModule(Module $module, array $manifest, ComposerJson $root, InstalledPackages $installed, ApplicationComposer $composer): array + { + $composerJson = ComposerJson::read($module->path.'/composer.json'); + $problems = []; + + if (! $root->requires($module->package)) { + $problems[] = [true, "The application does not require [{$module->package}]: run \"php artisan module:sync\"."]; + } + + if (! $root->isExcludedFromPackagist($module->package)) { + $problems[] = [true, "[{$module->package}] can be installed from Packagist: run \"php artisan module:sync\" to exclude the vendor."]; + } + + if (! $installed->has($module->package)) { + $problems[] = [true, "[{$module->package}] is not installed: run \"php artisan module:sync\"."]; + } elseif (realpath($vendorPath = $installed->path($module->package)) !== realpath($module->path)) { + $source = $installed->pathSource($module->package); + + // COMPOSER_MIRROR_PATH_REPOS=1 copies path packages, as Docker images often do. + $problems[] = $source !== null && is_dir($vendorPath) && ! is_link($vendorPath) && realpath($composer->absolute($source)) === realpath($module->path) + ? [false, "[{$composer->relative($vendorPath)}] is a copy, not a link to [{$composer->relative($module->path)}]: changes of the module need \"composer update\"."] + : [true, "[{$composer->relative($vendorPath)}] is not a link to [{$composer->relative($module->path)}]."]; + } elseif (! $installed->isCurrent($module->package, $composerJson->toArray())) { + $problems[] = [false, 'composer.json changed since the module was installed: run "php artisan module:sync".']; + } + + $autoloadDev = (array) $root->get('autoload-dev.psr-4'); + + foreach ($composer->autoloadDev($module) as $namespace => $paths) { + if (($autoloadDev[$namespace] ?? null) !== $paths) { + $problems[] = [false, "The application does not autoload [{$namespace}] for development: run \"php artisan module:sync\"."]; + } + } + + if (! is_dir($module->sourcePath)) { + $problems[] = [true, "The directory of the [{$module->namespace}] namespace does not exist."]; + } + + $autoload = (array) $composerJson->get('autoload.psr-4'); + + foreach (['database/factories' => 'Database\\Factories', 'database/seeders' => 'Database\\Seeders'] as $directory => $namespace) { + $namespace = $module->namespace.'\\'.$namespace.'\\'; + $mapped = array_map( + static fn (string $path): string => trim((string) preg_replace('{^\./}', '', $path), '/'), + array_filter((array) ($autoload[$namespace] ?? []), is_string(...)), + ); + + if (is_dir($module->path.'/'.$directory) && ! in_array($directory, $mapped, true)) { + $problems[] = [true, "\"autoload.psr-4\" must map [{$namespace}] to [{$directory}/]."]; + } + } + + foreach ((array) $composerJson->get('extra.laravel.providers') as $provider) { + if (! is_string($provider) || ! class_exists($provider)) { + $problems[] = [true, 'The provider ['.(is_string($provider) ? $provider : get_debug_type($provider)).'] does not exist.']; + } + } + + foreach ($this->unloadedRouteFiles($module, $manifest) as $file) { + $problems[] = [false, "The route file [{$file}] is not in a route group of config/modules.php."]; + } + + return $problems; + } + + /** + * PHP files in "routes" directories of the module that no route group loads. + * + * @param ModuleManifest $manifest + * @return list + */ + private function unloadedRouteFiles(Module $module, array $manifest): array + { + $loaded = array_merge(...array_values(array_map( + static fn (array $directories): array => array_merge(...array_values($directories)), + $manifest['routes'], + ))); + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($module->path, FilesystemIterator::SKIP_DOTS), + static fn (SplFileInfo $file): bool => ! $file->isDir() || preg_match('{^(vendor|node_modules|tests|\..*)$}', $file->getFilename()) !== 1, + ), + RecursiveIteratorIterator::LEAVES_ONLY, + RecursiveIteratorIterator::CATCH_GET_CHILD, + ); + $iterator->setMaxDepth(6); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + $path = substr(str_replace('\\', '/', $file->getPathname()), strlen($module->path) + 1); + + if ($file->getExtension() === 'php' && preg_match('{(^|/)routes/}', $path) === 1 && ! in_array($path, $loaded, true)) { + $files[] = $path; + } + } + + sort($files); + + return $files; + } + + /** + * @param list $problems + */ + private function report(string $subject, array $problems): void + { + if ($problems === []) { + $this->components->twoColumnDetail($subject, 'OK'); + + return; + } + + $errors = count(array_filter($problems, static fn (array $problem): bool => $problem[0])); + $this->errors += $errors; + $this->warnings += count($problems) - $errors; + + $this->components->twoColumnDetail($subject, $errors > 0 ? 'FAIL' : 'WARN'); + + foreach ($problems as [$isError, $message]) { + $this->line(' '.($isError ? '✗' : '!').' '.$message); + } + } + + private function config(): Repository + { + return $this->laravel->make('config'); + } +} diff --git a/src/Commands/Generators/ActionMakeCommand.php b/src/Commands/Generators/ActionMakeCommand.php deleted file mode 100644 index 94eb3b6..0000000 --- a/src/Commands/Generators/ActionMakeCommand.php +++ /dev/null @@ -1,144 +0,0 @@ - 'Enter the action class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('lorisleiva/laravel-actions'); - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'create', 'delete', 'list', 'update', 'view'], - 'plain' - ); - - return Stub::create("action/$stub.stub", $this->getStubReplaces($stub))->render(); - } - - /** - * @param string $stub - * @return array - */ - protected function getStubReplaces(string $stub): array - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub === 'plain') { - return $stubReplaces; - } - - if ($stub === 'create' || $stub === 'update') { - $dtoClass = $this->getFullClassFromOptionOrAsk( - optionName: 'dto', - question: 'Enter the class name of the DTO to be used in the action', - componentType: ModuleComponentType::Dto, - module: $this->module - ); - $stubReplaces['dto'] = class_basename($dtoClass); - $stubReplaces['dtoCamelCase'] = Str::camel($stubReplaces['dto']); - $stubReplaces['dtoNamespace'] = $this->getNamespaceOfClass($dtoClass); - } - - if ($stub !== 'delete') { - $resourceClass = $this->getFullClassFromOptionOrAsk( - optionName: 'resource', - question: 'Enter the class name of the resource to be used in the action', - componentType: ModuleComponentType::ApiResource, - module: $this->module - ); - $stubReplaces['resource'] = class_basename($resourceClass); - $stubReplaces['resourceNamespace'] = $this->getNamespaceOfClass($resourceClass); - - if (in_array($stub, ['list', 'view'], true)) { - $wizardClass = $this->getFullClassFromOptionOrAsk( - optionName: 'wizard', - question: 'Enter the class name of the query wizard to be used in the action', - componentType: ModuleComponentType::ApiQueryWizard, - module: $this->module - ); - $stubReplaces['queryWizard'] = class_basename($wizardClass); - $stubReplaces['queryWizardNamespace'] = $this->getNamespaceOfClass($wizardClass); - } - } - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the action', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - - $requestClass = $this->getFullClassFromOptionOrAsk( - optionName: 'request', - question: 'Enter the class name of the request to be used in the action', - componentType: ModuleComponentType::ApiRequest, - module: $this->module - ); - $stubReplaces['request'] = class_basename($requestClass); - $stubReplaces['requestNamespace'] = $this->getNamespaceOfClass($requestClass); - - return $stubReplaces; - } -} diff --git a/src/Commands/Generators/BaseComponentGeneratorCommand.php b/src/Commands/Generators/BaseComponentGeneratorCommand.php deleted file mode 100755 index 845e36a..0000000 --- a/src/Commands/Generators/BaseComponentGeneratorCommand.php +++ /dev/null @@ -1,475 +0,0 @@ -beforeGenerate(); - - try { - $this->nameArgument = $this->argument('name'); - $this->ensureNameIsNotReserved($this->nameArgument); - $this->ensureNameIsValidClassName($this->nameArgument); - $this->module = $this->getModuleArgumentOrFail(); - } catch (NameIsReserved|InvalidClassName|ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->components->error($exception->getMessage()); - - return self::FAILURE; - } - - try { - $result = $this->generate( - $this->getGeneratedFilePath(), - $this->getContents(), - $this->option('force') - ); - } catch (InvalidTableName $exception) { - $this->components->error($exception->getMessage()); - - return self::FAILURE; - } - - if ($result !== self::SUCCESS) { - return $result; - } - - $this->afterGenerate(); - - return self::SUCCESS; - } - - /** - * Hook called before generation starts. - * Override in subclasses to add custom logic (e.g., package checks). - */ - protected function beforeGenerate(): void - { - // Default: do nothing - } - - /** - * Get the contents for the generated file. - * Must be implemented by subclasses. - */ - abstract protected function getContents(): string; - - /** - * Get the path for the generated file. - * Override in subclasses to customize (e.g., MigrationMakeCommand). - */ - protected function getGeneratedFilePath(): string - { - return $this->getComponentPath($this->module, $this->nameArgument, $this->componentType); - } - - /** - * Hook called after successful generation. - * Override in subclasses to add custom logic (e.g., ProviderMakeCommand). - */ - protected function afterGenerate(): void - { - // Default: do nothing - } - - /** - * @param string $name - * @return void - * - * @throws NameIsReserved - */ - protected function ensureNameIsNotReserved(string $name): void - { - $classBaseName = class_basename($name); - - if ($this->isReservedName($classBaseName)) { - throw NameIsReserved::make($classBaseName); - } - } - - /** - * Validate that the name is a valid PHP class name, optionally prefixed with sub-namespaces. - * Every segment is validated, so names like "../../Foo" can never escape the component directory. - * - * @throws InvalidClassName - */ - protected function ensureNameIsValidClassName(string $name): void - { - foreach (preg_split('#[/\\\\]+#', trim($name, '/\\')) ?: [] as $segment) { - if (! $this->isValidClassName($segment)) { - throw InvalidClassName::make($segment); - } - } - } - - /** - * Check if the name is a valid PHP class name. - */ - protected function isValidClassName(string $name): bool - { - // PHP class names must start with a letter or underscore, - // followed by any number of letters, numbers, or underscores. - // Also supports multibyte characters as per PHP specification. - return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name); - } - - /** - * Check if the name is a valid database table name. - */ - protected function isValidTableName(string $name): bool - { - // Table names should contain only letters, numbers, and underscores, - // and start with a letter or underscore. - return (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name); - } - - /** - * Generate component file. - * @return int The function returns "0" on success and "1" on failure. - */ - protected function generate(string $path, string $contents, bool $force = false): int - { - if ($force === false && $this->filesystem->exists($path)) { - $this->components->error("File already exists: `$path`. Use --force to overwrite."); - - return self::FAILURE; - } - - $path = str_replace('\\', '/', $path); - $contents = $this->sortImports($contents); - - $directory = dirname($path); - - try { - $this->filesystem->ensureDirectoryExists($directory); - } catch (\Exception $e) { - $this->components->error("Failed to create directory: `$directory`. " . $e->getMessage()); - - return self::FAILURE; - } - - if (! is_writable($directory)) { - $this->components->error("Directory is not writable: `$directory`. Check file permissions."); - - return self::FAILURE; - } - - if ($this->filesystem->put($path, $contents) !== false) { - $this->components->info("Created: `$path`"); - - return self::SUCCESS; - } - - $this->components->error("Failed to write file: `$path`. Check disk space and file permissions."); - - return self::FAILURE; - } - - /** - * Get full class name from option or ask - */ - public function getFullClassFromOptionOrAsk( - string $optionName, - string $question, - ModuleComponentType $componentType, - Module $module - ): string { - return $this->getFullClass( - $this->getOptionOrAsk( - $optionName, - $question, - ), - GeneratorHelper::component($componentType)->getFullNamespace($module) - ); - } - - /** - * Get full class name with namespace - */ - public function getFullClass(string $class, string $defaultNamespace): string - { - if (Str::startsWith($class, '\\')) { - return $class; - } - - return trim($defaultNamespace, '\\') . '\\' . trim($class, '\\'); - } - - /** - * Get the class "namespace" of the given class. - */ - protected function getNamespaceOfClass(string $class): string - { - if (! Str::contains($class, '\\')) { - return ''; - } - - return rtrim(Str::beforeLast($class, '\\'), '\\'); - } - - /** - * Get component namespace, without the class name. - */ - protected function getComponentNamespace(Module $module, string $name, ModuleComponentType $componentType): string - { - $name = str_replace('/', '\\', $name); - $componentNamespace = GeneratorHelper::component($componentType)->getFullNamespace($module); - $subNamespace = $this->getNamespaceOfClass($name); - - $namespace = $componentNamespace . '\\' . $subNamespace; - - return trim($namespace, '\\'); - } - - /** - * Get component path - */ - protected function getComponentPath( - Module $module, - string $name, - ModuleComponentType $componentType, - string $extension = '.php' - ): string { - $componentPath = GeneratorHelper::component($componentType)->getFullPath($module); - $fileName = GeneratorHelper::normalizePath($name); - - return $componentPath . '/' . $fileName . $extension; - } - - protected function getUserModelClass(): string - { - $userModel = GeneratorHelper::getUserModelClass(); - - if (! $userModel) { - $userModel = $this->ask('Enter the class name of the "User model"'); - - if (empty($userModel)) { - throw new LogicException('The "User model" option is required'); - } - } - - return $userModel; - } - - protected function getCreatePermissionActionClass(): string - { - $createPermissionAction = GeneratorHelper::getCreatePermissionActionClass(); - - if (! $createPermissionAction) { - $createPermissionAction = $this->ask('Enter the class name of the "Create permission action"'); - - if (empty($createPermissionAction)) { - throw new LogicException('The "Create permission action" option is required'); - } - } - - return $createPermissionAction; - } - - protected function getCreatePermissionDTOClass(): string - { - $createPermissionAction = GeneratorHelper::getCreatePermissionDTOClass(); - - if (! $createPermissionAction) { - $createPermissionAction = $this->ask('Enter the class name of the "Create permission DTO"'); - - if (empty($createPermissionAction)) { - throw new LogicException('The "Create permission DTO" option is required'); - } - } - - return $createPermissionAction; - } - - /** - * Checks whether the given name is reserved. - */ - protected function isReservedName(string $name): bool - { - $name = strtolower($name); - - return in_array($name, $this->reservedNames, true); - } - - /** - * Alphabetically sorts the imports for the given stub. - */ - protected function sortImports(string $stubContent): string - { - if (preg_match('/(?P(?:use [^;]+;$\n?)+)/m', $stubContent, $match)) { - $imports = explode("\n", trim($match['imports'])); - - sort($imports); - $imports = array_unique($imports); - - return str_replace(trim($match['imports']), implode("\n", $imports), $stubContent); - } - - return $stubContent; - } - - /** - * Get the first view directory path from the application configuration. - */ - protected function viewPath(string $path = ''): string - { - $views = $this->laravel['config']['view.paths'][0] ?? resource_path('views'); - - return $views.($path ? DIRECTORY_SEPARATOR.$path : $path); - } - - /** - * Check if the package is installed - */ - protected function packageIsInstalled(string $packageName): bool - { - $vendorPath = Env::get('COMPOSER_VENDOR_DIR') ?: $this->laravel->basePath('/vendor'); - $installedJsonPath = $vendorPath . '/composer/installed.json'; - - if (! $this->filesystem->exists($installedJsonPath)) { - return false; - } - - /** @var array{packages?: array} $installed */ - $installed = json_decode($this->filesystem->get($installedJsonPath), true); - - foreach ($installed['packages'] ?? [] as $package) { - if ($package['name'] === $packageName) { - return true; - } - } - - return false; - } - - protected function ensurePackageIsInstalledOrWarn(string $packageName): void - { - if ($this->packageIsInstalled($packageName)) { - $this->components->warn("Package '$packageName' is not installed!"); - $this->components->warn("Please install by entering `composer require $packageName` on the command line"); - } - } -} diff --git a/src/Commands/Generators/CommandMakeCommand.php b/src/Commands/Generators/CommandMakeCommand.php deleted file mode 100644 index d085dbd..0000000 --- a/src/Commands/Generators/CommandMakeCommand.php +++ /dev/null @@ -1,73 +0,0 @@ - 'Enter the command class name', - ]; - } - - protected function getContents(): string - { - $signature = $this->getOptionOrAsk( - 'signature', - 'Enter the console command signature that should be assigned' - ); - $description = $this->getOptionOrAsk( - 'description', - 'Enter the console command description', - '', - false - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'signature' => $signature, - 'description' => $description, - ]; - - return Stub::create("command.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ControllerMakeCommand.php b/src/Commands/Generators/ControllerMakeCommand.php deleted file mode 100644 index d5fa285..0000000 --- a/src/Commands/Generators/ControllerMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the controller class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the controller will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiController - : ModuleComponentType::WebController; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("controller/{$this->ui}.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/DTOMakeCommand.php b/src/Commands/Generators/DTOMakeCommand.php deleted file mode 100644 index fc7457e..0000000 --- a/src/Commands/Generators/DTOMakeCommand.php +++ /dev/null @@ -1,64 +0,0 @@ - 'Enter the DTO class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('spatie/laravel-data'); - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("dto.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/EventMakeCommand.php b/src/Commands/Generators/EventMakeCommand.php deleted file mode 100644 index 612963e..0000000 --- a/src/Commands/Generators/EventMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the event class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("event.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ExceptionMakeCommand.php b/src/Commands/Generators/ExceptionMakeCommand.php deleted file mode 100644 index d4f77e8..0000000 --- a/src/Commands/Generators/ExceptionMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the exception class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("exception.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/FactoryMakeCommand.php b/src/Commands/Generators/FactoryMakeCommand.php deleted file mode 100644 index 1537e65..0000000 --- a/src/Commands/Generators/FactoryMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the factory class name', - ]; - } - - protected function getContents(): string - { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the factory', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'model' => class_basename($modelClass), - 'modelCamelCase' => Str::camel(class_basename($modelClass)), - 'modelNamespace' => $this->getNamespaceOfClass($modelClass), - ]; - - return Stub::create("factory.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/JobMakeCommand.php b/src/Commands/Generators/JobMakeCommand.php deleted file mode 100644 index 27beb39..0000000 --- a/src/Commands/Generators/JobMakeCommand.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Enter the job class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("job/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ListenerMakeCommand.php b/src/Commands/Generators/ListenerMakeCommand.php deleted file mode 100644 index cd1dfb0..0000000 --- a/src/Commands/Generators/ListenerMakeCommand.php +++ /dev/null @@ -1,75 +0,0 @@ - 'Enter the listener class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - $eventClass = $this->getFullClassFromOptionOrAsk( - optionName: 'event', - question: 'Enter the class name of the event to listen to', - componentType: ModuleComponentType::Event, - module: $this->module - ); - $stubReplaces['event'] = class_basename($eventClass); - $stubReplaces['eventNamespace'] = $this->getNamespaceOfClass($eventClass); - - return Stub::create("listener/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MailMakeCommand.php b/src/Commands/Generators/MailMakeCommand.php deleted file mode 100644 index 3dda291..0000000 --- a/src/Commands/Generators/MailMakeCommand.php +++ /dev/null @@ -1,76 +0,0 @@ - 'Enter the mail class name', - ]; - } - - protected function getContents(): string - { - $classBaseName = class_basename($this->nameArgument); - $subject = $this->getOptionOrAsk( - "subject", - "Enter the subject of mail", - Str::headline($classBaseName) - ); - $view = $this->getOptionOrAsk( - "view", - "Enter the view for the mail", - 'view.name' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => $classBaseName, - 'subject' => $subject, - 'view' => $view, - ]; - - return Stub::create("mail.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MiddlewareMakeCommand.php b/src/Commands/Generators/MiddlewareMakeCommand.php deleted file mode 100644 index 84dc4ae..0000000 --- a/src/Commands/Generators/MiddlewareMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the middleware class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("middleware.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MigrationMakeCommand.php b/src/Commands/Generators/MigrationMakeCommand.php deleted file mode 100644 index 9e17aa5..0000000 --- a/src/Commands/Generators/MigrationMakeCommand.php +++ /dev/null @@ -1,182 +0,0 @@ - 'Enter the migration name', - ]; - } - - protected function getGeneratedFilePath(): string - { - return $this->getComponentPath($this->module, $this->getFileName(), $this->componentType); - } - - protected function getContents(): string - { - $stub = $this->getOptionOneOf( - 'stub', - choices: [ - null, - 'add', - 'create', - 'delete', - 'pivot', - 'plain', - ], - ); - $parser = new NameParser($this->argument('name')); - - if ($stub === 'pivot') { - return $this->generatePivotMigrationContent($parser); - } - - if ($stub === 'add' || (empty($stub) && $parser->isAdd())) { - return Stub::create('/migration/add.stub', [ - 'table' => $this->getTableName($parser), - 'fieldsUp' => $this->getSchemaParser()->up(), - 'fieldsDown' => $this->getSchemaParser()->down(), - ])->render(); - } - - if ($stub === 'create' || (empty($stub) && $parser->isCreate())) { - return Stub::create('/migration/create.stub', [ - 'table' => $this->getTableName($parser), - 'fields' => $this->getSchemaParser()->render(), - ])->render(); - } - - if ($stub === 'delete' || (empty($stub) && $parser->isDelete())) { - return Stub::create('/migration/delete.stub', [ - 'table' => $this->getTableName($parser), - 'fieldsDown' => $this->getSchemaParser()->up(), - 'fieldsUp' => $this->getSchemaParser()->down(), - ])->render(); - } - - return Stub::create('/migration/plain.stub')->render(); - } - - /** - * @throws InvalidTableName - */ - protected function getTableName(NameParser $parser): string - { - $tableName = $parser->getTableName(); - - if (empty($tableName)) { - $tableName = $this->ask('Enter the table name'); - - if (empty($tableName)) { - throw new LogicException('The "table" option is required'); - } - } - - if (! $this->isValidTableName($tableName)) { - throw InvalidTableName::make($tableName); - } - - return $tableName; - } - - /** - * @throws InvalidTableName - */ - protected function generatePivotMigrationContent(NameParser $parser): string - { - $table = $this->getTableName($parser); - - $tables = explode('_', $table); - if (count($tables) === 2) { - $tableOne = Str::plural($tables[0]); - $tableTwo = Str::plural($tables[1]); - } - - $tableOne = $this->getOptionOrAsk( - 'tableOne', - 'Enter the name of first table', - $tableOne ?? '' - ); - $tableTwo = $this->getOptionOrAsk( - 'tableTwo', - 'Enter the name of second table', - $tableTwo ?? '' - ); - - if (! $this->isValidTableName($tableOne)) { - throw InvalidTableName::make($tableOne); - } - - if (! $this->isValidTableName($tableTwo)) { - throw InvalidTableName::make($tableTwo); - } - - return Stub::create('/migration/pivot.stub', [ - 'table' => $table, - 'tableOne' => $tableOne, - 'tableTwo' => $tableTwo, - 'columnOne' => $this->convertTableNameToPrimaryColumnName($tableOne), - 'columnTwo' => $this->convertTableNameToPrimaryColumnName($tableTwo), - ])->render(); - } - - protected function getSchemaParser(): SchemaParser - { - return new SchemaParser($this->option('fields')); - } - - protected function getFileName(): string - { - return now()->format('Y_m_d_His_') . Str::snake($this->nameArgument); - } - - protected function convertTableNameToPrimaryColumnName(string $tableName): string - { - return Str::singular($tableName) . '_id'; - } -} diff --git a/src/Commands/Generators/ModelMakeCommand.php b/src/Commands/Generators/ModelMakeCommand.php deleted file mode 100644 index 44cd497..0000000 --- a/src/Commands/Generators/ModelMakeCommand.php +++ /dev/null @@ -1,76 +0,0 @@ - 'Enter the model class name', - ]; - } - - protected function getContents(): string - { - $stub = 'plain'; - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - $factoryOption = $this->getOptionOrAsk( - 'factory', - question: 'Enter the class name of the factory to be used in the model (optional)', - required: false - ); - - if ($factoryOption) { - $factoryClass = $this->getFullClass( - $factoryOption, - GeneratorHelper::component(ModuleComponentType::Factory) - ->getFullNamespace($this->module) - ); - - $stub = 'full'; - $stubReplaces['factory'] = class_basename($factoryClass); - $stubReplaces['factoryNamespace'] = $this->getNamespaceOfClass($factoryClass); - } - - return Stub::create("model/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ModuleMakeCommand.php b/src/Commands/Generators/ModuleMakeCommand.php deleted file mode 100755 index 887f646..0000000 --- a/src/Commands/Generators/ModuleMakeCommand.php +++ /dev/null @@ -1,413 +0,0 @@ - 'Enter the name of the module to be created', - ]; - } - - /** - * Execute the console command. - */ - public function handle(): int - { - $nameArgument = trim($this->argument('name'), '/\\'); - $explodedNameArgument = explode('/', $nameArgument, 2); - - [$rawVendor, $rawModuleName] = empty($explodedNameArgument[1]) - ? ['', $explodedNameArgument[0]] - : $explodedNameArgument; - - $this->moduleStudlyName = Str::studly($rawModuleName); - $this->moduleName = Str::kebab($this->moduleStudlyName); - if (! $this->validateModuleStudlyName($this->moduleStudlyName)) { - $this->components->error("The module name passed is not valid!"); - - return self::FAILURE; - } - - $this->modulePackageName = sprintf( - '%s/%s', - Str::kebab($rawVendor ?: config('modules.composer.vendor', 'app')), - $this->moduleName - ); - - if (! Composer::isValidPackageName($this->modulePackageName)) { - $this->components->error("The package name '$this->modulePackageName' is not a valid composer package name!"); - - return self::FAILURE; - } - - if ($this->modulesRepository->has($this->modulePackageName)) { - $this->components->error("Module '$this->modulePackageName' already exist!"); - - return self::FAILURE; - } - - $this->modulePreset = $this->getOptionOrChoice( - optionName: 'preset', - question: 'Select the preset of module to create', - choices: [ - 'plain', - 'base', - 'api', - ], - default: 'plain' - ); - - if ($this->isFailure($this->generateComposerJsonFile())) { - return self::FAILURE; - } - - $this->modulesRepository->pruneModulesManifest(); - - if ($this->isFailure($this->generateComponents($this->modulesRepository->find($this->modulePackageName)))) { - return self::FAILURE; - } - - try { - $this->addModuleToComposer(); - } catch (ComposerException $exception) { - $this->components->error($exception->getMessage()); - $this->components->info("Please run composer update {$this->modulePackageName} manually"); - } - - return self::SUCCESS; - } - - protected function addModuleToComposer(): void - { - $initialWorkingDir = getcwd(); - $appBasePath = $this->laravel->basePath(); - chdir($appBasePath); - $moduleRelativePath = GeneratorHelper::makeRelativePath($appBasePath, GeneratorHelper::makeModulePath($this->moduleName)); - ComposerJsonFile::create(Factory::getComposerFile()) - ->addModule($this->modulePackageName, $moduleRelativePath) - ->save(); - chdir($initialWorkingDir); - - $composerClass = Composer::class; - $composer = $this->laravel[$composerClass]; - if (! ($composer instanceof Composer)) { - throw ComposerException::make("$composerClass not registered in your app."); - } - if (! $composer->updatePackages([$this->modulePackageName], false, $this->output)) { - throw ComposerException::make("Failed to update package with composer."); - } - } - - protected function generateComposerJsonFile(): int - { - $path = GeneratorHelper::makeModulePath($this->moduleName, 'composer.json'); - $contents = Stub::create("composer.json.stub", [ - 'modulePackageName' => $this->modulePackageName, - 'moduleName' => $this->moduleName, - 'moduleNamespace' => str_replace( - '\\', - '\\\\', - GeneratorHelper::makeModuleNamespace($this->moduleName) - ), - 'authorName' => $this->escapeJsonString(config('modules.composer.author.name', 'Example')), - 'authorEmail' => $this->escapeJsonString(config('modules.composer.author.email', 'example@example.com')), - ])->render(); - - return $this->generate($path, $contents); - } - - /** - * Escape a value for insertion between the quotes of a JSON string in the stub. - */ - protected function escapeJsonString(mixed $value): string - { - return substr(json_encode((string) $value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 1, -1); - } - - protected function generateComponents(Module $module): int - { - $statuses = [$this->generateProviders($module)]; - - if ($this->modulePreset !== 'plain') { - $statuses[] = $this->generateBaseComponents($module); - } - - if ($this->modulePreset === 'api') { - $statuses[] = $this->generateApiComponents($module); - } - - return $this->isFailure(...$statuses) ? self::FAILURE : self::SUCCESS; - } - - protected function generateBaseComponents(Module $module): int - { - $modulePackageName = $module->getPackageName(); - $entityName = $this->getEntityName(); - $snakeEntityName = Str::snake($entityName); - $snakePluralEntityName = Str::plural($snakeEntityName); - - return $this->isFailure( - $this->call('module:make:factory', [ - 'name' => "{$entityName}Factory", - 'module' => $modulePackageName, - '--model' => $entityName, - ]), - $this->call('module:make:migration', [ - 'name' => "create_{$snakePluralEntityName}_table", - 'module' => $modulePackageName, - '--stub' => 'create', - ]), - $this->call('module:make:seeder', [ - 'name' => "{$entityName}PermissionsSeeder_1", - 'module' => $modulePackageName, - '--stub' => 'permissions', - '--model' => $entityName, - ]), - $this->call('module:make:model', [ - 'name' => $entityName, - 'module' => $modulePackageName, - '--factory' => "{$entityName}Factory", - ]), - $this->call('module:make:policy', [ - 'name' => "{$entityName}Policy", - 'module' => $modulePackageName, - '--model' => $entityName, - ]) - ) ? self::FAILURE : self::SUCCESS; - } - - protected function generateApiComponents(Module $module): int - { - $actionVerbs = ['create', 'update', 'delete', 'list', 'view']; - - $modulePackageName = $module->getPackageName(); - $entityName = $this->getEntityName(); - $pluralEntityName = Str::plural($entityName); - $camelEntityName = Str::camel($entityName); - $kebabPluralEntityName = Str::kebab($pluralEntityName); - $snakePluralEntityName = Str::snake($pluralEntityName); - - $statuses = [ - $this->call('module:make:query-wizard', [ - 'name' => "{$pluralEntityName}QueryWizard", - 'module' => $modulePackageName, - '--stub' => 'eloquent', - ]), - $this->call('module:make:query-wizard', [ - 'name' => "{$entityName}QueryWizard", - 'module' => $modulePackageName, - '--stub' => 'model', - ]), - $this->call('module:make:resource', [ - 'name' => "{$entityName}Resource", - 'module' => $modulePackageName, - '--stub' => 'single', - ]), - $this->call('module:make:dto', [ - 'name' => "Create{$entityName}DTO", - 'module' => $modulePackageName, - ]), - $this->call('module:make:dto', [ - 'name' => "Update{$entityName}DTO", - 'module' => $modulePackageName, - ]), - ]; - - foreach ($actionVerbs as $actionVerb) { - $studlyActionVerb = Str::studly($actionVerb); - - $resourceClass = "{$entityName}Resource"; - $dtoClass = "{$studlyActionVerb}{$entityName}DTO"; - $routeName = 'api.' . $snakePluralEntityName . '.' . $actionVerb; - - if ($actionVerb === "list") { - $actionClass = "{$studlyActionVerb}{$pluralEntityName}Action"; - $requestClass = "{$studlyActionVerb}{$pluralEntityName}Request"; - $wizardClass = "{$pluralEntityName}QueryWizard"; - } else { - $actionClass = "{$studlyActionVerb}{$entityName}Action"; - $requestClass = "{$studlyActionVerb}{$entityName}Request"; - $wizardClass = "{$entityName}QueryWizard"; - } - $statuses[] = $this->call('module:make:action', [ - 'name' => $actionClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--dto' => $dtoClass, - '--model' => $entityName, - '--request' => $requestClass, - '--resource' => $resourceClass, - '--wizard' => $wizardClass, - ]); - $statuses[] = $this->call('module:make:request', [ - 'name' => $requestClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--ui' => 'api', - '--dto' => $dtoClass, - '--model' => $entityName, - ]); - - $actionMethodsMap = [ - 'create' => 'post', - 'update' => 'patch', - 'delete' => 'delete', - 'list' => 'get', - 'view' => 'get', - ]; - - $url = $kebabPluralEntityName; - if (in_array($actionVerb, ['update', 'delete', 'view'])) { - $url .= '/{' . $camelEntityName . '}'; - } - - $filePath = Str::snake(str_replace('Action', '', $actionClass), '_'); - $filePath = 'v1/' . $filePath; - - $statuses[] = $this->call('module:make:route', [ - 'name' => $filePath, - 'module' => $modulePackageName, - '--ui' => 'api', - '--action' => $actionClass, - '--method' => $actionMethodsMap[$actionVerb], - '--url' => $url, - '--name' => $routeName, - ]); - - $testClass = $actionVerb === 'list' - ? "{$studlyActionVerb}{$pluralEntityName}Test" - : "{$studlyActionVerb}{$entityName}Test"; - - $statuses[] = $this->call('module:make:test', [ - 'name' => $testClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--type' => 'api', - '--model' => $entityName, - '--route' => $routeName, - ]); - } - - return $this->isFailure(...$statuses) ? self::FAILURE : self::SUCCESS; - } - - protected function generateProviders(Module $module): int - { - return $this->isFailure( - $this->call('module:make:provider', [ - 'name' => "{$module->getStudlyName()}ServiceProvider", - 'module' => $module->getPackageName(), - '--stub' => 'module', - ]), - $this->call('module:make:provider', [ - 'name' => 'RouteServiceProvider', - 'module' => $module->getPackageName(), - '--stub' => 'route', - ]) - ) ? self::FAILURE : self::SUCCESS; - } - - /** - * @param int ...$statuses - * @return bool - */ - protected function isFailure(...$statuses): bool - { - foreach ($statuses as $status) { - if ($status !== self::SUCCESS) { - return true; - } - } - - return false; - } - - protected function validateModuleStudlyName(string $name): bool - { - return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name); - } - - protected function getEntityName(): string - { - if ($this->entityName !== null) { - return $this->entityName; - } - - return $this->entityName = Str::studly($this->getOptionOrAsk( - optionName: 'entity', - question: 'Enter the entity name (used to create module components)', - default: $this->moduleStudlyName - )); - } - - /** - * Not used by ModuleMakeCommand as it fully overrides handle(). - * Required by abstract base class. - */ - protected function getContents(): string - { - return ''; - } -} diff --git a/src/Commands/Generators/NotificationMakeCommand.php b/src/Commands/Generators/NotificationMakeCommand.php deleted file mode 100644 index 86deaf9..0000000 --- a/src/Commands/Generators/NotificationMakeCommand.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Enter the notification class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("notification/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ObserverMakeCommand.php b/src/Commands/Generators/ObserverMakeCommand.php deleted file mode 100644 index 30963ba..0000000 --- a/src/Commands/Generators/ObserverMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the observer class name', - ]; - } - - protected function getContents(): string - { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the observer', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'model' => class_basename($modelClass), - 'modelCamelCase' => Str::camel(class_basename($modelClass)), - 'modelNamespace' => $this->getNamespaceOfClass($modelClass), - ]; - - return Stub::create("observer.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/PolicyMakeCommand.php b/src/Commands/Generators/PolicyMakeCommand.php deleted file mode 100644 index 1ef9a1d..0000000 --- a/src/Commands/Generators/PolicyMakeCommand.php +++ /dev/null @@ -1,87 +0,0 @@ - 'Enter the policy class name', - ]; - } - - protected function getContents(): string - { - $stub = 'plain'; - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - $modelOption = $this->getOptionOrAsk( - 'model', - question: 'Enter the class name of the model to be used in the policy (optional)', - required: false - ); - - if ($modelOption) { - $modelClass = $this->getFullClass( - $modelOption, - GeneratorHelper::component(ModuleComponentType::Model) - ->getFullNamespace($this->module) - ); - - $stub = 'full'; - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - $stubReplaces['modelKebabCase'] = Str::kebab($stubReplaces['model']); - $stubReplaces['modelsKebabCase'] = Str::kebab(Str::plural($stubReplaces['model'])); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - if ($stubReplaces['modelCamelCase'] === 'user') { - $stubReplaces['modelCamelCase'] = 'model'; - } - - $fullUserClass = $this->getUserModelClass(); - $stubReplaces['user'] = class_basename($fullUserClass); - $stubReplaces['userNamespace'] = $this->getNamespaceOfClass($fullUserClass); - } - - return Stub::create("policy/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ProviderMakeCommand.php b/src/Commands/Generators/ProviderMakeCommand.php deleted file mode 100644 index f2f4fe1..0000000 --- a/src/Commands/Generators/ProviderMakeCommand.php +++ /dev/null @@ -1,129 +0,0 @@ - 'Enter the provider class name', - ]; - } - - protected function getContents(): string - { - $this->selectedStub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'module', 'event', 'route'], - 'plain' - ); - - $this->stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - $providerDir = GeneratorHelper::component(ModuleComponentType::Provider)->getFullPath($this->module); - - if ($this->selectedStub === 'module') { - $this->stubReplaces = array_merge($this->stubReplaces, [ - 'modulePackageName' => $this->module->getPackageName(), - 'moduleNameKebabCase' => $this->module->getKebabName(), - 'commandsNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::CliCommand)->getFullNamespace($this->module)), - 'commandsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::CliCommand)->getFullPath($this->module) - ), - 'configPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Config)->getFullPath($this->module) - ), - 'langPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Lang)->getFullPath($this->module) - ), - 'viewsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::View)->getFullPath($this->module) - ), - 'migrationsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Migration)->getFullPath($this->module) - ), - ]); - } elseif ($this->selectedStub === 'route') { - $this->stubReplaces = array_merge($this->stubReplaces, [ - 'modulePackageName' => $this->module->getPackageName(), - 'webControllerNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::WebController)->getFullNamespace($this->module)), - 'apiControllerNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::ApiController)->getFullNamespace($this->module)), - 'webRoutesPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::WebRoute)->getFullPath($this->module) - ), - 'apiRoutesPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::ApiRoute)->getFullPath($this->module) - ), - ]); - } - - return Stub::create("provider/{$this->selectedStub}.stub", $this->stubReplaces)->render(); - } - - protected function afterGenerate(): void - { - $this->laravel->make(ModuleConfigWriter::class) - ->addProvider($this->module, $this->stubReplaces['namespace'] . '\\' . $this->stubReplaces['class']); - } -} diff --git a/src/Commands/Generators/QueryWizardMakeCommand.php b/src/Commands/Generators/QueryWizardMakeCommand.php deleted file mode 100644 index 79dac50..0000000 --- a/src/Commands/Generators/QueryWizardMakeCommand.php +++ /dev/null @@ -1,72 +0,0 @@ - 'Enter the query-wizard class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('jackardios/laravel-query-wizard'); - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['eloquent', 'model'], - 'eloquent' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("query-wizard/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/RequestMakeCommand.php b/src/Commands/Generators/RequestMakeCommand.php deleted file mode 100644 index 88c6f54..0000000 --- a/src/Commands/Generators/RequestMakeCommand.php +++ /dev/null @@ -1,109 +0,0 @@ - 'Enter the request class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the request will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiRequest - : ModuleComponentType::WebRequest; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - optionName: 'stub', - question: 'Select the stub you want to use for generator', - choices: ($this->ui === "web") - ? ['plain', 'create', 'delete', 'update'] - : ['plain', 'create', 'delete', 'list', 'update', 'view'], - default: 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub !== 'plain') { - if ($stub === 'create' || $stub === 'update') { - $dtoClass = $this->getFullClassFromOptionOrAsk( - optionName: 'dto', - question: 'Enter the class name of the DTO to be used in the request', - componentType: ModuleComponentType::Dto, - module: $this->module - ); - $stubReplaces['dto'] = class_basename($dtoClass); - $stubReplaces['dtoNamespace'] = $this->getNamespaceOfClass($dtoClass); - } - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the request', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - } - - return Stub::create("request/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ResourceMakeCommand.php b/src/Commands/Generators/ResourceMakeCommand.php deleted file mode 100644 index 7abae6d..0000000 --- a/src/Commands/Generators/ResourceMakeCommand.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Enter the resource class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['single', 'collection'], - 'eloquent' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("resource/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/RouteMakeCommand.php b/src/Commands/Generators/RouteMakeCommand.php deleted file mode 100644 index e34618d..0000000 --- a/src/Commands/Generators/RouteMakeCommand.php +++ /dev/null @@ -1,165 +0,0 @@ - 'Enter the route class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the route will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiRoute - : ModuleComponentType::WebRoute; - } - - protected function getContents(): string - { - $url = $this->getOptionOrAsk( - 'url', - 'Enter the route URL', - ); - $method = $this->getOptionOrChoice( - 'method', - 'Select the HTTP request method', - ['get', 'post', 'put', 'patch', 'delete', 'options'], - 'get' - ); - $name = $this->getOptionOrAsk( - 'name', - 'Enter the route name', - $this->generateDefaultRouteName($url, $method), - ); - $actionClass = $this->getFullClassFromOptionOrAsk( - optionName: 'action', - question: 'Enter the class name of the action to be used in the route', - componentType: ModuleComponentType::Action, - module: $this->module - ); - $stubReplaces = [ - 'method' => $method, - 'url' => $url, - 'name' => $name, - ]; - $stubReplaces['action'] = class_basename($actionClass); - $stubReplaces['actionNamespace'] = $this->getNamespaceOfClass($actionClass); - - return Stub::create("route.stub", $stubReplaces)->render(); - } - - protected function generateDefaultActionName(string $url, string $method): string - { - $verb = $this->recognizeActionVerbByMethod($url, $method); - $studlyVerb = Str::studly($verb); - $resource = Str::studly($this->recognizeResourceByUrl($url)); - - if ($verb === 'list') { - $resource = Str::plural($resource); - } else { - $resource = Str::singular($resource); - } - - if ($verb === 'options') { - return $resource . $studlyVerb . 'Action'; - } - - return $studlyVerb . $resource . 'Action'; - } - - protected function generateDefaultRouteName(string $url, string $method): string - { - $verb = $this->recognizeActionVerbByMethod($url, $method); - $resource = Str::snake($this->recognizeResourceByUrl($url)); - - return Str::lower($this->ui) . '.' . $resource . '.' . $verb; - } - - protected function recognizeActionVerbByMethod(string $url, string $method): string - { - if ($method === 'get') { - $lastUrlPart = basename($url); - - if ($this->urlHasParameters($lastUrlPart)) { - return 'view'; - } - - return 'list'; - } - - return in_array($method, ['post', 'put', 'patch']) ? 'update' : $method; - } - - protected function recognizeResourceByUrl(string $url): string - { - $urlParts = explode('/', $url); - $urlPartsNumber = count($urlParts); - $lastUrlPart = $urlParts[$urlPartsNumber - 1]; - - if (! $this->urlHasParameters($lastUrlPart)) { - return $lastUrlPart; - } - - if ($urlPartsNumber > 1) { - $resource = $urlParts[$urlPartsNumber - 2]; - } else { - $resource = $lastUrlPart; - } - - return str_replace(['{', '}'], '', $resource); - } - - protected function urlHasParameters(string $url): bool - { - return Str::contains($url, '{'); - } -} diff --git a/src/Commands/Generators/RuleMakeCommand.php b/src/Commands/Generators/RuleMakeCommand.php deleted file mode 100644 index 2853588..0000000 --- a/src/Commands/Generators/RuleMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the rule class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("rule.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/SeederMakeCommand.php b/src/Commands/Generators/SeederMakeCommand.php deleted file mode 100644 index 32a538f..0000000 --- a/src/Commands/Generators/SeederMakeCommand.php +++ /dev/null @@ -1,84 +0,0 @@ - 'Enter the seeder class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'permissions'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub === 'permissions') { - $createPermissionActionClass = $this->getCreatePermissionActionClass(); - $stubReplaces['createPermissionAction'] = class_basename($createPermissionActionClass); - $stubReplaces['createPermissionActionNamespace'] = $this->getNamespaceOfClass($createPermissionActionClass); - - $createPermissionDTOClass = $this->getCreatePermissionDTOClass(); - $stubReplaces['createPermissionDTO'] = class_basename($createPermissionDTOClass); - $stubReplaces['createPermissionDTONamespace'] = $this->getNamespaceOfClass($createPermissionDTOClass); - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the seeder', - componentType: ModuleComponentType::Model, - module: $this->module - ); - - $stubReplaces['modelKebabCase'] = Str::kebab(class_basename($modelClass)); - $stubReplaces['modelsKebabCase'] = Str::plural($stubReplaces['modelKebabCase']); - } - - return Stub::create("seeder/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/TestMakeCommand.php b/src/Commands/Generators/TestMakeCommand.php deleted file mode 100644 index 37395e3..0000000 --- a/src/Commands/Generators/TestMakeCommand.php +++ /dev/null @@ -1,121 +0,0 @@ - 'Enter the test class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->type = $this->getOptionOrChoice( - 'type', - question: 'Enter the type of test to be created', - choices: ['unit', 'feature', 'api', 'web', 'cli'], - default: 'unit' - ); - $this->componentType = match($this->type) { - 'unit' => ModuleComponentType::UnitTest, - 'feature' => ModuleComponentType::FeatureTest, - 'api' => ModuleComponentType::ApiTest, - 'web' => ModuleComponentType::WebTest, - 'cli' => ModuleComponentType::CliTest, - default => ModuleComponentType::UnitTest, - }; - } - - protected function getContents(): string - { - $stubChoices = match($this->type) { - 'unit' => ['plain'], - 'feature' => ['plain'], - 'api' => ['plain', 'create', 'delete', 'list', 'update', 'view'], - 'web' => ['plain', 'create', 'delete', 'update'], - 'cli' => ['plain'], - default => ['plain'], - }; - - $stub = count($stubChoices) === 1 - ? $stubChoices[0] - : $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - $stubChoices, - 'plain' - ); - - $stubReplaces = [ - 'group' => $this->module->getKebabName(), - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub !== 'plain') { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the test', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - $stubReplaces['models'] = Str::plural($stubReplaces['model']); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelSnakeCase'] = Str::snake($stubReplaces['model']); - $stubReplaces['modelsSnakeCase'] = Str::snake($stubReplaces['models']); - $stubReplaces['modelKebabCase'] = Str::kebab($stubReplaces['model']); - $stubReplaces['modelsKebabCase'] = Str::plural($stubReplaces['modelKebabCase']); - } - - if (in_array($this->type, ['api', 'web'])) { - $stubReplaces['routeName'] = $this->getOptionOrAsk( - 'route', - 'Enter the route name for HTTP tests' - ); - } - - return Stub::create("test/{$this->type}/{$stub}.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/stubs/action/create.stub b/src/Commands/Generators/stubs/action/create.stub deleted file mode 100644 index 1340d64..0000000 --- a/src/Commands/Generators/stubs/action/create.stub +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController({{ request }} $request): JsonResponse - { - ${{ modelCamelCase }} = $this->handle($request->toDTO()); - - return (new {{ resource }}(${{ modelCamelCase }}))->created(); - } -} diff --git a/src/Commands/Generators/stubs/action/delete.stub b/src/Commands/Generators/stubs/action/delete.stub deleted file mode 100644 index b210dcc..0000000 --- a/src/Commands/Generators/stubs/action/delete.stub +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): Response - { - $this->handle(${{ modelCamelCase }}); - - return $this->noContent(); - } -} diff --git a/src/Commands/Generators/stubs/action/list.stub b/src/Commands/Generators/stubs/action/list.stub deleted file mode 100644 index 011b4b2..0000000 --- a/src/Commands/Generators/stubs/action/list.stub +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController({{ request }} $request): AnonymousResourceCollection - { - return {{ resource }}::collection($this->handle($request)); - } -} diff --git a/src/Commands/Generators/stubs/action/plain.stub b/src/Commands/Generators/stubs/action/plain.stub deleted file mode 100644 index 32d49ea..0000000 --- a/src/Commands/Generators/stubs/action/plain.stub +++ /dev/null @@ -1,15 +0,0 @@ -all(); - - if ($data) { - ${{ modelCamelCase }}->update($data); - } - - return ${{ modelCamelCase }}; - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): {{ resource }} - { - ${{ modelCamelCase }} = $this->handle(${{ modelCamelCase }}, $request->toDTO()); - - return new {{ resource }}(${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/action/view.stub b/src/Commands/Generators/stubs/action/view.stub deleted file mode 100644 index 7b16531..0000000 --- a/src/Commands/Generators/stubs/action/view.stub +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): {{ resource }} - { - return new {{ resource }}($this->handle($request, ${{ modelCamelCase }})); - } -} diff --git a/src/Commands/Generators/stubs/command.stub b/src/Commands/Generators/stubs/command.stub deleted file mode 100755 index 8adfa16..0000000 --- a/src/Commands/Generators/stubs/command.stub +++ /dev/null @@ -1,30 +0,0 @@ -|{{ model }} create($attributes = [], ?{{ model }} $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method {{ model }} createOne($attributes = []) - * @method \Illuminate\Support\Collection|{{ model }} make($attributes = [], ?{{ model }} $parent = null) - * @method {{ model }} makeOne($attributes = []) - */ -class {{ class }} extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = {{ model }}::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/src/Commands/Generators/stubs/job/plain.stub b/src/Commands/Generators/stubs/job/plain.stub deleted file mode 100644 index a539371..0000000 --- a/src/Commands/Generators/stubs/job/plain.stub +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function attachments(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/middleware.stub b/src/Commands/Generators/stubs/middleware.stub deleted file mode 100755 index 323c213..0000000 --- a/src/Commands/Generators/stubs/middleware.stub +++ /dev/null @@ -1,20 +0,0 @@ -id(); -{{ fields }} - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('{{ table }}'); - } -}; diff --git a/src/Commands/Generators/stubs/migration/delete.stub b/src/Commands/Generators/stubs/migration/delete.stub deleted file mode 100755 index 8837445..0000000 --- a/src/Commands/Generators/stubs/migration/delete.stub +++ /dev/null @@ -1,28 +0,0 @@ -unsignedBigInteger('{{ columnOne }}')->index(); - $table->foreign('{{ columnOne }}')->references('id')->on('{{ tableOne }}')->onDelete('cascade'); - $table->unsignedBigInteger('{{ columnTwo }}')->index(); - $table->foreign('{{ columnTwo }}')->references('id')->on('{{ tableTwo }}')->onDelete('cascade'); - $table->primary(['{{ columnOne }}', '{{ columnTwo }}']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('{{ table }}'); - } -}; diff --git a/src/Commands/Generators/stubs/migration/plain.stub b/src/Commands/Generators/stubs/migration/plain.stub deleted file mode 100755 index 88fa2f3..0000000 --- a/src/Commands/Generators/stubs/migration/plain.stub +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail', 'database']; - } - - /** - * Get the array representation of the notification. - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/src/Commands/Generators/stubs/notification/queued.stub b/src/Commands/Generators/stubs/notification/queued.stub deleted file mode 100644 index d4d0905..0000000 --- a/src/Commands/Generators/stubs/notification/queued.stub +++ /dev/null @@ -1,54 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/src/Commands/Generators/stubs/observer.stub b/src/Commands/Generators/stubs/observer.stub deleted file mode 100644 index cff4f33..0000000 --- a/src/Commands/Generators/stubs/observer.stub +++ /dev/null @@ -1,48 +0,0 @@ -can('view-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can view the model. - */ - public function view({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('view-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can create models. - */ - public function create({{ user }} $user): bool - { - return $user->can('create-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can update the model. - */ - public function update({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('update-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('delete-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('delete-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('force-delete-{{ modelKebabCase }}'); - } -} diff --git a/src/Commands/Generators/stubs/policy/plain.stub b/src/Commands/Generators/stubs/policy/plain.stub deleted file mode 100644 index e2506b7..0000000 --- a/src/Commands/Generators/stubs/policy/plain.stub +++ /dev/null @@ -1,8 +0,0 @@ -> - */ - protected $listen = []; - - /** - * Register any events for your application. - */ - public function boot(): void - { - // - } -} diff --git a/src/Commands/Generators/stubs/provider/module.stub b/src/Commands/Generators/stubs/provider/module.stub deleted file mode 100644 index 429cd9d..0000000 --- a/src/Commands/Generators/stubs/provider/module.stub +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/{{ configPath }}/{{ moduleNameKebabCase }}.php'; - $configsPath = $this->app->configPath('{{ moduleNameKebabCase }}.php'); - - $this->mergeConfigFrom($sourcePath, '{{ moduleNameKebabCase }}'); - - $this->publishes([ - $sourcePath => $configsPath - ], '{{ moduleNameKebabCase }}-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/{{ migrationsPath }}'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], '{{ moduleNameKebabCase }}-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - '{{ commandsNamespace }}' => __DIR__.'/{{ commandsPath }}', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/{{ langPath }}'; - $langPath = $this->app->langPath('modules/{{ moduleNameKebabCase }}'); - - $this->loadTranslationsFrom($sourcePath, '{{ moduleNameKebabCase }}'); - - $this->publishes([ - $sourcePath => $langPath, - ], '{{ moduleNameKebabCase }}-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/{{ viewsPath }}'; - $viewsPath = $this->app->resourcePath('views/modules/{{ moduleNameKebabCase }}'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('{{ moduleNameKebabCase }}'), [$sourcePath]), - '{{ moduleNameKebabCase }}' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], '{{ moduleNameKebabCase }}-views'); - } -} diff --git a/src/Commands/Generators/stubs/provider/plain.stub b/src/Commands/Generators/stubs/provider/plain.stub deleted file mode 100644 index 537ce0a..0000000 --- a/src/Commands/Generators/stubs/provider/plain.stub +++ /dev/null @@ -1,24 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('{{ webControllerNamespace }}') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/{{ webRoutesPath }}'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('{{ apiControllerNamespace }}') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/{{ apiRoutesPath }}'); - }); - } -} diff --git a/src/Commands/Generators/stubs/query-wizard/eloquent.stub b/src/Commands/Generators/stubs/query-wizard/eloquent.stub deleted file mode 100644 index a001698..0000000 --- a/src/Commands/Generators/stubs/query-wizard/eloquent.stub +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/query-wizard/model.stub b/src/Commands/Generators/stubs/query-wizard/model.stub deleted file mode 100644 index 2b878ef..0000000 --- a/src/Commands/Generators/stubs/query-wizard/model.stub +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/request/create.stub b/src/Commands/Generators/stubs/request/create.stub deleted file mode 100644 index 1a2bc08..0000000 --- a/src/Commands/Generators/stubs/request/create.stub +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/src/Commands/Generators/stubs/request/delete.stub b/src/Commands/Generators/stubs/request/delete.stub deleted file mode 100644 index 837e84f..0000000 --- a/src/Commands/Generators/stubs/request/delete.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('delete', ${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/request/list.stub b/src/Commands/Generators/stubs/request/list.stub deleted file mode 100644 index 8284481..0000000 --- a/src/Commands/Generators/stubs/request/list.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('update', ${{ modelCamelCase }}); - } - - public function toDTO(): {{ dto }} - { - return {{ dto }}::from($this->validated()); - } -} diff --git a/src/Commands/Generators/stubs/request/view.stub b/src/Commands/Generators/stubs/request/view.stub deleted file mode 100644 index e5c4e85..0000000 --- a/src/Commands/Generators/stubs/request/view.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('view', ${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/resource/collection.stub b/src/Commands/Generators/stubs/resource/collection.stub deleted file mode 100644 index 88f416b..0000000 --- a/src/Commands/Generators/stubs/resource/collection.stub +++ /dev/null @@ -1,17 +0,0 @@ -name('{{ name }}'); diff --git a/src/Commands/Generators/stubs/rule.stub b/src/Commands/Generators/stubs/rule.stub deleted file mode 100755 index e54d7ef..0000000 --- a/src/Commands/Generators/stubs/rule.stub +++ /dev/null @@ -1,19 +0,0 @@ -handle(new {{ createPermissionDTO }}( - name: 'view-{{ modelKebabCase }}', - display_name: 'View any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'create-{{ modelKebabCase }}', - display_name: 'Create "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'update-{{ modelKebabCase }}', - display_name: 'Update any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'delete-{{ modelKebabCase }}', - display_name: 'Delete any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'force-delete-{{ modelKebabCase }}', - display_name: 'Force delete any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - } -} diff --git a/src/Commands/Generators/stubs/test/api/create.stub b/src/Commands/Generators/stubs/test/api/create.stub deleted file mode 100644 index 6129bc4..0000000 --- a/src/Commands/Generators/stubs/test/api/create.stub +++ /dev/null @@ -1,79 +0,0 @@ - 'create-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_create_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas({{ model }}::class, $data); - } - - public function test_create_{{ modelSnakeCase }}_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('{{ routeName }}'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_{{ modelSnakeCase }}_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertUnauthorized(); - } - - public function test_create_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/src/Commands/Generators/stubs/test/api/delete.stub b/src/Commands/Generators/stubs/test/api/delete.stub deleted file mode 100644 index ab949bc..0000000 --- a/src/Commands/Generators/stubs/test/api/delete.stub +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_delete_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertNoContent(); - - $this->assertNull({{ model }}::find(${{ modelCamelCase }}->getKey())); - } - - public function test_delete_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/api/list.stub b/src/Commands/Generators/stubs/test/api/list.stub deleted file mode 100644 index d07d8a9..0000000 --- a/src/Commands/Generators/stubs/test/api/list.stub +++ /dev/null @@ -1,57 +0,0 @@ - 'view-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_list_{{ modelsSnakeCase }}(): void - { - $this->actingAsTestUser(); - - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_{{ modelsSnakeCase }}_unauthenticated(): void - { - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertUnauthorized(); - } - - public function test_list_{{ modelsSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertForbidden(); - } -} diff --git a/src/Commands/Generators/stubs/test/api/plain.stub b/src/Commands/Generators/stubs/test/api/plain.stub deleted file mode 100644 index f9c09b4..0000000 --- a/src/Commands/Generators/stubs/test/api/plain.stub +++ /dev/null @@ -1,27 +0,0 @@ - '', - 'roles' => '', - ]; - - public function test(): void - { - // - } -} diff --git a/src/Commands/Generators/stubs/test/api/update.stub b/src/Commands/Generators/stubs/test/api/update.stub deleted file mode 100644 index 6ca199f..0000000 --- a/src/Commands/Generators/stubs/test/api/update.stub +++ /dev/null @@ -1,99 +0,0 @@ - 'update-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_update_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => ${{ modelCamelCase }}->getKey(), - ]); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas({{ model }}::class, $expectedData); - } - - public function test_update_{{ modelSnakeCase }}_with_invalid_data(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/src/Commands/Generators/stubs/test/api/view.stub b/src/Commands/Generators/stubs/test/api/view.stub deleted file mode 100644 index 810f24c..0000000 --- a/src/Commands/Generators/stubs/test/api/view.stub +++ /dev/null @@ -1,67 +0,0 @@ - 'view-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_view_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - $expectedData = ${{ modelCamelCase }}->toArray(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/cli/plain.stub b/src/Commands/Generators/stubs/test/cli/plain.stub deleted file mode 100644 index b816be1..0000000 --- a/src/Commands/Generators/stubs/test/cli/plain.stub +++ /dev/null @@ -1,16 +0,0 @@ - 'create-{{ modelKebabCase }}', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_create_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->post(route('{{ routeName }}'), $data) - ->assertCreated(); - - $this->assertDatabaseHas({{ model }}::class, $data); - } - - public function test_create_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->post(route('{{ routeName }}'), $data) - ->assertForbidden(); - } -} diff --git a/src/Commands/Generators/stubs/test/web/delete.stub b/src/Commands/Generators/stubs/test/web/delete.stub deleted file mode 100644 index 2709649..0000000 --- a/src/Commands/Generators/stubs/test/web/delete.stub +++ /dev/null @@ -1,50 +0,0 @@ - 'delete-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_delete_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertNoContent(); - - $this->assertNull({{ model }}::find(${{ modelCamelCase }}->getKey())); - } - - public function test_delete_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/web/plain.stub b/src/Commands/Generators/stubs/test/web/plain.stub deleted file mode 100644 index ab05743..0000000 --- a/src/Commands/Generators/stubs/test/web/plain.stub +++ /dev/null @@ -1,16 +0,0 @@ - 'update-{{ modelKebabCase }}', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_update_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => ${{ modelCamelCase }}->getKey(), - ]); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertOk(); - - $this->assertDatabaseHas({{ model }}::class, $expectedData); - } - - public function test_update_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777]), $data) - ->assertNotFound(); - } -} diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php index 67eb3ab..bcd2965 100644 --- a/src/Commands/ListCommand.php +++ b/src/Commands/ListCommand.php @@ -1,38 +1,89 @@ manifest(); + + if ($manifest === []) { + $this->components->warn("No modules found in [{$composer->relative($modules->modulesPath())}]."); + + return self::SUCCESS; + } + + $verbose = $this->output->isVerbose(); + $rows = []; + + foreach ($manifest as $name => $module) { + $row = [$name, $module['package'], $module['namespace'], $composer->relative($module['path'])]; + + if ($verbose) { + $row[] = $this->resources($module); + } + + $rows[] = $row; + } + + $this->table(['Name', 'Package', 'Namespace', 'Path', ...($verbose ? ['Loads'] : [])], $rows); + + return self::SUCCESS; + } + /** - * The console command description. - * - * @var string + * @param ModuleManifest $module */ - protected $description = 'Show list of all modules.'; + private function resources(array $module): string + { + $seeders = count(self::concrete(array_merge(...array_values($module['seeders'])), Seeder::class)); + $commands = count(self::concrete($module['commands'], SymfonyCommand::class)); + + return implode(', ', array_filter([ + $module['config'] === [] ? null : 'config ('.implode(', ', $module['config']).')', + $module['lang'] ? ($module['json'] ? 'lang (+json)' : 'lang') : null, + $module['views'] ? 'views' : null, + $module['migrations'] ? 'migrations' : null, + ...array_map( + static fn (string $group, array $files): string => "{$group} routes (".array_sum(array_map(count(...), $files)).')', + array_keys($module['routes']), + $module['routes'], + ), + $seeders === 0 ? null : "seeders ({$seeders})", + $commands === 0 ? null : "commands ({$commands})", + ])) ?: '-'; + } /** - * Execute the console command. + * The manifest lists every class of the directory; only those that are loaded count. + * + * @param list $classes + * @param class-string $parent + * @return list */ - public function handle(): int + private static function concrete(array $classes, string $parent): array { - $this->table( - ['Package Name', 'Namespace', 'Path'], - collect($this->modulesRepository->getModules()) - ->map(fn (Module $module) => [$module->getPackageName(), $module->getNamespace(), $module->getPath()]) - ->values() - ->toArray() - ); - - return self::SUCCESS; + return array_values(array_filter( + $classes, + static fn (string $class): bool => is_subclass_of($class, $parent) && ! (new ReflectionClass($class))->isAbstract(), + )); } } diff --git a/src/Commands/MakeCommand.php b/src/Commands/MakeCommand.php new file mode 100644 index 0000000..f44279f --- /dev/null +++ b/src/Commands/MakeCommand.php @@ -0,0 +1,143 @@ +" of the application} + {--no-update : Do not run "composer update" for the module}'; + + public function handle( + ModuleRepository $modules, + TemplateRenderer $renderer, + ApplicationComposer $composer, + ComposerRunner $runner, + Filesystem $files, + ): int { + try { + $name = $this->input->getArgument('name'); + $name = Names::module(is_string($name) ? $name : ''); + $package = Names::package(Config::string('modules.vendor'), $name); + $path = $modules->modulesPath().'/'.$name; + + if (file_exists($path) || is_link($path)) { + $this->components->error("The directory [{$composer->relative($path)}] already exists."); + + return self::FAILURE; + } + + $preset = $this->input->getOption('preset'); + $preset = is_string($preset) ? $preset : 'default'; + + $rendered = $renderer->render($this->template($preset), [ + 'name' => $name, + 'namespace' => Names::namespace(Config::string('modules.namespace'), $name), + 'package' => $package, + 'vendor' => explode('/', $package)[0], + 'date' => Date::now()->format('Y_m_d_His'), + ]); + + if (! isset($rendered['composer.json'])) { + throw InvalidTemplate::at($preset, 'the template must contain a "composer.json" or "composer.json.stub" file.'); + } + + $this->write($files, $path, $rendered); + + try { + $modules->refresh(); + $module = $modules->get($name); + + if ($module->package !== $package) { + throw InvalidTemplate::at('composer.json', "the package name must be [{$package}], [{$module->package}] given."); + } + + $root = $composer->composerJson(); + $composer->addModule($root, $module, $modules->modulesPath()); + $root->save(); + } catch (Throwable $exception) { + $files->deleteDirectory($path); + $modules->refresh(); + + throw $exception; + } + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->components->info("Module [{$name}] created in [{$composer->relative($path)}]."); + + if ($this->option('no-update')) { + $this->components->warn('Install it with: '.$runner->commandLine('update', [$package])); + + return self::SUCCESS; + } + + if (! $runner->update([$package], $this->streamOutput(...))) { + $this->components->error('Composer failed to install the module. Fix the problem and run: '.$runner->commandLine('update', [$package])); + + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function template(string $preset): string + { + if (preg_match('/^[a-z0-9][a-z0-9_-]*$/D', $preset) !== 1) { + throw InvalidTemplate::at($preset, 'the preset name may contain lowercase latin letters, digits, "-" and "_".'); + } + + if (is_dir($template = $this->laravel->basePath('stubs/module/'.$preset))) { + return $template; + } + + if ($preset === 'default') { + return dirname(__DIR__, 2).'/resources/stubs/module/default'; + } + + throw InvalidTemplate::at("stubs/module/{$preset}", 'the preset does not exist.'); + } + + /** + * @param array $rendered + */ + private function write(Filesystem $files, string $path, array $rendered): void + { + try { + foreach ($rendered as $file => $contents) { + $files->ensureDirectoryExists(dirname($path.'/'.$file)); + $files->put($path.'/'.$file, $contents); + } + } catch (Throwable $exception) { + $files->deleteDirectory($path); + + throw $exception; + } + } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } +} diff --git a/src/Commands/MigrateCommand.php b/src/Commands/MigrateCommand.php deleted file mode 100644 index d6ad43e..0000000 --- a/src/Commands/MigrateCommand.php +++ /dev/null @@ -1,55 +0,0 @@ -call('migrate', [ - '--path' => $this->getMigrationPaths($module), - '--realpath' => (bool) $this->option('realpath'), - '--database' => $this->option('database'), - '--schema-path' => $this->option('schema-path'), - '--pretend' => (bool) $this->option('pretend'), - '--step' => (bool) $this->option('step'), - '--force' => true, - ]); - - if ($this->option('seed') && ! $this->option('pretend')) { - $this->call('module:seed', [ - 'module' => $module->getName(), - '--class' => $this->option('seeder'), - '--force' => true, - ]); - } - } -} diff --git a/src/Commands/MigrateRefreshCommand.php b/src/Commands/MigrateRefreshCommand.php deleted file mode 100644 index 8ce4c2b..0000000 --- a/src/Commands/MigrateRefreshCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -confirmToProceed()) { - return self::FAILURE; - } - - $this->call('module:migrate:reset', [ - 'module' => $this->argument('module'), - '--realpath' => $this->option('realpath'), - '--database' => $this->option('database'), - '--pretend' => $this->option('pretend'), - '--force' => true, - ]); - - $this->call('module:migrate', [ - 'module' => $this->argument('module'), - '--realpath' => $this->option('realpath'), - '--database' => $this->option('database'), - '--schema-path' => $this->option('schema-path'), - '--pretend' => $this->option('pretend'), - '--seed' => $this->option('seed'), - '--seeder' => $this->option('seeder'), - '--step' => $this->option('step'), - '--force' => true, - ]); - - return self::SUCCESS; - } -} diff --git a/src/Commands/MigrateResetCommand.php b/src/Commands/MigrateResetCommand.php deleted file mode 100644 index 248e9dc..0000000 --- a/src/Commands/MigrateResetCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -call('migrate:reset', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--realpath' => (bool) $this->option('realpath'), - '--pretend' => (bool) $this->option('pretend'), - '--force' => true, - ]); - } -} diff --git a/src/Commands/MigrateRollbackCommand.php b/src/Commands/MigrateRollbackCommand.php deleted file mode 100644 index f3f6499..0000000 --- a/src/Commands/MigrateRollbackCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -call('migrate:rollback', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--step' => $this->option('step') ?: null, - '--batch' => $this->option('batch') ?: null, - '--realpath' => (bool) $this->option('realpath'), - '--pretend' => (bool) $this->option('pretend'), - '--force' => true, - ]); - } -} diff --git a/src/Commands/MigrateStatusCommand.php b/src/Commands/MigrateStatusCommand.php deleted file mode 100644 index 1af1b41..0000000 --- a/src/Commands/MigrateStatusCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -call('migrate:status', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--realpath' => (bool) $this->option('realpath'), - '--pending' => (bool) $this->option('pending'), - ]); - } -} diff --git a/src/Commands/ModuleDeleteCommand.php b/src/Commands/ModuleDeleteCommand.php deleted file mode 100644 index b4b7310..0000000 --- a/src/Commands/ModuleDeleteCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -confirmToProceed()) { - return self::FAILURE; - } - - try { - $modulesToDelete = $this->getModuleArgumentOrFail(); - } catch (ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - $exitCode = self::SUCCESS; - - foreach ($modulesToDelete as $moduleToDelete) { - try { - $status = $this->modulesRepository->delete($moduleToDelete->getPackageName(), $this->output); - if ($status) { - $this->components->info("Module [{$moduleToDelete->getPackageName()}] has been deleted."); - } else { - $exitCode = self::FAILURE; - $this->components->error("Failed to remove module [{$moduleToDelete->getPackageName()}]."); - } - } catch (ComposerException $exception) { - $exitCode = self::FAILURE; - $this->components->error($exception->getMessage()); - $this->components->info("Module files were kept. Run composer remove {$moduleToDelete->getPackageName()} manually and retry."); - } catch (CannotDeleteModule $exception) { - $exitCode = self::FAILURE; - $this->components->error($exception->getMessage()); - } - } - - return $exitCode; - } -} diff --git a/src/Commands/StubPublishCommand.php b/src/Commands/StubPublishCommand.php deleted file mode 100644 index 37d6916..0000000 --- a/src/Commands/StubPublishCommand.php +++ /dev/null @@ -1,54 +0,0 @@ -ensureDirectoryExists($customStubsPath = GeneratorHelper::getCustomStubsPath()); - $originalStubsPath = realpath(__DIR__ . '/Generators/stubs'); - - foreach ($filesystem->allFiles($originalStubsPath) as $file) { - $from = $file->getRealPath(); - $relativePath = Str::after($file->getRealPath(), $originalStubsPath); - $to = $customStubsPath . '/' . ltrim($relativePath, '/'); - - $onlyExisting = $this->option('existing'); - $force = $this->option('force'); - $toIsExists = $filesystem->exists($to); - - if (($onlyExisting && $toIsExists) || (! $onlyExisting && (! $toIsExists || $force))) { - $filesystem->ensureDirectoryExists(dirname($to)); - $filesystem->put($to, $filesystem->get($from)); - } - } - - $this->components->info(sprintf('Stubs have been successfully published to the "%s" directory.', $customStubsPath)); - } -} diff --git a/src/Commands/SyncCommand.php b/src/Commands/SyncCommand.php index 835f634..ad6ee3a 100644 --- a/src/Commands/SyncCommand.php +++ b/src/Commands/SyncCommand.php @@ -1,49 +1,72 @@ modulesRepository->pruneModulesManifest(); + protected $signature = 'module:sync + {--no-update : Do not run "composer update" for the outdated modules}'; + public function handle(ModuleRepository $modules, ApplicationComposer $composer, ComposerRunner $runner): int + { try { - $this->modulesRepository->syncWithComposer($this->output); - $this->components->info("Modules completed successfully!"); - } catch (ModuleHasNoNamespace|ModuleHasNonUniquePackageName $e) { - $this->components->error($e->getMessage()); + $modules->refresh(); + + $root = $composer->composerJson(); + $installed = $composer->installed($root); + $outdated = []; + + foreach ($modules->all() as $module) { + $changed = $composer->addModule($root, $module, $modules->modulesPath()); + + if ($changed || ! $installed->isCurrent($module->package, ComposerJson::read($module->path.'/composer.json')->toArray())) { + $outdated[] = $module->package; + } + } + + $root->save(); + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); return self::FAILURE; - } catch (ComposerException $e) { - $this->components->error($e->getMessage()); - $modulePackageNames = join(" ", array_keys($this->modulesRepository->getModules())); - $this->components->info("Please run composer update {$modulePackageNames} manually"); + } + + if ($outdated === []) { + $this->components->info('Modules are in sync with Composer.'); + + return self::SUCCESS; + } + + if ($this->option('no-update')) { + $this->components->warn('Install the outdated modules with: '.$runner->commandLine('update', $outdated)); + + return self::SUCCESS; + } + + if (! $runner->update($outdated, $this->streamOutput(...))) { + $this->components->error('Composer failed to install the modules. Fix the problem and run: '.$runner->commandLine('update', $outdated)); return self::FAILURE; } + $this->components->info('Modules are in sync with Composer.'); + return self::SUCCESS; } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } } diff --git a/src/Enums/ModuleComponentType.php b/src/Enums/ModuleComponentType.php deleted file mode 100644 index 8d5d485..0000000 --- a/src/Enums/ModuleComponentType.php +++ /dev/null @@ -1,41 +0,0 @@ - all() + * @method static \Laraneat\Modules\Module|null find(string $name) + * @method static \Laraneat\Modules\Module get(string $name) + * @method static list> seeders(string ...$directories) + * + * @see ModuleRepository + */ +final class Modules extends Facade +{ + protected static function getFacadeAccessor(): string + { + return ModuleRepository::class; + } +} diff --git a/src/Generators/ComponentMakeCommand.php b/src/Generators/ComponentMakeCommand.php new file mode 100644 index 0000000..0c129c9 --- /dev/null +++ b/src/Generators/ComponentMakeCommand.php @@ -0,0 +1,49 @@ +currentModule(); + $name = $this->argument('name'); + + if ($module === null || ! is_string($name)) { + return $this->namespacedView(); + } + + $components = Names::qualify($module->namespace, Config::string('modules.generators.make:component', 'View\\Components')).'\\'; + $class = str_replace('/', '\\', ltrim($name, '\\/')); + + if (! str_starts_with($class, $components)) { + return $this->namespacedView(); + } + + $this->input->setArgument('name', substr($class, strlen($components))); + + try { + return $this->namespacedView(); + } finally { + $this->input->setArgument('name', $name); + } + } +} diff --git a/src/Generators/ControllerMakeCommand.php b/src/Generators/ControllerMakeCommand.php new file mode 100644 index 0000000..d866c1d --- /dev/null +++ b/src/Generators/ControllerMakeCommand.php @@ -0,0 +1,53 @@ + $replace + * @param string $modelClass + * @return array + */ + #[Override] + protected function buildFormRequestReplacements(array $replace, $modelClass) + { + $replace = parent::buildFormRequestReplacements($replace, $modelClass); + $module = $this->currentModule(); + + if ($module === null) { + return $replace; + } + + $namespace = Names::qualify($module->namespace, Config::string('modules.generators.make:request', 'Http\\Requests')).'\\'; + + foreach (self::REQUEST_REPLACEMENTS as $key) { + if (is_string($replace[$key] ?? null)) { + $replace[$key] = str_replace('App\\Http\\Requests\\', $namespace, $replace[$key]); + } + } + + return $replace; + } +} diff --git a/src/Generators/FactoryMakeCommand.php b/src/Generators/FactoryMakeCommand.php new file mode 100644 index 0000000..3f72f6e --- /dev/null +++ b/src/Generators/FactoryMakeCommand.php @@ -0,0 +1,53 @@ +currentModule(); + $name = str_replace('/', '\\', ltrim($name, '\\/')); + + if ($module !== null && str_starts_with($name, $factories = $module->namespace.'\\Database\\Factories\\')) { + $name = $module->namespace.'\\'.substr($name, strlen($factories)); + } + + return parent::qualifyClass($name); + } + + #[Override] + protected function buildClass($name) + { + $class = parent::buildClass($name); + $module = $this->currentModule(); + + if ($module === null) { + return $class; + } + + return (string) preg_replace_callback( + '/^namespace Database\\\\Factories(?=[\\\;])/m', + static fn (): string => 'namespace '.$module->namespace.'\\Database\\Factories', + $class, + 1, + ); + } +} diff --git a/src/Generators/MailMakeCommand.php b/src/Generators/MailMakeCommand.php new file mode 100644 index 0000000..9c60886 --- /dev/null +++ b/src/Generators/MailMakeCommand.php @@ -0,0 +1,15 @@ +currentModule() === null) { + parent::createFactory(); + + return; + } + + $this->call('make:factory', [ + // Fully qualified, so the "generators" config does not move it away from the factory resolver. + 'name' => $this->factoryClass(), + '--model' => $this->qualifyClass($this->getNameInput()), + ]); + } + + /** + * @return array + */ + #[Override] + protected function buildFactoryReplacements() + { + $replacements = parent::buildFactoryReplacements(); + + if ($this->currentModule() !== null && isset($replacements['{{ factory }}'])) { + $factory = $this->factoryClass(); + + $replacements['{{ factory }}'] = (string) preg_replace_callback( + '/HasFactory<[^>]*>/', + static fn (): string => 'HasFactory<\\'.$factory.'>', + $replacements['{{ factory }}'], + ); + } + + return $replacements; + } + + private function factoryClass(): string + { + // @phpstan-ignore argument.type, argument.templateType (the model is being generated, it does not exist yet) + return Factory::resolveFactoryName($this->qualifyClass($this->getNameInput())); + } +} diff --git a/src/Generators/ModuleContext.php b/src/Generators/ModuleContext.php new file mode 100644 index 0000000..7fef252 --- /dev/null +++ b/src/Generators/ModuleContext.php @@ -0,0 +1,71 @@ +module; + } + + /** + * @template TResult + * + * @param Closure(): TResult $callback + * @return TResult + */ + public function run(Module $module, Closure $callback): mixed + { + $config = $this->app->make('config'); + $namespace = new ReflectionProperty(Application::class, 'namespace'); + + $previous = [ + $this->module, + $this->app->path(), + $this->app->databasePath(), + $this->app->configPath(), + $namespace->getValue($this->app), + $config->get('view.paths'), + ]; + + $this->module = $module; + $this->app->useAppPath($module->sourcePath); + $this->app->useDatabasePath($module->path.'/database'); + $this->app->useConfigPath($module->path.'/config'); + $namespace->setValue($this->app, $module->namespace.'\\'); + $config->set('view.paths', [$module->path.'/resources/views']); + + try { + return $callback(); + } finally { + [$this->module, $appPath, $databasePath, $configPath, $previousNamespace, $viewPaths] = $previous; + + $this->app->useAppPath($appPath); + $this->app->useDatabasePath($databasePath); + $this->app->useConfigPath($configPath); + $namespace->setValue($this->app, $previousNamespace); + $config->set('view.paths', $viewPaths); + } + } +} diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php new file mode 100644 index 0000000..3fed740 --- /dev/null +++ b/src/Generators/ModuleOption.php @@ -0,0 +1,169 @@ + ComponentMakeCommand::class, + BaseControllerMakeCommand::class => ControllerMakeCommand::class, + BaseFactoryMakeCommand::class => FactoryMakeCommand::class, + BaseMailMakeCommand::class => MailMakeCommand::class, + BaseModelMakeCommand::class => ModelMakeCommand::class, + BaseNotificationMakeCommand::class => NotificationMakeCommand::class, + BaseProviderMakeCommand::class => ProviderMakeCommand::class, + BaseSeederMakeCommand::class => SeederMakeCommand::class, + BaseTestMakeCommand::class => TestMakeCommand::class, + BaseViewMakeCommand::class => ViewMakeCommand::class, + ]; + + /** + * The options of the Laravel generators that name a model. + */ + private const array MODEL_OPTIONS = [ + 'make:controller' => ['model', 'parent'], + 'make:factory' => ['model'], + 'make:observer' => ['model'], + 'make:policy' => ['model'], + ]; + + public static function register(Container $app): void + { + $app->singleton(ModuleContext::class); + + $app->resolving(GeneratorCommand::class, self::addTo(...)); + $app->resolving(MigrateMakeCommand::class, self::addTo(...)); + $app->resolving(MigrationGeneratorCommand::class, self::addTo(...)); + + // Generators that hard-code a namespace, a destination or a view name of the application. + foreach (self::GENERATORS as $base => $generator) { + $app->extend($base, static fn (Command $command, Container $app): Command => new $generator($app->make(Filesystem::class))); + } + } + + /** + * The option is added when the command is resolved, and the command code is replaced by + * a closure bound to the command, so its protected execute() still does the work. + * Symfony binds the input before the code runs, which is why this can not happen earlier. + */ + public static function addTo(Command $command): void + { + if ($command->getDefinition()->hasOption('module')) { + return; + } + + $command->addOption('module', null, InputOption::VALUE_REQUIRED, 'Generate the file inside the given module'); + + $command->setCode(Closure::bind(function (InputInterface $input, OutputInterface $output): int { + /** @var Command $this */ + $name = $input->getOption('module'); + $context = $this->getLaravel()->make(ModuleContext::class); + + // A generator called by another one (make:model Post -a) runs in the module of the caller. + if (! is_string($name)) { + if (($module = $context->module()) !== null) { + ModuleOption::qualify($this, $input, $module); + } + + return $this->execute($input, $output); + } + + $module = $this->getLaravel()->make(ModuleRepository::class)->find($name); + + if ($module === null) { + $this->components->error("Module [{$name}] not found."); + + return Command::FAILURE; + } + + return $context->run($module, function () use ($input, $output, $module): int { + ModuleOption::qualify($this, $input, $module); + + return $this->execute($input, $output); + }); + }, $command, $command::class)); + } + + /** + * Apply the "generators" config: "make:controller Post" becomes "Modules\\Blog\\\\Post". + * The namespace is relative to the root namespace of the generator in the module, which is + * "Modules\\Blog\\Tests" for make:test and "Modules\\Blog\\Database\\Seeders" for make:seeder. + * The "make:model" namespace also applies to the --model and --parent options. + * A name in the module namespace is left as it is. + */ + public static function qualify(Command $command, InputInterface $input, Module $module): void + { + if (! $command instanceof GeneratorCommand) { + return; + } + + $generators = Config::array('modules.generators', []); + $namespace = $generators[$command->getName() ?? ''] ?? null; + + if (is_string($namespace) && $input->hasArgument('name')) { + $root = (fn (): string => $this->rootNamespace())->call($command); + + // The --test option of generators passes a Stringable. + self::qualifyInput($input->getArgument('name'), $module, $root, $namespace, static fn (string $class) => $input->setArgument('name', $class)); + } + + if (is_string($models = $generators['make:model'] ?? null)) { + foreach (self::MODEL_OPTIONS[$command->getName() ?? ''] ?? [] as $option) { + if ($input->hasOption($option)) { + self::qualifyInput($input->getOption($option), $module, $module->namespace, $models, static fn (string $class) => $input->setOption($option, $class)); + } + } + } + } + + /** + * @param Closure(string): void $set + */ + private static function qualifyInput(mixed $class, Module $module, string $root, string $namespace, Closure $set): void + { + if (! is_string($class) && ! $class instanceof Stringable) { + return; + } + + $class = trim(str_replace('/', '\\', (string) $class), '\\'); + + if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { + $set(Names::qualify($root, $namespace, $class)); + } + } +} diff --git a/src/Generators/NamespacesViews.php b/src/Generators/NamespacesViews.php new file mode 100644 index 0000000..ab55664 --- /dev/null +++ b/src/Generators/NamespacesViews.php @@ -0,0 +1,59 @@ +currentModule(); + $markdown = $this->hasOption('markdown') ? $this->option('markdown') : null; + + // Before Laravel 13.33, make:notification renders the --markdown option without getView(). + $namespaced = $module !== null && is_string($markdown) && $markdown !== '' && ! str_contains($markdown, '::'); + + if ($namespaced) { + $this->input->setOption('markdown', $module->name.'::'.$markdown); + } + + $this->buildingClass = true; + + try { + return parent::buildClass($name); + } finally { + $this->buildingClass = false; + + if ($namespaced) { + $this->input->setOption('markdown', $markdown); + } + } + } + + /** + * Not marked as an override: make:notification has getView() only since Laravel 13.33. + * + * @return string + */ + protected function getView() + { + $view = parent::getView(); + $module = $this->currentModule(); + + return $this->buildingClass && $module !== null && ! str_contains($view, '::') ? $module->name.'::'.$view : $view; + } +} diff --git a/src/Generators/NotificationMakeCommand.php b/src/Generators/NotificationMakeCommand.php new file mode 100644 index 0000000..3ccd7c9 --- /dev/null +++ b/src/Generators/NotificationMakeCommand.php @@ -0,0 +1,15 @@ +currentModule(); + + if ($module === null) { + return parent::handle(); + } + + if (GeneratorCommand::handle() === false) { + return false; + } + + $provider = $this->qualifyClass($this->getNameInput()); + + ComposerJson::read($module->path.'/composer.json')->addProvider($provider)->save(); + + $composerJson = $this->laravel->make(ApplicationComposer::class)->relative($module->path.'/composer.json'); + + $this->components->info("Provider [{$provider}] registered in [{$composerJson}]. Run \"php artisan module:sync\" to install it."); + + return null; + } +} diff --git a/src/Generators/ResolvesModule.php b/src/Generators/ResolvesModule.php new file mode 100644 index 0000000..6844ffc --- /dev/null +++ b/src/Generators/ResolvesModule.php @@ -0,0 +1,21 @@ +laravel->make(ModuleContext::class)->module(); + } +} diff --git a/src/Generators/SeederMakeCommand.php b/src/Generators/SeederMakeCommand.php new file mode 100644 index 0000000..0599208 --- /dev/null +++ b/src/Generators/SeederMakeCommand.php @@ -0,0 +1,26 @@ +currentModule(); + + return $module === null ? parent::rootNamespace() : $module->namespace.'\\Database\\Seeders\\'; + } +} diff --git a/src/Generators/TestMakeCommand.php b/src/Generators/TestMakeCommand.php new file mode 100644 index 0000000..2d4804d --- /dev/null +++ b/src/Generators/TestMakeCommand.php @@ -0,0 +1,39 @@ +currentModule(); + + return $module === null ? parent::rootNamespace() : $module->namespace.'\\Tests'; + } + + #[Override] + protected function getPath($name) + { + $module = $this->currentModule(); + + if ($module === null) { + return parent::getPath($name); + } + + return $module->path.'/tests'.str_replace('\\', '/', Str::replaceFirst($this->rootNamespace(), '', $name)).'.php'; + } +} diff --git a/src/Generators/ViewMakeCommand.php b/src/Generators/ViewMakeCommand.php new file mode 100644 index 0000000..efbccd3 --- /dev/null +++ b/src/Generators/ViewMakeCommand.php @@ -0,0 +1,49 @@ +currentModule(); + + if ($module === null) { + return parent::getTestPath(); + } + + $class = Str::after($this->testClassFullyQualifiedName(), $module->namespace.'\\Tests\\'); + + return $module->path.'/tests/'.str_replace('\\', '/', $class).'Test.php'; + } + + #[Override] + protected function testClassFullyQualifiedName() + { + $module = $this->currentModule(); + + return ($module === null ? '' : $module->namespace.'\\').parent::testClassFullyQualifiedName(); + } + + #[Override] + protected function testViewName() + { + $module = $this->currentModule(); + + return ($module === null ? '' : $module->name.'::').parent::testViewName(); + } +} diff --git a/src/Manifest/ManifestBuilder.php b/src/Manifest/ManifestBuilder.php new file mode 100644 index 0000000..d3a4281 --- /dev/null +++ b/src/Manifest/ManifestBuilder.php @@ -0,0 +1,311 @@ +, + * lang: bool, + * json: bool, + * views: bool, + * migrations: bool, + * routes: array>>, + * seeders: array>, + * commands: list, + * } + * @phpstan-type Manifest array + */ +final readonly class ManifestBuilder +{ + private string $modulesPath; + + private string $commandsNamespace; + + /** + * @param array $routes The route directory of every route group. + */ + public function __construct( + string $modulesPath, + private array $routes, + string $commandsNamespace, + ) { + $this->modulesPath = rtrim(str_replace('\\', '/', $modulesPath), '/'); + $this->commandsNamespace = trim($commandsNamespace, '\\'); + } + + /** + * Identifies the settings the manifest is built with. + */ + public function key(): string + { + return hash('xxh128', serialize([$this->modulesPath, $this->routes, $this->commandsNamespace])); + } + + public function modulesPath(): string + { + return $this->modulesPath; + } + + /** + * @return Manifest + */ + public function build(): array + { + $manifest = []; + $packages = []; + $namespaces = []; + + foreach ($this->scan($this->modulesPath)[1] as $name) { + $path = $this->modulesPath.'/'.$name; + + if (! is_file($path.'/composer.json')) { + continue; + } + + $module = $this->module($path); + + if (isset($packages[$module['package']])) { + throw InvalidModule::at($path, "the package name [{$module['package']}] is already used by the [{$packages[$module['package']]}] module."); + } + + if (isset($namespaces[$module['namespace']])) { + throw InvalidModule::at($path, "the namespace [{$module['namespace']}] is already used by the [{$namespaces[$module['namespace']]}] module."); + } + + $packages[$module['package']] = $name; + $namespaces[$module['namespace']] = $name; + $manifest[$name] = $module; + } + + return $manifest; + } + + /** + * @return ModuleManifest + */ + private function module(string $path): array + { + $composer = $this->composerJson($path); + + $package = $composer['name'] ?? null; + + if (! is_string($package) || ! Names::isPackage($package)) { + throw InvalidModule::at($path, 'composer.json must have a valid "name".'); + } + + $autoload = is_array($composer['autoload'] ?? null) ? ($composer['autoload']['psr-4'] ?? null) : null; + $namespace = is_array($autoload) ? array_key_first($autoload) : null; + $source = $namespace === null ? null : (is_array($autoload[$namespace]) ? ($autoload[$namespace][0] ?? null) : $autoload[$namespace]); + + if (! is_string($namespace) || ! Names::isNamespace(rtrim($namespace, '\\')) || ! str_ends_with($namespace, '\\') || ! is_string($source)) { + throw InvalidModule::at($path, 'the first "autoload.psr-4" entry of composer.json must map the root namespace of the module (e.g. "Modules\\\\Blog\\\\": "src/").'); + } + + $namespace = rtrim($namespace, '\\'); + $source = trim((string) preg_replace('{^\./}', '', str_replace('\\', '/', $source)), '/'); + $lang = is_dir($path.'/lang'); + $json = $lang && preg_grep('/\.json$/D', scandir($path.'/lang') ?: []) !== []; + + return [ + 'package' => $package, + 'namespace' => $namespace, + 'path' => $path, + 'src' => $source, + 'config' => array_map(static fn (string $file): string => substr($file, 0, -4), $this->scan($path.'/config')[0]), + 'lang' => $lang, + 'json' => $json, + 'views' => is_dir($path.'/resources/views'), + 'migrations' => is_dir($path.'/database/migrations'), + 'routes' => $this->routes($path), + 'seeders' => $this->seeders($path, $namespace), + 'commands' => $this->commands($path, $source, $namespace), + ]; + } + + /** + * @return array + */ + private function composerJson(string $path): array + { + try { + $composer = json_decode((string) file_get_contents($path.'/composer.json'), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw InvalidModule::at($path, 'composer.json is not valid JSON: '.$exception->getMessage(), $exception); + } + + if (! is_array($composer)) { + throw InvalidModule::at($path, 'composer.json must contain an object.'); + } + + return $composer; + } + + /** + * Route files by route group, then by the prefix of their directory: files of a directory + * come before its subdirectories, everything in alphabetical order. + * + * @return array>> + */ + private function routes(string $path): array + { + $routes = []; + + foreach ($this->routes as $group => $directory) { + $directory = trim(str_replace('\\', '/', $directory), '/'); + + if (is_dir($path.'/'.$directory)) { + $routes[$group] = $this->routeFiles($path, $directory, ''); + } + } + + return $routes; + } + + /** + * @return array> + */ + private function routeFiles(string $path, string $directory, string $prefix): array + { + [$files, $directories] = $this->scan($path.'/'.$directory); + $routes = []; + + foreach ($files as $file) { + $routes[$prefix][] = $directory.'/'.$file; + } + + foreach ($directories as $subdirectory) { + $routes += $this->routeFiles($path, $directory.'/'.$subdirectory, ltrim($prefix.'/'.$subdirectory, '/')); + } + + return $routes; + } + + /** + * Seeder classes of "database/seeders" (key "") and of its direct subdirectories. + * + * @return array> + */ + private function seeders(string $path, string $namespace): array + { + $directory = $path.'/database/seeders'; + $namespace .= '\\Database\\Seeders\\'; + [$files, $directories] = $this->scan($directory); + $seeders = []; + + foreach ($this->classes($directory, $files) as $class) { + $seeders[''][] = $namespace.$class; + } + + foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { + foreach ($this->classes($directory.'/'.$subdirectory, $this->scan($directory.'/'.$subdirectory)[0]) as $class) { + $seeders[$subdirectory][] = $namespace.$subdirectory.'\\'.$class; + } + } + + return $seeders; + } + + /** + * Classes of the "make:command" namespace, including its subdirectories. + * + * @return list + */ + private function commands(string $path, string $source, string $namespace): array + { + $directory = $path.($source === '' ? '' : '/'.$source).'/'.str_replace('\\', '/', $this->commandsNamespace); + + return $this->classesIn($directory, $namespace.'\\'.$this->commandsNamespace.'\\'); + } + + /** + * @return list + */ + private function classesIn(string $directory, string $namespace): array + { + [$files, $directories] = $this->scan($directory); + $classes = array_map(static fn (string $class): string => $namespace.$class, $this->classes($directory, $files)); + + foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { + array_push($classes, ...$this->classesIn($directory.'/'.$subdirectory, $namespace.$subdirectory.'\\')); + } + + return $classes; + } + + /** + * Class names of the PHP files that declare a class of their name. Files without one are left out: + * Composer includes a file every time a class is looked up in it, so a file of functions would be + * included again, and fail, each time the provider checks it for a command or a seeder. + * + * @param list $files + * @return list + */ + private function classes(string $directory, array $files): array + { + return array_values(array_filter( + array_map(static fn (string $file): string => substr($file, 0, -4), $files), + fn (string $class): bool => $this->isIdentifier($class) && $this->declares($directory.'/'.$class.'.php', $class), + )); + } + + /** + * Whether a line of the file declares a class, an interface, a trait or an enum of the name. + */ + private function declares(string $file, string $class): bool + { + return preg_match( + '/^[ \t]*(?:#\[.*\][ \t]*)?(?:(?:abstract|final|readonly)[ \t]+)*(?:class|interface|trait|enum)[ \t]+'.$class.'\b/mi', + (string) file_get_contents($file), + ) === 1; + } + + /** + * PHP files and subdirectories of a directory, sorted, without hidden entries. + * + * @return array{list, list} + */ + private function scan(string $directory): array + { + $entries = is_dir($directory) ? (scandir($directory) ?: []) : []; + $files = []; + $directories = []; + + sort($entries, SORT_STRING); + + foreach ($entries as $entry) { + if ($entry[0] === '.') { + continue; + } + + if (is_dir($directory.'/'.$entry)) { + $directories[] = $entry; + } elseif (str_ends_with($entry, '.php')) { + $files[] = $entry; + } + } + + return [$files, $directories]; + } + + private function isIdentifier(string $name): bool + { + return preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name) === 1; + } +} diff --git a/src/Manifest/ManifestCache.php b/src/Manifest/ManifestCache.php new file mode 100644 index 0000000..a5ef85d --- /dev/null +++ b/src/Manifest/ManifestCache.php @@ -0,0 +1,77 @@ +basePath = rtrim(str_replace('\\', '/', $basePath), '/'); + } + + public function path(): string + { + return $this->path; + } + + public function exists(): bool + { + return is_file($this->path); + } + + /** + * @return Manifest + */ + public function read(): array + { + /** @var Manifest $manifest */ + $manifest = require $this->path; + + return array_map(fn (array $module): array => [...$module, 'path' => $this->absolute($module['path'])], $manifest); + } + + /** + * @param Manifest $manifest + */ + public function write(array $manifest): void + { + $manifest = array_map(fn (array $module): array => [...$module, 'path' => $this->relative($module['path'])], $manifest); + + $this->files->ensureDirectoryExists(dirname($this->path)); + $this->files->replace($this->path, 'exists()) { + $this->files->delete($this->path); + } + } + + private function relative(string $path): string + { + return str_starts_with($path, $this->basePath.'/') ? substr($path, strlen($this->basePath) + 1) : $path; + } + + private function absolute(string $path): string + { + return preg_match('{^(/|[A-Za-z]:[/\\\\])}', $path) === 1 ? $path : $this->basePath.'/'.$path; + } +} diff --git a/src/Module.php b/src/Module.php old mode 100755 new mode 100644 index 108ad82..1e63016 --- a/src/Module.php +++ b/src/Module.php @@ -1,165 +1,23 @@ - */ -class Module implements Arrayable +final readonly class Module { - use Macroable; - - protected string $packageName; - protected string $name; - protected string $path; - protected string $namespace; - /** - * @param string $packageName The module package name. - * @param string $name The module name. - * @param string $path The module path. - * @param string $namespace The module namespace. - * @param array $providers Module providers - * @param array $aliases Module aliases + * @param string $name The directory name of the module ("shop-order"). + * @param string $package The Composer package name ("app/shop-order"). + * @param string $namespace The root namespace, without the trailing backslash ("Modules\ShopOrder"). + * @param string $path The absolute path of the module directory. + * @param string $sourcePath The absolute path of the directory of the root namespace. */ public function __construct( - string $packageName, - string $name, - string $path, - string $namespace, - protected array $providers = [], - protected array $aliases = [], - ) { - $this->packageName = trim($packageName); - $this->name = trim($name) ?: Str::afterLast($this->packageName, '/'); - $this->path = rtrim($path, '/'); - $this->namespace = trim($namespace, '\\'); - } - - /** - * Get package name. - */ - public function getPackageName(): string - { - return $this->packageName; - } - - /** - * Get name. - */ - public function getName(): string - { - return $this->name; - } - - /** - * Get name in studly case. - */ - public function getStudlyName(): string - { - return Str::studly($this->name); - } - - /** - * Get name in kebab case. - */ - public function getKebabName(): string - { - return Str::kebab(str_replace('_', '-', $this->name)); - } - - /** - * Get name in snake case. - */ - public function getSnakeName(): string - { - return Str::snake(str_replace('-', '_', $this->name)); - } - - /** - * Get path. - */ - public function getPath(): string - { - return $this->path; - } - - /** - * Get namespace. - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * Get module providers. - * - * @return array - */ - public function getProviders(): array - { - return $this->providers; - } - - /** - * Get module aliases. - * - * @return array - */ - public function getAliases(): array - { - return $this->aliases; - } - - /** - * Get sub path. - */ - public function subPath(string $subPath): string - { - return $this->getPath() . '/' . GeneratorHelper::normalizePath($subPath); - } - - /** - * Get sub namespace. - */ - public function subNamespace(string $subNamespace): string - { - return $this->getNamespace() . '\\' . GeneratorHelper::normalizeNamespace($subNamespace); - } - - /** - * Handle call __toString. - */ - public function __toString(): string - { - return $this->getPackageName(); - } - - /** - * @return array{ - * path: string, - * packageName: string, - * name: string, - * namespace: string, - * providers: array, - * aliases: array - * } - */ - public function toArray(): array - { - return [ - 'path' => $this->path, - 'packageName' => $this->packageName, - 'name' => $this->name, - 'namespace' => $this->namespace, - 'providers' => $this->providers, - 'aliases' => $this->aliases, - ]; - } + public string $name, + public string $package, + public string $namespace, + public string $path, + public string $sourcePath, + ) {} } diff --git a/src/ModuleRepository.php b/src/ModuleRepository.php new file mode 100644 index 0000000..4bdeebb --- /dev/null +++ b/src/ModuleRepository.php @@ -0,0 +1,192 @@ + + */ + private static array $manifests = []; + + /** + * @var array|null + */ + private ?array $modules = null; + + public function __construct( + private readonly ManifestBuilder $builder, + private readonly ManifestCache $cache, + ) {} + + /** + * @return array + */ + public function all(): array + { + return $this->modules ??= array_map(static fn (array $module): Module => new Module( + name: basename($module['path']), + package: $module['package'], + namespace: $module['namespace'], + path: $module['path'], + sourcePath: $module['src'] === '' ? $module['path'] : $module['path'].'/'.$module['src'], + ), $this->manifest()); + } + + public function find(string $name): ?Module + { + return $this->all()[$name] ?? null; + } + + /** + * @throws ModuleNotFound + */ + public function get(string $name): Module + { + return $this->find($name) ?? throw ModuleNotFound::named($name); + } + + /** + * Seeders of all modules, from "database/seeders" and the given subdirectories of it + * ("" is "database/seeders" itself). They run in the order of their "_N" class name + * suffix; seeders without a suffix run last. Equal suffixes run by class name. + * + * @return list> + */ + public function seeders(string ...$directories): array + { + $directories = $directories === [] ? [''] : array_unique(array_map(static fn (string $directory): string => trim($directory, '/\\'), $directories)); + $seeders = []; + + foreach ($this->manifest() as $module) { + foreach ($directories as $directory) { + foreach ($module['seeders'][$directory] ?? [] as $class) { + if (is_subclass_of($class, Seeder::class) && ! (new ReflectionClass($class))->isAbstract()) { + $seeders[] = $class; + } + } + } + } + + usort($seeders, static fn (string $a, string $b): int => [self::order($a), $a] <=> [self::order($b), $b]); + + return $seeders; + } + + /** + * @internal + * + * @return Manifest + */ + public function manifest(): array + { + return self::$manifests[$this->key()] ??= $this->cache->exists() ? $this->cache->read() : $this->builder->build(); + } + + /** + * @internal + */ + public function modulesPath(): string + { + return $this->builder->modulesPath(); + } + + /** + * @internal + */ + public function cachePath(): string + { + return $this->cache->path(); + } + + /** + * @internal + */ + public function isCached(): bool + { + return $this->cache->exists(); + } + + /** + * @internal + */ + public function cache(): void + { + $this->cache->write($this->builder->build()); + $this->forget(); + } + + /** + * @internal + */ + public function clearCache(): void + { + $this->cache->delete(); + $this->forget(); + } + + /** + * Pick up created or deleted modules; the cache file is rebuilt only if it exists. + * + * @internal + */ + public function refresh(): void + { + $this->forget(); + + if ($this->cache->exists()) { + $this->cache(); + } + } + + /** + * Whether the cache file differs from the modules on disk. + * + * @internal + */ + public function isCacheStale(): bool + { + return $this->cache->exists() && $this->cache->read() !== $this->builder->build(); + } + + /** + * @internal + */ + public static function flushState(): void + { + self::$manifests = []; + } + + private function forget(): void + { + unset(self::$manifests[$this->key()]); + $this->modules = null; + } + + private function key(): string + { + return $this->cache->path()."\0".$this->builder->key(); + } + + private static function order(string $class): int + { + $basename = class_basename($class); + $suffix = substr((string) strrchr($basename, '_'), 1); + + return ctype_digit($suffix) ? (int) $suffix : PHP_INT_MAX; + } +} diff --git a/src/ModulesRepository.php b/src/ModulesRepository.php deleted file mode 100644 index 953d866..0000000 --- a/src/ModulesRepository.php +++ /dev/null @@ -1,406 +0,0 @@ - - */ - protected array $scanPaths = []; - - /** - * The loaded modules array. - * - * @var array|null - */ - protected ?array $modules = null; - - /** - * Create a new module manifest instance. - */ - public function __construct( - protected Filesystem $filesystem, - protected Composer $composer, - protected string $modulesPath, - protected string $basePath, - protected ?string $modulesManifestPath = null, - ) { - $this->addScanPath($this->modulesPath); - } - - /** - * Get module scan paths. - * - * @return array - */ - public function getScanPaths(): array - { - return $this->scanPaths; - } - - /** - * Add module scan path. - * - * @param string|array $scanPaths - * - * @return $this - */ - public function addScanPath(string|array $scanPaths): static - { - foreach (Arr::wrap($scanPaths) as $scanPath) { - $normalizedScanPath = $this->normalizeScanPath($scanPath); - - if (! $normalizedScanPath || in_array($normalizedScanPath, $this->scanPaths, true)) { - continue; - } - - $this->scanPaths[] = $normalizedScanPath; - } - - $this->modules = null; - - return $this; - } - - /** - * Build the modules manifest and write it to disk if cache enabled. - * - * @return array }> - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - */ - public function buildModulesManifest(): array - { - $modulesManifest = []; - - foreach ($this->scanPaths as $path) { - $packagePaths = $this->filesystem->glob("$path/composer.json"); - - foreach ($packagePaths as $packagePath) { - $packagePath = GeneratorHelper::normalizePath($packagePath, true); - $composerJsonFile = ComposerJsonFile::create($packagePath); - $packageName = trim($composerJsonFile->get('name') ?? ""); - - if (! $packageName) { - continue; - } - - if (array_key_exists($packageName, $modulesManifest)) { - throw ModuleHasNonUniquePackageName::make($packageName); - } - - $path = str_replace('\\', '/', dirname($packagePath)); - - $moduleData = [ - 'path' => $path, - 'name' => basename($path), - 'namespace' => array_key_first($composerJsonFile->get('autoload.psr-4') ?? []), - 'providers' => $composerJsonFile->get('extra.laravel.providers') ?? [], - 'aliases' => $composerJsonFile->get('extra.laravel.aliases') ?? [], - ]; - - $modulesManifest[$packageName] = $this->validateModuleData($packageName, $moduleData); - } - } - - if ($this->modulesManifestPath) { - $this->write($modulesManifest, $this->modulesManifestPath); - } - - return $modulesManifest; - } - - /** - * Prune modules manifest - */ - public function pruneModulesManifest(): bool - { - $this->modules = null; - - if ($this->modulesManifestPath === null) { - return true; - } - - return $this->filesystem->delete($this->modulesManifestPath); - } - - /** - * Get discovered modules - * - * @return array - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - */ - public function getModules(): array - { - if ($this->modules !== null) { - return $this->modules; - } - - try { - if ($this->modulesManifestPath && $this->filesystem->isFile($this->modulesManifestPath)) { - return $this->modules = $this->makeModulesFromManifest( - $this->filesystem->getRequire($this->modulesManifestPath) - ); - } - } catch (FileNotFoundException) { - // Manifest file not found, will rebuild - } - - return $this->modules = $this->makeModulesFromManifest($this->buildModulesManifest()); - } - - /** - * Determine whether the given module exist by its package name. - */ - public function has(string $modulePackageName): bool - { - return array_key_exists($modulePackageName, $this->getModules()); - } - - /** - * Get count from all modules. - */ - public function count(): int - { - return count($this->getModules()); - } - - /** - * Find a specific module by its package name. - */ - public function find(string $modulePackageName): ?Module - { - $modulePackageName = trim($modulePackageName); - - return $this->getModules()[$modulePackageName] ?? null; - } - - /** - * Find a specific module by its package name, if there return that, otherwise throw exception. - * - * @throws ModuleNotFound - */ - public function findOrFail(string $modulePackageName): Module - { - $module = $this->find($modulePackageName); - - if ($module === null) { - throw ModuleNotFound::make($modulePackageName); - } - - return $module; - } - - /** - * Filter modules by name. - * - * @return array - */ - public function filterByName(string $moduleName): array - { - $moduleName = trim($moduleName); - $moduleKebabName = Str::kebab($moduleName); - $moduleStudlyName = Str::studly($moduleName); - $foundModules = []; - - foreach ($this->getModules() as $modulePackageName => $module) { - $modulePackageNameWithoutVendor = Str::kebab(Str::afterLast($modulePackageName, '/')); - if ($module->getStudlyName() === $moduleStudlyName || $modulePackageNameWithoutVendor === $moduleKebabName) { - $foundModules[$modulePackageName] = $module; - } - } - - return $foundModules; - } - - /** - * Find a specific module by its name, if there return that, otherwise throw exception. - * - * @return array - * - * @throws ModuleNotFound - */ - public function filterByNameOrFail(string $moduleName): array - { - $modules = $this->filterByName($moduleName); - - if (! $modules) { - throw ModuleNotFound::makeForName($moduleName); - } - - return $modules; - } - - /** - * Delete a specific module by its package name. - * - * The package is removed from composer first: if that fails, the module files are kept, - * so composer.json never requires a module that no longer exists locally. - * - * @throws ModuleNotFound - * @throws ComposerException - * @throws CannotDeleteModule - */ - public function delete(string $modulePackageName, \Closure|OutputInterface|null $output = null): bool - { - $module = $this->findOrFail($modulePackageName); - $modulePath = rtrim($module->getPath(), '/\\'); - - if (is_link($modulePath)) { - throw CannotDeleteModule::becauseItIsSymlink($module->getPackageName(), $modulePath); - } - - if (! $this->composer->removePackages([$module->getPackageName()], false, $output)) { - throw ComposerException::make("Failed to remove package with composer."); - } - - $result = $this->filesystem->deleteDirectory($modulePath); - - $this->pruneModulesManifest(); - - return $result; - } - - /** - * Sync modules with composer. - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - * @throws ComposerException - */ - public function syncWithComposer(\Closure|OutputInterface|null $output = null): void - { - $composerJsonPath = $this->basePath . '/' . Factory::getComposerFile(); - $composerJsonFile = ComposerJsonFile::create($composerJsonPath); - - foreach ($this->getModules() as $modulePackageName => $module) { - $moduleRelativePath = GeneratorHelper::makeRelativePath($this->basePath, $module->getPath()); - if ($moduleRelativePath !== null) { - $composerJsonFile->addModule($modulePackageName, $moduleRelativePath); - } - } - - $composerJsonFile->save(); - - $modulePackageNames = array_keys($this->getModules()); - if (! $modulePackageNames) { - return; - } - - if (! $this->composer->updatePackages($modulePackageNames, false, $output)) { - throw ComposerException::make("Failed to update package with composer."); - } - } - - /** - * @return array, - * aliases: array - * }> - */ - public function toArray(): array - { - return array_map(static fn (Module $module) => $module->toArray(), $this->getModules()); - } - - /** - * Write the given manifest array to disk. - * - * @param array $manifest - * - * @throws DirectoryMustBePresentAndWritable - */ - protected function write(array $manifest, string $manifestPath): void - { - if (! is_writable($dirname = dirname($manifestPath))) { - throw DirectoryMustBePresentAndWritable::make($dirname); - } - - $this->filesystem->replace( - $manifestPath, - ' } $moduleData - * - * @return array{ path: string, name: string, namespace: string, providers: class-string[], aliases: array } - * - * @throws ModuleHasNoNamespace - */ - protected function validateModuleData(string $packageName, array $moduleData): array - { - if (empty(trim($moduleData['namespace'] ?? ""))) { - throw ModuleHasNoNamespace::make($packageName); - } - - return $moduleData; - } - - /** - * Make Module instances from manifest - * - * @param array }> $manifest - * - * @return array - */ - protected function makeModulesFromManifest(array $manifest): array - { - return collect($manifest) - ->map(fn ($module, $packageName) => $this->makeModuleFromManifestItem($packageName, $module)) - ->all(); - } - - /** - * @param string $packageName - * @param array{ path: string, name: string, namespace: string, providers: class-string[], aliases: array } $moduleData - */ - protected function makeModuleFromManifestItem(string $packageName, array $moduleData): Module - { - return new Module( - packageName: $packageName, - name: $moduleData['name'], - path: $moduleData['path'], - namespace: $moduleData['namespace'], - providers: $moduleData['providers'], - aliases: $moduleData['aliases'], - ); - } -} diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php new file mode 100644 index 0000000..461b63a --- /dev/null +++ b/src/ModulesServiceProvider.php @@ -0,0 +1,163 @@ +mergeConfigFrom(__DIR__.'/../config/modules.php', 'modules'); + + $this->app->singleton(ModuleRepository::class, static fn (Application $app): ModuleRepository => new ModuleRepository( + self::manifestBuilder($app), + new ManifestCache($app->make('files'), self::cachePath($app), $app->basePath()), + )); + + (new ResourceRegistrar( + $this->app, + $this->manifest(), + $this->isCached(), + Config::string('modules.generators.make:component', 'View\\Components'), + ))->register(); + + if ($this->app->runningInConsole()) { + $this->app->singleton(ApplicationComposer::class, static fn (Application $app): ApplicationComposer => new ApplicationComposer($app->basePath())); + $this->app->singleton(ComposerRunner::class, static fn (Application $app): ComposerRunner => new ComposerRunner($app->basePath())); + + ModuleOption::register($this->app); + } + } + + public function boot(): void + { + $manifest = $this->manifest(); + + if (! $this->app->routesAreCached()) { + (new RouteRegistrar($this->app->make('router'), Config::array('modules.routes', []), $manifest, $this->isCached()))->register(); + } + + if ($this->app->runningInConsole()) { + $this->bootConsole($manifest); + } + } + + /** + * @param Manifest $manifest + */ + private function bootConsole(array $manifest): void + { + $modules = $this->app->make(ModuleRepository::class); + $composer = $this->app->make(ApplicationComposer::class); + + (new ConsoleRegistrar($this->app, $manifest, $modules->modulesPath()))->register(); + + $this->commands([ + Commands\CacheCommand::class, + Commands\ClearCommand::class, + Commands\DeleteCommand::class, + Commands\DoctorCommand::class, + Commands\ListCommand::class, + Commands\MakeCommand::class, + Commands\SyncCommand::class, + ]); + + $this->optimizes(optimize: 'module:cache', clear: 'module:clear', key: 'modules'); + + AboutCommand::add('Modules', static fn (): array => [ + 'Modules' => count($modules->manifest()), + 'Path' => $composer->relative($modules->modulesPath()), + 'Cache' => AboutCommand::format( + $modules->isCached(), + console: static fn (mixed $cached): string => $cached ? 'CACHED' : 'NOT CACHED', + ), + ]); + + $this->publishes([__DIR__.'/../config/modules.php' => $this->app->configPath('modules.php')], 'modules-config'); + $this->publishes([__DIR__.'/../resources/stubs/module/default' => $this->app->basePath('stubs/module/default')], 'modules-stubs'); + } + + /** + * @return Manifest + */ + private function manifest(): array + { + return $this->app->make(ModuleRepository::class)->manifest(); + } + + private function isCached(): bool + { + return $this->app->make(ModuleRepository::class)->isCached(); + } + + private static function manifestBuilder(Application $app): ManifestBuilder + { + $routes = []; + $generators = Config::array('modules.generators', []); + + foreach (Config::array('modules.routes', []) as $group => $attributes) { + if (! is_array($attributes) || ! is_string($attributes['path'] ?? null)) { + throw InvalidConfiguration::because("the [{$group}] route group must have a \"path\"."); + } + + $routes[(string) $group] = $attributes['path']; + } + + foreach ($generators as $command => $namespace) { + if (! is_string($namespace)) { + throw InvalidConfiguration::because("the namespace of the [{$command}] generator must be a string."); + } + } + + // An empty namespace would make every class of a module a candidate command. + if (trim($generators['make:command'] ?? 'Console\\Commands', '\\') === '') { + throw InvalidConfiguration::because('the namespace of the [make:command] generator can not be empty: module commands are discovered in it.'); + } + + $path = Config::string('modules.path'); + + return new ManifestBuilder( + self::isAbsolute($path) ? $path : $app->basePath($path), + $routes, + $generators['make:command'] ?? 'Console\\Commands', + ); + } + + /** + * Like the config and route caches: "bootstrap/cache/modules.php", or the MODULES_CACHE path. + */ + private static function cachePath(Application $app): string + { + $path = Env::get('MODULES_CACHE'); + + if (! is_string($path) || $path === '') { + return $app->bootstrapPath('cache/modules.php'); + } + + return self::isAbsolute($path) ? $path : $app->basePath($path); + } + + private static function isAbsolute(string $path): bool + { + return str_starts_with($path, '/') || str_starts_with($path, '\\') || preg_match('{^[A-Za-z]:[/\\\\]}', $path) === 1; + } +} diff --git a/src/Providers/ComposerServiceProvider.php b/src/Providers/ComposerServiceProvider.php deleted file mode 100644 index 1d8ec10..0000000 --- a/src/Providers/ComposerServiceProvider.php +++ /dev/null @@ -1,31 +0,0 @@ -app->singleton(Composer::class, function (Application $app) { - return new Composer($app['files'], $app->basePath()); - }); - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return [Composer::class]; - } -} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php deleted file mode 100644 index 551ce58..0000000 --- a/src/Providers/ConsoleServiceProvider.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ - protected array $commands = [ - Commands\CacheClearCommand::class, - Commands\CacheCommand::class, - Commands\ListCommand::class, - Commands\MigrateCommand::class, - Commands\MigrateRefreshCommand::class, - Commands\MigrateResetCommand::class, - Commands\MigrateRollbackCommand::class, - Commands\MigrateStatusCommand::class, - Commands\ModuleDeleteCommand::class, - Commands\StubPublishCommand::class, - Commands\SyncCommand::class, - Commands\Generators\ActionMakeCommand::class, - Commands\Generators\CommandMakeCommand::class, - Commands\Generators\ControllerMakeCommand::class, - Commands\Generators\DTOMakeCommand::class, - Commands\Generators\EventMakeCommand::class, - Commands\Generators\ExceptionMakeCommand::class, - Commands\Generators\FactoryMakeCommand::class, - Commands\Generators\JobMakeCommand::class, - Commands\Generators\ListenerMakeCommand::class, - Commands\Generators\MailMakeCommand::class, - Commands\Generators\MiddlewareMakeCommand::class, - Commands\Generators\MigrationMakeCommand::class, - Commands\Generators\ModelMakeCommand::class, - Commands\Generators\ModuleMakeCommand::class, - Commands\Generators\NotificationMakeCommand::class, - Commands\Generators\ObserverMakeCommand::class, - Commands\Generators\PolicyMakeCommand::class, - Commands\Generators\ProviderMakeCommand::class, - Commands\Generators\QueryWizardMakeCommand::class, - Commands\Generators\RequestMakeCommand::class, - Commands\Generators\ResourceMakeCommand::class, - Commands\Generators\RouteMakeCommand::class, - Commands\Generators\RuleMakeCommand::class, - Commands\Generators\SeederMakeCommand::class, - Commands\Generators\TestMakeCommand::class, - ]; - - /** - * Register commands - */ - public function register(): void - { - if ($this->app->runningInConsole()) { - $this->commands(config('modules.commands', $this->commands)); - } - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return $this->commands; - } -} diff --git a/src/Providers/ModulesRepositoryServiceProvider.php b/src/Providers/ModulesRepositoryServiceProvider.php deleted file mode 100644 index e8392f8..0000000 --- a/src/Providers/ModulesRepositoryServiceProvider.php +++ /dev/null @@ -1,50 +0,0 @@ -app->singleton(ModulesRepository::class, function (Application $app) { - return new ModulesRepository( - filesystem: $app['files'], - composer: $app[Composer::class], - modulesPath: $app['config']->get('modules.path'), - basePath: $app->basePath(), - modulesManifestPath: $app['config']->get('modules.cache.enabled') - ? $app->bootstrapPath('cache/laraneat-modules.php') - : null - ); - }); - - $this->app->singleton(ModuleConfigWriter::class, function (Application $app) { - return new ModuleConfigWriter( - modulesRepository: $app[ModulesRepository::class], - ); - }); - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return [ - ModulesRepository::class, - ModuleConfigWriter::class, - ]; - } -} diff --git a/src/Providers/ModulesServiceProvider.php b/src/Providers/ModulesServiceProvider.php deleted file mode 100755 index 1e62181..0000000 --- a/src/Providers/ModulesServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -mergeConfigFrom(__DIR__ . '/../../config/config.php', 'modules'); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - if (app()->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../../config/config.php' => config_path('modules.php'), - ], 'config'); - } - } -} diff --git a/src/Registrars/ConsoleRegistrar.php b/src/Registrars/ConsoleRegistrar.php new file mode 100644 index 0000000..c467eac --- /dev/null +++ b/src/Registrars/ConsoleRegistrar.php @@ -0,0 +1,167 @@ +registerCommands(); + + if ($this->manifest !== []) { + $this->registerFactoryResolvers(); + $this->registerTinkerAliases(); + } + + $this->watchWithOctane(); + } + + private function registerCommands(): void + { + $commands = array_merge(...array_column($this->manifest, 'commands')); + + if ($commands === []) { + return; + } + + Artisan::starting(static function (Artisan $artisan) use ($commands): void { + $artisan->resolveCommands(array_values(array_filter( + $commands, + static fn (string $class): bool => is_subclass_of($class, Command::class) && ! (new ReflectionClass($class))->isAbstract(), + ))); + }); + } + + /** + * "Modules\Blog\Models\Post" uses "Modules\Blog\Database\Factories\PostFactory" and back, + * the way Laravel pairs "App\Models\Post" with "Database\Factories\PostFactory". + * Classes outside the modules keep the default Laravel behavior. + */ + private function registerFactoryResolvers(): void + { + $namespaces = array_map(static fn (array $module): string => $module['namespace'].'\\', array_values($this->manifest)); + + usort($namespaces, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + // @phpstan-ignore argument.type (like the default resolver, it returns a class name that may not exist yet) + Factory::guessFactoryNamesUsing(static function (string $model) use ($namespaces): string { + [$root, $factories] = self::roots($model, $namespaces, false); + + $name = str_starts_with($model, $root.'Models\\') + ? substr($model, strlen($root.'Models\\')) + : Str::after($model, $root); + + return $factories.$name.'Factory'; + }); + + // @phpstan-ignore argument.type (like the default resolver, it returns a class name that may not exist) + Factory::guessModelNamesUsing(static function (Factory $factory) use ($namespaces): string { + [$root, $factories] = self::roots($factory::class, $namespaces, true); + + $name = Str::replaceLast('Factory', '', Str::replaceFirst($factories, '', $factory::class)); + + foreach ([$root.'Models\\'.$name, $root.$name] as $model) { + if (class_exists($model)) { + return $model; + } + } + + return $root.Str::replaceLast('Factory', '', class_basename($factory)); + }); + } + + /** + * The root namespace and the factories namespace the class belongs to. + * + * @param list $namespaces + * @return array{string, string} + */ + private static function roots(string $class, array $namespaces, bool $isFactory): array + { + foreach ($namespaces as $namespace) { + if (str_starts_with($class, $isFactory ? $namespace.'Database\\Factories\\' : $namespace)) { + return [$namespace, $namespace.'Database\\Factories\\']; + } + } + + try { + $root = Container::getInstance()->make(ApplicationContract::class)->getNamespace(); + } catch (Throwable) { + $root = 'App\\'; + } + + return [$root, Factory::$namespace]; + } + + /** + * Module classes are installed under "vendor", which Tinker does not alias by default. + */ + private function registerTinkerAliases(): void + { + $config = $this->config(); + $aliases = array_filter((array) $config->get('tinker.alias', []), is_string(...)); + + foreach ($this->manifest as $module) { + $aliases[] = $module['namespace'].'\\'; + } + + $config->set('tinker.alias', array_values(array_unique($aliases))); + } + + /** + * Let "octane:start --watch" reload the workers when module files change. + * Octane resolves watched paths against the base path, so only a path inside it can be added. + */ + private function watchWithOctane(): void + { + $config = $this->config(); + $watch = $config->get('octane.watch'); + $basePath = rtrim(str_replace('\\', '/', $this->app->basePath()), '/').'/'; + + if (! is_array($watch) || ! str_starts_with($this->modulesPath.'/', $basePath)) { + return; + } + + $path = substr($this->modulesPath, strlen($basePath)); + + if ($path !== '' && ! in_array($path, $watch, true)) { + $config->set('octane.watch', [...$watch, $path]); + } + } + + private function config(): Repository + { + return $this->app->make('config'); + } +} diff --git a/src/Registrars/ResourceRegistrar.php b/src/Registrars/ResourceRegistrar.php new file mode 100644 index 0000000..0df1670 --- /dev/null +++ b/src/Registrars/ResourceRegistrar.php @@ -0,0 +1,139 @@ +" override lookup of loadViewsFrom(): modules belong to the + * application. JSON translation paths are added only for modules that have them. + * + * @internal + * + * @phpstan-import-type Manifest from ManifestBuilder + */ +final readonly class ResourceRegistrar +{ + /** + * @param Manifest $manifest + * @param bool $cached Whether the manifest was read from the cache file: config files deleted since it was written are skipped. + * @param string $componentsNamespace The namespace of Blade components inside a module. + */ + public function __construct( + private Application $app, + private array $manifest, + private bool $cached = false, + private string $componentsNamespace = 'View\\Components', + ) {} + + public function register(): void + { + $this->registerConfig(); + $this->registerCallbacks(); + } + + /** + * Merge the config files of the modules; the application config wins. + */ + private function registerConfig(): void + { + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; + } + + $config = $this->app->make('config'); + + foreach ($this->manifest as $module) { + foreach ($module['config'] as $key) { + $file = $module['path'].'/config/'.$key.'.php'; + + // A built manifest lists existing files, so only a cached one costs a check per file. + if ($this->cached && ! is_file($file)) { + continue; + } + + $values = require $file; + + if (! is_array($values)) { + throw InvalidModule::at($module['path'], "config/{$key}.php must return an array."); + } + + $config->set($key, array_merge($values, (array) $config->get($key, []))); + } + } + } + + private function registerCallbacks(): void + { + $lang = array_filter($this->manifest, static fn (array $module): bool => $module['lang']); + $views = array_filter($this->manifest, static fn (array $module): bool => $module['views']); + + if ($lang !== []) { + $this->afterResolving('translator', static function (Translator $translator) use ($lang): void { + foreach ($lang as $name => $module) { + $translator->addNamespace($name, $module['path'].'/lang'); + + if ($module['json']) { + $translator->addJsonPath($module['path'].'/lang'); + } + } + }); + } + + if ($views !== []) { + $this->afterResolving('view', static function (ViewFactory $view) use ($views): void { + foreach ($views as $name => $module) { + $view->addNamespace($name, $module['path'].'/resources/views'); + } + }); + } + + if ($this->manifest !== []) { + // renders the Alert class of the components namespace, or the "blog::components.alert" view. + $this->afterResolving('blade.compiler', function (BladeCompiler $blade): void { + foreach ($this->manifest as $name => $module) { + $blade->componentNamespace(Names::qualify($module['namespace'], $this->componentsNamespace), $name); + } + }); + } + + if ($this->app->runningInConsole()) { + $migrations = array_filter($this->manifest, static fn (array $module): bool => $module['migrations']); + + if ($migrations !== []) { + $this->afterResolving('migrator', static function (Migrator $migrator) use ($migrations): void { + foreach ($migrations as $module) { + $migrator->path($module['path'].'/database/migrations'); + } + }); + } + } + } + + private function afterResolving(string $abstract, Closure $callback): void + { + $this->app->afterResolving($abstract, $callback); + + if ($this->app->resolved($abstract)) { + $callback($this->app->make($abstract)); + } + } +} diff --git a/src/Registrars/RouteRegistrar.php b/src/Registrars/RouteRegistrar.php new file mode 100644 index 0000000..f666367 --- /dev/null +++ b/src/Registrars/RouteRegistrar.php @@ -0,0 +1,60 @@ + $groups The "routes" config. + * @param Manifest $manifest + * @param bool $cached Whether the manifest was read from the cache file: route files deleted since it was written are skipped. + */ + public function __construct( + private Router $router, + private array $groups, + private array $manifest, + private bool $cached = false, + ) {} + + public function register(): void + { + foreach ($this->groups as $group => $attributes) { + if (! is_array($attributes)) { + continue; + } + + unset($attributes['path']); + $prefix = is_string($attributes['prefix'] ?? null) ? trim($attributes['prefix'], '/') : ''; + + foreach ($this->manifest as $module) { + foreach ($module['routes'][$group] ?? [] as $directory => $files) { + $directoryAttributes = $directory === '' + ? $attributes + : ['prefix' => ltrim($prefix.'/'.$directory, '/')] + $attributes; + + foreach ($files as $file) { + $path = $module['path'].'/'.$file; + + // A built manifest lists existing files, so only a cached one costs a check per file. + if (! $this->cached || is_file($path)) { + $this->router->group($directoryAttributes, $path); + } + } + } + } + } + } +} diff --git a/src/Scaffold/ApplicationComposer.php b/src/Scaffold/ApplicationComposer.php new file mode 100644 index 0000000..ca890d6 --- /dev/null +++ b/src/Scaffold/ApplicationComposer.php @@ -0,0 +1,92 @@ +basePath = rtrim(str_replace('\\', '/', $basePath), '/'); + } + + public function composerJsonPath(): string + { + return $this->basePath.'/composer.json'; + } + + public function composerJson(): ComposerJson + { + return ComposerJson::read($this->composerJsonPath()); + } + + /** + * Add the module to the root composer.json and tell whether that changed anything. + */ + public function addModule(ComposerJson $root, Module $module, string $modulesPath): bool + { + $before = $root->contents(); + + $root->addModule($module->package, $this->relative($modulesPath).'/*', $this->autoloadDev($module)); + + return $root->contents() !== $before; + } + + /** + * The "autoload-dev" namespaces of the module (its tests) with paths relative to the application: + * Composer does not load the dev autoload of dependencies, so the application has to. + * + * @return array> + */ + public function autoloadDev(Module $module): array + { + $prefix = $this->relative($module->path).'/'; + $autoloadDev = []; + + foreach ((array) ComposerJson::read($module->path.'/composer.json')->get('autoload-dev.psr-4') as $namespace => $paths) { + $paths = array_values(array_filter((array) $paths, is_string(...))); + + if (is_string($namespace) && $paths !== []) { + $paths = array_map(static fn (string $path): string => $prefix.preg_replace('{^\./}', '', $path), $paths); + $autoloadDev[$namespace] = count($paths) === 1 ? $paths[0] : $paths; + } + } + + return $autoloadDev; + } + + public function installed(ComposerJson $root): InstalledPackages + { + $vendor = $root->get('config.vendor-dir'); + + return InstalledPackages::read($this->absolute(is_string($vendor) ? $vendor : 'vendor')); + } + + /** + * A path relative to the application, if it is inside of it. + */ + public function relative(string $path): string + { + $path = rtrim(str_replace('\\', '/', $path), '/'); + + return str_starts_with($path.'/', $this->basePath.'/') ? ltrim(substr($path, strlen($this->basePath)), '/') : $path; + } + + /** + * A path relative to the application made absolute. + */ + public function absolute(string $path): string + { + return preg_match('{^(/|[A-Za-z]:[/\\\\])}', $path) === 1 ? $path : $this->basePath.'/'.$path; + } +} diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php new file mode 100644 index 0000000..d6c9703 --- /dev/null +++ b/src/Scaffold/ComposerJson.php @@ -0,0 +1,407 @@ +getMessage()}", $exception); + } + + if (! $data instanceof stdClass) { + throw ComposerFailed::because("[{$path}] must contain a JSON object."); + } + + $indent = preg_match('/^([ \t]+)"/m', $json, $matches) === 1 ? $matches[1] : ' '; + + return new self($path, $data, $indent, self::encode($data, $indent)); + } + + /** + * A value by its dot path, with objects as arrays. + */ + public function get(string $key): mixed + { + return Arr::get($this->toArray(), $key); + } + + /** + * @return array + */ + public function toArray(): array + { + return (array) json_decode((string) json_encode($this->data), true); + } + + /** + * Require a module from its path repository, load its dev autoload (tests) from the application + * and never resolve its vendor from Packagist. + * + * @param string $repository The path repository URL, e.g. "modules/*". + * @param array> $autoloadDev Namespaces and their paths relative to the application. + */ + public function addModule(string $package, string $repository, array $autoloadDev): self + { + $this->addPathRepository($repository); + $this->excludeFromPackagist($package); + + // A path package has a "dev-*" version, which "minimum-stability": "stable" refuses without "@dev". + if (! $this->requires($package)) { + $require = $this->object($this->data, 'require'); + $require->{$package} = '*@dev'; + + if ($this->get('config.sort-packages') === true) { + $this->data->require = $this->sortPackages($require); + } + } + + if ($autoloadDev !== []) { + $psr4 = $this->object($this->object($this->data, 'autoload-dev'), 'psr-4'); + + foreach ($autoloadDev as $namespace => $path) { + $psr4->{$namespace} = $path; + } + } + + return $this; + } + + public function removeRequire(string $package): self + { + foreach (['require', 'require-dev'] as $key) { + if (($this->data->{$key} ?? null) instanceof stdClass) { + unset($this->data->{$key}->{$package}); + } + } + + return $this; + } + + /** + * @param list $namespaces + */ + public function removeAutoloadDev(array $namespaces): self + { + $autoloadDev = $this->data->{'autoload-dev'} ?? null; + $psr4 = $autoloadDev instanceof stdClass ? ($autoloadDev->{'psr-4'} ?? null) : null; + + if ($psr4 instanceof stdClass) { + foreach ($namespaces as $namespace) { + unset($psr4->{$namespace}); + } + } + + return $this; + } + + /** + * Register a service provider for Laravel package discovery. + */ + public function addProvider(string $provider): self + { + $laravel = $this->object($this->object($this->data, 'extra'), 'laravel'); + $providers = is_array($laravel->providers ?? null) ? $laravel->providers : []; + + if (! in_array($provider, $providers, true)) { + $laravel->providers = [...$providers, $provider]; + } + + return $this; + } + + /** + * Whether "require" or "require-dev" has the package. + */ + public function requires(string $package): bool + { + foreach (['require', 'require-dev'] as $key) { + $require = $this->data->{$key} ?? null; + + if ($require instanceof stdClass && is_string($require->{$package} ?? null)) { + return true; + } + } + + return false; + } + + /** + * Whether Composer never looks the package up on Packagist or on a Packagist mirror. + */ + public function isExcludedFromPackagist(string $package): bool + { + [$repositories, $default] = $this->packagist(); + + if ($default) { + return false; + } + + foreach ($repositories as $repository) { + if (! $this->excludes($repository, $package)) { + return false; + } + } + + return true; + } + + public function isDirty(): bool + { + return self::encode($this->data, $this->indent) !== $this->saved; + } + + /** + * The file content as it will be saved. + */ + public function contents(): string + { + return self::encode($this->data, $this->indent); + } + + public function save(): void + { + if ($this->isDirty()) { + // Without a mode, replace() makes the file executable. + $mode = fileperms($this->path); + + (new Filesystem)->replace($this->path, $this->saved = $this->contents(), $mode === false ? null : $mode & 0777); + } + } + + private function addPathRepository(string $url): void + { + foreach ($this->repositories() as $repository) { + if ($repository instanceof stdClass && ($repository->type ?? null) === 'path' && ($repository->url ?? null) === $url) { + return; + } + } + + $this->addRepository('modules', (object) ['type' => 'path', 'url' => $url, 'options' => (object) ['symlink' => true]]); + } + + /** + * Modules are required with "*@dev" from a path repository. Without this, a missing module + * directory (another branch, a partial checkout) makes Composer install a package with + * the same name from Packagist instead of failing. + */ + private function excludeFromPackagist(string $package): void + { + $pattern = strstr($package, '/', true).'/*'; + [$repositories, $default] = $this->packagist(); + + if ($default) { + // Composer replaces the default Packagist repository with a repository of its URL. + $this->addRepository('packagist.org', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); + } + + foreach ($repositories as $repository) { + // Composer does not accept "only" and "exclude" together. + if (is_array($repository->only ?? null)) { + if ($this->matches($package, $repository->only)) { + throw ComposerFailed::because("[{$this->path}] a Packagist repository serves [{$package}] through \"only\": remove the pattern that matches it."); + } + + continue; + } + + $exclude = is_array($repository->exclude ?? null) ? $repository->exclude : []; + + if (! in_array($pattern, $exclude, true)) { + $repository->exclude = [...$exclude, $pattern]; + } + } + } + + private function excludes(stdClass $repository, string $package): bool + { + if (is_array($repository->only ?? null)) { + return ! $this->matches($package, $repository->only); + } + + return $this->matches($package, is_array($repository->exclude ?? null) ? $repository->exclude : []); + } + + /** + * The repositories that serve the packages of Packagist, and whether Composer still uses + * the default Packagist repository. Composer semantics: + * + * - any value under the "packagist.org" or "packagist" key replaces the default repository, + * and false, or a {"packagist.org": false} entry, disables it; + * - a composer repository with a packagist.org URL disables the default repository; + * - a list entry named "packagist.org" or "packagist" (a mirror, as Composer 2.10 writes it) + * is used next to the default repository: Composer ignores the name. + * + * @return array{list, bool} + */ + private function packagist(): array + { + $repositories = []; + $default = true; + + foreach ($this->repositories() as $name => $repository) { + $slot = is_string($name) && in_array($name, ['packagist', 'packagist.org'], true); + + if ($slot) { + $default = false; + } + + if (! $repository instanceof stdClass) { + continue; + } + + if (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false) { + $default = false; + + continue; + } + + if (($repository->type ?? null) !== 'composer') { + continue; + } + + $url = is_string($repository->url ?? null) && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1; + + if ($url) { + $default = false; + } + + if ($slot || $url || (is_int($name) && in_array($repository->name ?? null, ['packagist', 'packagist.org'], true))) { + $repositories[] = $repository; + } + } + + return [$repositories, $default]; + } + + /** + * @param array $patterns Package names with "*" wildcards. + */ + private function matches(string $package, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (is_string($pattern) && preg_match('{^'.str_replace('\\*', '.*', preg_quote($pattern)).'$}iD', $package) === 1) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function repositories(): array + { + $repositories = $this->data->repositories ?? []; + + return $repositories instanceof stdClass ? get_object_vars($repositories) : (array) $repositories; + } + + /** + * Append a repository to the list, or add it by name when repositories are an object. + */ + private function addRepository(string $name, stdClass $repository): void + { + $repositories = $this->data->repositories ?? []; + + if ($repositories instanceof stdClass) { + $key = $name; + + for ($i = 2; property_exists($repositories, $key); $i++) { + $key = $name.'-'.$i; + } + + $repositories->{$key} = $repository; + + return; + } + + if (! is_array($repositories)) { + throw ComposerFailed::because("[{$this->path}] \"repositories\" must be an array or an object."); + } + + $this->data->repositories = [...$repositories, $repository]; + } + + private function object(stdClass $parent, string $key): stdClass + { + $value = $parent->{$key} ?? null; + + if ($value instanceof stdClass) { + return $value; + } + + if ($value !== null && $value !== []) { + throw ComposerFailed::because("[{$this->path}] \"{$key}\" must be an object."); + } + + return $parent->{$key} = new stdClass; + } + + /** + * Composer's "sort-packages" order: platform packages first (PHP, HHVM, extensions, libraries, + * then the others), then packages by name. It is the order of Composer's JsonManipulator. + */ + private function sortPackages(stdClass $packages): stdClass + { + $packages = get_object_vars($packages); + + $weight = static fn (string $name): string => preg_match(self::PLATFORM_PACKAGE, $name) === 1 + ? (string) preg_replace(['/^php/', '/^hhvm/', '/^ext/', '/^lib/', '/^\D/'], ['0-$0', '1-$0', '2-$0', '3-$0', '4-$0'], $name) + : '5-'.$name; + + uksort($packages, static fn (string $a, string $b): int => strnatcmp($weight($a), $weight($b))); + + return (object) $packages; + } + + private static function encode(stdClass $data, string $indent): string + { + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + if ($indent !== ' ') { + $json = (string) preg_replace_callback('/^(?: {4})+/m', static fn (array $matches): string => str_repeat($indent, intdiv(strlen($matches[0]), 4)), $json); + } + + return $json."\n"; + } +} diff --git a/src/Scaffold/ComposerRunner.php b/src/Scaffold/ComposerRunner.php new file mode 100644 index 0000000..8ddfd62 --- /dev/null +++ b/src/Scaffold/ComposerRunner.php @@ -0,0 +1,77 @@ + $packages + * @param (Closure(string, string): void)|null $output + */ + public function update(array $packages, ?Closure $output = null): bool + { + return $this->run('update', $packages, $output); + } + + /** + * @param list $packages + * @param (Closure(string, string): void)|null $output + */ + public function remove(array $packages, ?Closure $output = null): bool + { + return $this->run('remove', $packages, $output); + } + + /** + * The command line to run by hand. + * + * @param list $packages + */ + public function commandLine(string $action, array $packages): string + { + return implode(' ', ['composer', $action, ...array_map(Names::assertPackage(...), $packages)]); + } + + /** + * @param list $packages + * @return list + */ + public function command(string $action, array $packages): array + { + $composer = is_file($this->basePath.'/composer.phar') + ? [php_binary(), 'composer.phar'] + : ['composer']; + + return [...$composer, $action, '--', ...array_map(Names::assertPackage(...), $packages)]; + } + + /** + * @param list $packages + * @param (Closure(string, string): void)|null $output + */ + private function run(string $action, array $packages, ?Closure $output): bool + { + return Process::path($this->basePath) + ->env(['COMPOSER_MEMORY_LIMIT' => '-1']) + ->forever() + ->run($this->command($action, $packages), $output) + ->successful(); + } +} diff --git a/src/Scaffold/InstalledPackages.php b/src/Scaffold/InstalledPackages.php new file mode 100644 index 0000000..c24fa36 --- /dev/null +++ b/src/Scaffold/InstalledPackages.php @@ -0,0 +1,80 @@ +> $packages + */ + private function __construct( + private string $vendorPath, + private array $packages, + ) {} + + public static function read(string $vendorPath): self + { + $json = is_file($file = $vendorPath.'/composer/installed.json') ? json_decode((string) file_get_contents($file), true) : null; + $packages = []; + + foreach ((array) (is_array($json) && isset($json['packages']) ? $json['packages'] : $json) as $package) { + if (is_array($package) && is_string($package['name'] ?? null)) { + $packages[$package['name']] = $package; + } + } + + return new self($vendorPath, $packages); + } + + public function has(string $package): bool + { + return isset($this->packages[$package]); + } + + /** + * Whether the installed copy of the package metadata matches its composer.json: + * a changed "require", "autoload" or "extra" (providers) needs "composer update". + * + * @param array $composerJson + */ + public function isCurrent(string $package, array $composerJson): bool + { + $installed = $this->packages[$package] ?? null; + + if ($installed === null) { + return false; + } + + // Composer lowercases the package names of the installed requirements. + $require = is_array($composerJson['require'] ?? null) ? array_change_key_case($composerJson['require']) : []; + + return ($installed['require'] ?? []) == $require + && ($installed['autoload'] ?? []) == ($composerJson['autoload'] ?? []) + && ($installed['extra'] ?? []) == ($composerJson['extra'] ?? []); + } + + /** + * The directory of the path repository the package was installed from, as Composer wrote it. + */ + public function pathSource(string $package): ?string + { + $dist = $this->packages[$package]['dist'] ?? null; + + return is_array($dist) && ($dist['type'] ?? null) === 'path' && is_string($dist['url'] ?? null) ? $dist['url'] : null; + } + + /** + * The directory the package is installed in. + */ + public function path(string $package): string + { + return $this->vendorPath.'/'.$package; + } +} diff --git a/src/Scaffold/Names.php b/src/Scaffold/Names.php new file mode 100644 index 0000000..6c8dfce --- /dev/null +++ b/src/Scaffold/Names.php @@ -0,0 +1,100 @@ + trim($segment, '\\'), $segments), + static fn (string $segment): bool => $segment !== '', + )); + } +} diff --git a/src/Scaffold/TemplateRenderer.php b/src/Scaffold/TemplateRenderer.php new file mode 100644 index 0000000..bf7874e --- /dev/null +++ b/src/Scaffold/TemplateRenderer.php @@ -0,0 +1,122 @@ + $variables + * @return array File contents by their path relative to the module. + */ + public function render(string $template, array $variables): array + { + $template = rtrim(str_replace('\\', '/', $template), '/'); + + if (! is_dir($template)) { + throw InvalidTemplate::at($template, 'the directory does not exist.'); + } + + $files = []; + $sources = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($template, FilesystemIterator::SKIP_DOTS)) as $file) { + $source = substr(str_replace('\\', '/', $file->getPathname()), strlen($template) + 1); + $isStub = str_ends_with($source, '.stub'); + $path = $this->renderPath($isStub ? substr($source, 0, -5) : $source, $source, $variables); + + if (isset($files[$path])) { + throw InvalidTemplate::at($source, "another file is also rendered to [{$path}]."); + } + + $contents = (string) file_get_contents($file->getPathname()); + $files[$path] = $isStub ? $this->renderString($contents, $source, $variables) : $contents; + $sources[$path] = $source; + } + + foreach ($sources as $path => $source) { + for ($directory = dirname($path); $directory !== '.'; $directory = dirname($directory)) { + if (isset($files[$directory])) { + throw InvalidTemplate::at($sources[$directory], "the file is rendered to [{$directory}], which is also the directory of [{$path}]."); + } + } + } + + ksort($files, SORT_STRING); + + return $files; + } + + /** + * @param array $variables + */ + private function renderPath(string $path, string $source, array $variables): string + { + return implode('/', array_map(function (string $segment) use ($source, $variables): string { + $segment = $this->renderString($segment, $source, $variables); + + if (in_array($segment, ['', '.', '..'], true) || strpbrk($segment, "/\\\0:") !== false) { + throw InvalidTemplate::at($source, "the path renders to an unsafe name [{$segment}]."); + } + + return $segment; + }, explode('/', $path))); + } + + /** + * @param array $variables + */ + private function renderString(string $string, string $source, array $variables): string + { + return (string) preg_replace_callback(self::PLACEHOLDER, function (array $matches) use ($source, $variables): string { + if (! array_key_exists($matches[1], $variables)) { + throw InvalidTemplate::at($source, "unknown variable [{$matches[1]}]."); + } + + $value = $variables[$matches[1]]; + + foreach (array_filter(array_map(trim(...), explode('|', $matches[2]))) as $filter) { + $value = $this->filter($filter, $value, $source); + } + + return $value; + }, $string); + } + + private function filter(string $filter, string $value, string $source): string + { + return match ($filter) { + 'studly' => Str::studly($value), + 'camel' => Str::camel($value), + 'snake' => Str::snake(Str::camel($value)), + 'kebab' => Str::kebab(Str::camel($value)), + 'plural' => Str::plural($value), + 'singular' => Str::singular($value), + 'lower' => Str::lower($value), + 'upper' => Str::upper($value), + 'title' => Str::title($value), + 'json' => substr(json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 1, -1), + default => throw InvalidTemplate::at($source, "unknown filter [{$filter}]."), + }; + } +} diff --git a/src/Support/Composer.php b/src/Support/Composer.php deleted file mode 100644 index efa5d9f..0000000 --- a/src/Support/Composer.php +++ /dev/null @@ -1,97 +0,0 @@ - $packages - * @param bool $dev - * @param Closure|OutputInterface|null $output - * @param string|null $composerBinary - * @return bool - * - * @throws ComposerException - */ - public function updatePackages( - array $packages, - bool $dev = false, - Closure|OutputInterface|null $output = null, - ?string $composerBinary = null - ): bool { - return $this->runPackagesCommand('update', $packages, $dev, $output, $composerBinary); - } - - /** - * Remove the given Composer packages from the application. - * - * @param array $packages - * @param bool $dev - * @param Closure|OutputInterface|null $output - * @param string|null $composerBinary - * @return bool - * - * @throws ComposerException - */ - public function removePackages( - array $packages, - bool $dev = false, - Closure|OutputInterface|null $output = null, - $composerBinary = null - ): bool { - return $this->runPackagesCommand('remove', $packages, $dev, $output, $composerBinary); - } - - /** - * @param array $packages - * - * @throws ComposerException - */ - protected function runPackagesCommand( - string $action, - array $packages, - bool $dev, - Closure|OutputInterface|null $output, - ?string $composerBinary - ): bool { - foreach ($packages as $package) { - if (! static::isValidPackageName($package)) { - throw ComposerException::make("Invalid composer package name [{$package}]."); - } - } - - $command = [ - ...$this->findComposer($composerBinary), - $action, - ...($dev ? ['--dev'] : []), - // Everything after "--" is an argument, so a package name can never be read as an option. - '--', - ...$packages, - ]; - - return 0 === $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']) - ->run( - $output instanceof OutputInterface - ? function ($type, $line) use ($output) { - $output->write(' '.$line); - } : $output - ); - } -} diff --git a/src/Support/ComposerJsonFile.php b/src/Support/ComposerJsonFile.php deleted file mode 100644 index 2f2552a..0000000 --- a/src/Support/ComposerJsonFile.php +++ /dev/null @@ -1,215 +0,0 @@ -composerJsonHandler = new JsonFile($filePath); - } - - public static function create( - string $filePath - ): static { - return new static($filePath); - } - - /** - * Get an item from a json content using "dot" notation. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function get(string $key, mixed $default = null): mixed - { - return Arr::get($this->read(), $key, $default); - } - - /** - * Set JSON content item to a given value using "dot" notation. - * - * @param string $key - * @param mixed $value - * @return $this - */ - public function set(string $key, mixed $value): static - { - $this->read(); - Arr::set($this->jsonContent, $key, $value); - - return $this; - } - - public function addModule(string $modulePackageName, string $moduleRelativePath): static - { - $this->addPathRepository(dirname($moduleRelativePath)); - $this->excludeFromPackagist(Str::before($modulePackageName, '/') . '/*'); - - $packages = $this->get('require'); - - if (! isset($packages[$modulePackageName])) { - $packages[$modulePackageName] = '*'; - $this->set('require', $this->sortPackages($packages)); - } - - return $this; - } - - public function addPathRepository(string $path, array $options = ['symlink' => true]): static - { - $path = Str::finish(GeneratorHelper::normalizePath($path, true), '/*'); - - $repositories = $this->get('repositories', []); - $repositoryAlreadyExists = collect($repositories) - ->contains(fn ($repository) => is_array($repository) && ($repository['url'] ?? null) === $path); - - if ($repositoryAlreadyExists) { - return $this; - } - - $repositories[] = [ - 'type' => 'path', - 'url' => $path, - 'options' => $options, - ]; - - $this->set('repositories', $repositories); - - return $this; - } - - /** - * Never resolve the given package pattern (e.g. "app/*") from Packagist. - * - * Modules are required with "*" from a path repository. If a module directory is missing - * (deleted module, other branch, CI without the modules directory), Composer would otherwise - * fall back to Packagist and could install a foreign package with the same name. - */ - public function excludeFromPackagist(string $packagePattern): static - { - $repositories = $this->get('repositories', []); - - foreach ($repositories as $key => $repository) { - if (is_array($repository) && $this->isPackagistRepository($repository)) { - if (! isset($repository['only']) && ! in_array($packagePattern, $repository['exclude'] ?? [], true)) { - $repositories[$key]['exclude'] = [...($repository['exclude'] ?? []), $packagePattern]; - $this->set('repositories', $repositories); - } - - return $this; - } - } - - if ($this->isPackagistDisabled($repositories)) { - return $this; - } - - $packagist = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => [$packagePattern]]; - - if (array_is_list($repositories)) { - $repositories[] = $packagist; - $repositories[] = ['packagist.org' => false]; - } else { - $repositories['packagist'] = $packagist; - $repositories['packagist.org'] = false; - } - - $this->set('repositories', $repositories); - - return $this; - } - - /** - * @param array $repository - */ - protected function isPackagistRepository(array $repository): bool - { - return ($repository['type'] ?? null) === 'composer' - && in_array(rtrim((string) ($repository['url'] ?? ''), '/'), ['https://repo.packagist.org', 'https://packagist.org'], true); - } - - /** - * @param array $repositories - */ - protected function isPackagistDisabled(array $repositories): bool - { - foreach ($repositories as $key => $repository) { - if (($key === 'packagist.org' && $repository === false) - || (is_array($repository) && ($repository['packagist.org'] ?? null) === false)) { - return true; - } - } - - return false; - } - - /** - * Save the JSON content to a file - * - * @throws \Exception - */ - public function save(int $flags = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE): void - { - if ($this->jsonContent !== null) { - $this->composerJsonHandler->write($this->jsonContent, $flags); - } - } - - protected function read(): array - { - if ($this->jsonContent !== null) { - return $this->jsonContent; - } - - return $this->jsonContent = $this->composerJsonHandler->read(); - } - - /** - * @param array $packages - * @return array - */ - protected function sortPackages(array $packages): array - { - $prefix = fn ($requirement) - => preg_replace( - [ - '/^php$/', - '/^hhvm-/', - '/^ext-/', - '/^lib-/', - '/^\D/', - '/^(?!php$|hhvm-|ext-|lib-)/', - ], - [ - '0-$0', - '1-$0', - '2-$0', - '3-$0', - '4-$0', - '5-$0', - ], - $requirement - ); - - uksort($packages, function ($a, $b) use ($prefix) { - return strnatcmp($prefix($a), $prefix($b)); - }); - - return $packages; - } -} diff --git a/src/Support/Concerns/CanLoadRoutesFromDirectory.php b/src/Support/Concerns/CanLoadRoutesFromDirectory.php deleted file mode 100644 index 658d5af..0000000 --- a/src/Support/Concerns/CanLoadRoutesFromDirectory.php +++ /dev/null @@ -1,55 +0,0 @@ -loadRoutesFromDirectory( - $nestedDirectory, - $directoryRoutePrefix, - $generateRoutePrefixesByNestedDirectories - ); - } - - // Only "*.php" files are routes: leftovers like "*.php.orig" or "README.md" must never be required. - /** @var SplFileInfo[] $files */ - $files = Arr::sort( - array_filter(File::files($directory), static fn (SplFileInfo $file) => $file->getExtension() === 'php'), - static fn (SplFileInfo $file) => $file->getFilename() - ); - - Route::prefix($routePrefix)->group(function () use ($files) { - foreach ($files as $file) { - require $file->getPathname(); - } - }); - } -} diff --git a/src/Support/Concerns/CanRunModuleSeeders.php b/src/Support/Concerns/CanRunModuleSeeders.php deleted file mode 100644 index 9bd39c5..0000000 --- a/src/Support/Concerns/CanRunModuleSeeders.php +++ /dev/null @@ -1,158 +0,0 @@ -getSeederClassesFromModule($module, $subdirectories); - } - - $seederClasses = $this->sortSeederClasses(Arr::flatten($seederClasses)); - foreach ($seederClasses as $seeder) { - $this->call($seeder); - } - } - - /** - * Seeders run in the order of their integer "_N" suffix (e.g. "PermissionsSeeder_1"), - * seeders without such a suffix run last; ties are ordered by class name. - */ - protected function sortSeederClasses(array $seedersClasses): array - { - $order = static function (string $seederClass): int { - $suffix = Str::afterLast(class_basename($seederClass), '_'); - - return $suffix !== class_basename($seederClass) && ctype_digit($suffix) ? (int) $suffix : PHP_INT_MAX; - }; - - usort($seedersClasses, static fn (string $a, string $b): int => [$order($a), $a] <=> [$order($b), $b]); - - return $seedersClasses; - } - - protected function getSeederClassesFromModule(Module $module, array $subdirectories = []): array - { - $moduleSeedersPath = rtrim(GeneratorHelper::component(ModuleComponentType::Seeder)->getFullPath($module), '/'); - $paths = array_unique([ - $moduleSeedersPath, - ...array_map( - static fn ($subdirectory) => rtrim($moduleSeedersPath . '/' . trim($subdirectory, '/'), '/'), - $subdirectories - ), - ]); - - $seederClasses = []; - foreach ($paths as $path) { - if (File::isDirectory($path)) { - foreach (File::files($path) as $file) { - if ($file->getExtension() === 'php') { - $seederClasses[] = $this->getClassFullNameFromFile($file->getPathname()); - } - } - } - } - - return $seederClasses; - } - - /** - * Get the full name (name \ namespace) of a class from its file path - * result example: (string) "I\Am\The\Namespace\Of\This\Class" - * - * @param string $filePathName - * - * @return string - */ - public function getClassFullNameFromFile(string $filePathName): string - { - return $this->getClassNamespaceFromFile($filePathName) . '\\' . $this->getClassNameFromFile($filePathName); - } - - /** - * Get the class namespace form file path using token - * - * @param string $filePathName - * - * @return null|string - */ - protected function getClassNamespaceFromFile(string $filePathName): ?string - { - $src = file_get_contents($filePathName); - - $tokens = token_get_all($src); - $count = count($tokens); - $i = 0; - $namespace = ''; - $namespace_ok = false; - while ($i < $count) { - $token = $tokens[$i]; - if (is_array($token) && $token[0] === T_NAMESPACE) { - // Found namespace declaration - while (++$i < $count) { - if ($tokens[$i] === ';') { - $namespace_ok = true; - $namespace = trim($namespace); - - break; - } - $namespace .= is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i]; - } - - break; - } - $i++; - } - if (! $namespace_ok) { - return null; - } - - return $namespace; - } - - /** - * Get the class name from file path using token - * - * @param string $filePathName - * - * @return string - */ - protected function getClassNameFromFile(string $filePathName): string - { - $php_code = file_get_contents($filePathName); - - $classes = []; - $tokens = token_get_all($php_code); - $count = count($tokens); - for ($i = 2; $i < $count; $i++) { - if ($tokens[$i - 2][0] === T_CLASS - && $tokens[$i - 1][0] === T_WHITESPACE - && $tokens[$i][0] === T_STRING - ) { - $class_name = $tokens[$i][1]; - $classes[] = $class_name; - } - } - - return $classes[0]; - } -} diff --git a/src/Support/Concerns/InteractsWithTestUser.php b/src/Support/Concerns/InteractsWithTestUser.php deleted file mode 100644 index c044c0a..0000000 --- a/src/Support/Concerns/InteractsWithTestUser.php +++ /dev/null @@ -1,186 +0,0 @@ -|null - */ - protected ?string $testUserClass = null; - - /** - * Roles and permissions, to be attached on the user by default - * - * @var array{permissions?: string|array, roles?:string|array} - */ - protected array $testUserAccess = [ - 'permissions' => '', - 'roles' => '', - ]; - - protected ?string $testUserGuard = null; - - /** - * Create a test user without access and make him logged into the application. - * Same as `besTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @param string|null $guard - * - * @return $this - */ - public function beTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static - { - return $this->actingAsTestUserWithoutAccess($userDetails, $guard); - } - - /** - * Create a test user and make him logged into the application. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @param string|null $guard - * - * @return $this - */ - public function beTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static - { - return $this->actingAsTestUser($userDetails, $access, $guard); - } - - /** - * Create a test user without access and make him logged into the application. - * Same as `actingAsTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @param string|null $guard - * - * @return $this - */ - public function actingAsTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static - { - return $this->actingAs( - $this->createTestUserWithoutAccess($userDetails), - $guard ?? $this->testUserGuard - ); - } - - /** - * Create a test user and make him logged into the application. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @param string|null $guard - * - * @return $this - */ - public function actingAsTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static - { - return $this->actingAs( - $this->createTestUser($userDetails, $access), - $guard ?? $this->testUserGuard - ); - } - - /** - * Create test user without access. - * Same as `createTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @return U - */ - public function createTestUserWithoutAccess(?array $userDetails = null): UserContract - { - return $this->createTestUser($userDetails, [ - 'permissions' => null, - 'roles' => null, - ]); - } - - /** - * Create test user. - * By default, Users will be given the Roles and Permissions found in the class - * `$access` property. But the $access parameter can be used to override the - * defined roles and permissions in the `$access` property of your class. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @return U - */ - public function createTestUser(?array $userDetails = null, ?array $access = null): UserContract - { - $this->testUserClass = $this->testUserClass ?? config('modules.user_model'); - - if (! $this->testUserClass) { - throw new LogicException("User class was not provided!"); - } - - return $this->setupTestUserAccess( - $this->factoryCreateUser($userDetails), - $access - ); - } - - /** - * @param array|null $userDetails - * @return U - */ - private function factoryCreateUser(?array $userDetails = null): UserContract - { - if (! method_exists($this->testUserClass, 'factory')) { - throw new LogicException("class `$this->testUserClass` does not have method `factory()`"); - } - - return $this->testUserClass::factory()->create($this->prepareUserDetails($userDetails)); - } - - private function prepareUserDetails(?array $userDetails = null): array - { - $defaultUserDetails = [ - 'name' => 'Testing user', - 'email' => 'testing@test.com', - 'password' => 'testing-password', - ]; - $userDetails = $userDetails ? array_merge($defaultUserDetails, $userDetails) : $defaultUserDetails; - - $userDetails['password'] = Hash::make($userDetails['password']); - - return $userDetails; - } - - private function setupTestUserAccess(UserContract $user, ?array $access = null): UserContract - { - $access = $access ?: $this->testUserAccess; - - if ($access['permissions'] ?? false) { - if (! method_exists($user, 'givePermissionTo')) { - throw new LogicException("user instance does not have method `givePermissionTo()`, make sure the user class uses `spatie/laravel-permission`"); - } - $user->givePermissionTo($access['permissions']); - } - - if ($access['roles'] ?? false) { - if (! method_exists($user, 'assignRole')) { - throw new LogicException("user instance does not have method `assignRole()`, make sure the user class uses `spatie/laravel-permission`"); - } - $user->assignRole($access['roles']); - } - - return $user; - } -} diff --git a/src/Support/Concerns/WithJsonResponseHelpers.php b/src/Support/Concerns/WithJsonResponseHelpers.php deleted file mode 100644 index 86ab197..0000000 --- a/src/Support/Concerns/WithJsonResponseHelpers.php +++ /dev/null @@ -1,33 +0,0 @@ - getScanPaths() - * @method static $this addScanPath(string|string[] $scanPaths) - * @method static array buildModulesManifest() - * @method static bool pruneModulesManifest() - * @method static array getModules() - * @method static array getPackagesToIgnore() - * @method static bool has(string $modulePackageName) - * @method static int count() - * @method static Module|null find(string $modulePackageName) - * @method static Module findOrFail(string $modulePackageName) - * @method static array filterByName(string $moduleName) - * @method static array filterByNameOrFail(string $moduleName) - * @method static bool delete(string $modulePackageName, \Closure|OutputInterface|null $output = null) - * @method static void syncWithComposer(\Closure|OutputInterface|null $output = null) - * @method static array toArray() - * - * @see \Laraneat\Modules\ModulesRepository - */ -class Modules extends Facade -{ - /** - * Get the registered name of the component. - */ - protected static function getFacadeAccessor(): string - { - return ModulesRepository::class; - } -} diff --git a/src/Support/Generator/GeneratorHelper.php b/src/Support/Generator/GeneratorHelper.php deleted file mode 100755 index dc60a4c..0000000 --- a/src/Support/Generator/GeneratorHelper.php +++ /dev/null @@ -1,214 +0,0 @@ -value}"; - $generatorComponent = config($configPath); - - if (! is_array($generatorComponent) || empty($generatorComponent['path'])) { - throw InvalidConfigValue::make($configPath); - }; - - return new GeneratorPath($generatorComponent); - } - - /** - * Get module path - * - * @throws InvalidConfigValue - */ - public static function makeModulePath(Module|string $moduleOrName, ?string $subPath = null): ?string - { - if ($moduleOrName instanceof Module) { - return $moduleOrName->subPath($subPath); - } - - $modulePart = self::normalizePath($moduleOrName); - - if (! $modulePart) { - return null; - } - - $modulePath = self::getBasePath() . '/' . $modulePart; - - return $subPath ? $modulePath . '/' . self::normalizePath($subPath) : $modulePath; - } - - /** - * Get module namespace - * - * @throws InvalidConfigValue - */ - public static function makeModuleNamespace(Module|string $moduleOrName, ?string $subNamespace = null): ?string - { - if ($moduleOrName instanceof Module) { - return $moduleOrName->subNamespace($subNamespace); - } - - $modulePart = self::normalizeNamespace(Str::studly($moduleOrName)); - - if (! $modulePart) { - return null; - } - - $moduleNamespace = self::getBaseNamespace() . '\\' . $modulePart; - - return $subNamespace ? $moduleNamespace . '\\' . self::normalizeNamespace($subNamespace) : $moduleNamespace; - } - - /** - * Make relative path or returns null - */ - public static function makeRelativePath(string $from, string $to): ?string - { - $from = static::normalizePath($from, true); - $to = static::normalizePath($to, true); - - if ($from === $to) { - return ''; - } - - $fromSegments = explode('/', $from); - $toSegments = explode('/', $to); - - $diffStartIndex = null; - $fromSegmentsCount = count($fromSegments); - $toSegmentsCount = count($toSegments); - $segmentsCount = max($fromSegmentsCount, $toSegmentsCount); - for ($i = 0; $i < $segmentsCount; $i++) { - if (! isset($fromSegments[$i], $toSegments[$i]) || $fromSegments[$i] !== $toSegments[$i]) { - if ($i === 0 || $i === 1 && ! $fromSegments[0]) { - return null; - } - $diffStartIndex = $i; - - break; - } - } - - $relativePath = Str::repeat('../', $fromSegmentsCount - $diffStartIndex) - . join('/', array_slice($toSegments, $diffStartIndex)); - - return rtrim($relativePath, '/'); - } - - /** - * Normalize path to use only forward slash and trim slashes - */ - public static function normalizePath(string $path, $useRtrim = false): string - { - $path = str_replace('\\', '/', $path); - - return $useRtrim && Str::startsWith($path, '/') - ? '/' . trim($path, '/') - : trim($path, '/'); - } - - /** - * Normalize namespace to use only backslash and trim slashes - */ - public static function normalizeNamespace(string $namespace, $useRtrim = false): string - { - $namespace = str_replace('/', '\\', $namespace); - - return $useRtrim && Str::startsWith($namespace, '\\') - ? '\\' . trim($namespace, '\\') - : trim($namespace, '\\'); - } -} diff --git a/src/Support/Generator/GeneratorPath.php b/src/Support/Generator/GeneratorPath.php deleted file mode 100755 index f522389..0000000 --- a/src/Support/Generator/GeneratorPath.php +++ /dev/null @@ -1,37 +0,0 @@ -path = GeneratorHelper::normalizePath((string) $config['path']); - $this->namespace = GeneratorHelper::normalizeNamespace($config['namespace'] ?? $this->path); - } - - public function getPath(): string - { - return $this->path; - } - - public function getFullPath(Module|string $module): string - { - return GeneratorHelper::makeModulePath($module, $this->path); - } - - public function getNamespace(): string - { - return $this->namespace; - } - - public function getFullNamespace(Module|string $module): string - { - return GeneratorHelper::makeModuleNamespace($module, $this->namespace); - } -} diff --git a/src/Support/Generator/Stub.php b/src/Support/Generator/Stub.php deleted file mode 100755 index 523ca75..0000000 --- a/src/Support/Generator/Stub.php +++ /dev/null @@ -1,120 +0,0 @@ - - */ - protected array $replaces = []; - - /** - * @param string $path - * @param array $replaces - */ - public function __construct(string $path, array $replaces = []) - { - $this->path = $path; - $this->replaces = $replaces; - } - - /** - * Create new self instance. - * - * @param string $path - * @param array $replaces - * - * @return Stub - */ - public static function create(string $path, array $replaces = []): Stub - { - return new static($path, $replaces); - } - - /** - * Set stub path. - * - * @param string $path - * - * @return $this - */ - public function setPath(string $path): static - { - $this->path = $path; - - return $this; - } - - /** - * Get stub path. - */ - public function getPath(): string - { - $customStubsFolderPath = GeneratorHelper::getCustomStubsPath(); - $customStubFilePath = $customStubsFolderPath . '/' . ltrim($this->path, '/'); - - if (file_exists($customStubFilePath)) { - return $customStubFilePath; - } - - return __DIR__ . '/../../Commands/Generators/stubs/' . ltrim($this->path, '/'); - } - - /** - * Set replacements array. - * - * @param array $replaces - * - * @return $this - */ - public function setReplaces(array $replaces = []): static - { - $this->replaces = $replaces; - - return $this; - } - - /** - * Get replacements. - * - * @return array - */ - public function getReplaces(): array - { - return $this->replaces; - } - - /** - * Render stub contents. - */ - public function render(): string - { - $contents = file_get_contents($this->getPath()); - - foreach ($this->replaces as $search => $replace) { - $contents = str_replace( - ['{{' . $search . '}}', '{{ ' . $search . ' }}'], - $replace, - $contents - ); - } - - return $contents; - } - - /** - * Handle magic method __toString. - */ - public function __toString() - { - return $this->render(); - } -} diff --git a/src/Support/Migrations/NameParser.php b/src/Support/Migrations/NameParser.php deleted file mode 100755 index 84c656f..0000000 --- a/src/Support/Migrations/NameParser.php +++ /dev/null @@ -1,141 +0,0 @@ - [ - 'create', - 'make', - ], - 'delete' => [ - 'delete', - 'remove', - ], - 'add' => [ - 'add', - 'update', - 'append', - 'insert', - ], - ]; - - /** - * The constructor. - */ - public function __construct(string $name) - { - $this->name = $name; - $this->data = $this->fetchData(); - } - - /** - * Get original migration name. - */ - public function getOriginalName(): string - { - return $this->name; - } - - /** - * Get schema type or action. - */ - public function getAction(): string - { - return head($this->data); - } - - /** - * Get the table will be used. - */ - public function getTableName(): ?string - { - $matches = array_reverse($this->getMatches()); - - return array_shift($matches); - } - - /** - * Get matches data from regex. - */ - public function getMatches(): array - { - preg_match($this->getPattern(), $this->name, $matches); - - return $matches; - } - - /** - * Get name pattern. - */ - public function getPattern(): string - { - return match ($action = $this->getAction()) { - 'add', 'append', 'update', 'insert' => "/{$action}_(.*)_to_(.*)_table/", - 'delete', 'remove', 'alter' => "/{$action}_(.*)_from_(.*)_table/", - default => "/{$action}_(.*)_table/", - }; - } - - /** - * Fetch the migration name to an array data. - */ - protected function fetchData(): array - { - return explode('_', $this->name); - } - - /** - * Get the array data. - */ - public function getData(): array - { - return $this->data; - } - - /** - * Determine whether the given type is same with the current schema action or type. - */ - public function is(string $type): bool - { - return $type === $this->getAction(); - } - - /** - * Determine whether the current schema action is a adding action. - */ - public function isAdd(): bool - { - return in_array($this->getAction(), $this->actions['add'], true); - } - - /** - * Determine whether the current schema action is a deleting action. - */ - public function isDelete(): bool - { - return in_array($this->getAction(), $this->actions['delete'], true); - } - - /** - * Determine whether the current schema action is a creating action. - */ - public function isCreate(): bool - { - return in_array($this->getAction(), $this->actions['create'], true); - } -} diff --git a/src/Support/Migrations/SchemaParser.php b/src/Support/Migrations/SchemaParser.php deleted file mode 100755 index 514ab59..0000000 --- a/src/Support/Migrations/SchemaParser.php +++ /dev/null @@ -1,244 +0,0 @@ - 'rememberToken()', - 'soft_delete' => 'softDeletes()', - ]; - - /** - * The migration schema. - */ - protected ?string $schema; - - /** - * The relationship keys. - */ - protected array $relationshipKeys = [ - 'belongsTo', - ]; - - /** - * Create new instance. - */ - public function __construct(?string $schema = null) - { - $this->schema = $schema; - } - - /** - * Parse a string to array of formatted schema. - */ - public function parse(?string $schema): array - { - $this->schema = $schema; - - $parsed = []; - - foreach ($this->getSchemas() as $schemaArray) { - $column = $this->getColumn($schemaArray); - - $attributes = $this->getAttributes($column, $schemaArray); - - $parsed[$column] = $attributes; - } - - return $parsed; - } - - /** - * Get array of schema. - */ - public function getSchemas(): array - { - if (is_null($this->schema)) { - return []; - } - - return explode(',', str_replace(' ', '', $this->schema)); - } - - /** - * Convert string migration to array. - */ - public function toArray(): array - { - return $this->parse($this->schema); - } - - /** - * Render the migration to formatted script. - */ - public function render(): string - { - $results = ''; - - foreach ($this->toArray() as $column => $attributes) { - $results .= $this->createField($column, $attributes); - } - - return $results; - } - - /** - * Render up migration fields. - */ - public function up(): string - { - return $this->render(); - } - - /** - * Render down migration fields. - */ - public function down(): string - { - $results = ''; - - foreach ($this->toArray() as $column => $attributes) { - $results .= $this->createField($column, $attributes, 'remove'); - } - - return $results; - } - - /** - * Create field. - */ - public function createField(string $column, array $attributes, string $type = 'add'): string - { - $results = "\t\t\t" . '$table'; - - if (in_array($column, $this->relationshipKeys, true)) { - if ($type === 'add') { - $results .= $this->addRelationColumn($attributes, $column); - } elseif ($type === 'remove') { - $results .= $this->removeRelationColumn($attributes, $column); - } - } else { - if ($type === 'remove') { - $attributes = [head($attributes)]; - } - foreach ($attributes as $key => $field) { - $results .= $this->{"{$type}Column"}($key, $field, $column); - } - } - - return $results . ';' . PHP_EOL; - } - - /** - * Add relation column. - */ - protected function addRelationColumn(array $attributes, string $column): string - { - $result = ''; - - foreach ($attributes as $key => $field) { - if ($key === 0) { - $relatedColumn = Str::snake(class_basename($field)) . '_id'; - $result .= "->integer('$relatedColumn')->unsigned();" . PHP_EOL . "\t\t\t" . "\$table->foreign('$relatedColumn')"; - } elseif ($key === 1) { - $result .= "->references('$field')"; - } elseif ($key === 2) { - $result .= "->on('$field')"; - } elseif (Str::contains($field, '(')) { - $result .= '->' . $field; - } else { - $result .= '->' . $field . '()'; - } - } - - return $result; - } - - /** - * Remove relation column. - */ - protected function removeRelationColumn(array $attributes, string $column): string - { - if (! ($attributes[0] ?? null)) { - return ""; - } - - $relatedColumn = Str::snake(class_basename($attributes[0])) . '_id'; - - return "->dropColumn('$relatedColumn');" . PHP_EOL . "\t\t\t" . "\$table->dropForeign(['$relatedColumn'])"; - } - - /** - * Format field to script. - */ - protected function addColumn(int $key, string $field, string $column): string - { - if ($this->hasCustomAttribute($column)) { - return '->' . $field; - } - - if ($key === 0) { - return '->' . $field . "('" . $column . "')"; - } - - if (Str::contains($field, '(')) { - return '->' . $field; - } - - return '->' . $field . '()'; - } - - /** - * Format field to script. - */ - protected function removeColumn(int $key, string $field, string $column): string - { - if ($this->hasCustomAttribute($column)) { - return '->' . $field; - } - - return '->dropColumn(' . "'" . $column . "')"; - } - - /** - * Get column name from schema. - */ - public function getColumn(string $schema): string - { - return Arr::get(explode(':', $schema), 0); - } - - /** - * Get column attributes. - */ - public function getAttributes(string $column, string $schema): array - { - $fields = str_replace($column . ':', '', $schema); - - return $this->hasCustomAttribute($column) ? $this->getCustomAttribute($column) : explode(':', $fields); - } - - /** - * Determine whether the given column exists in the customAttributes array. - */ - public function hasCustomAttribute(string $column): bool - { - return array_key_exists($column, $this->customAttributes); - } - - /** - * Get custom attributes value. - */ - public function getCustomAttribute(string $column): array - { - return (array) $this->customAttributes[$column]; - } -} diff --git a/src/Support/ModuleConfigWriter.php b/src/Support/ModuleConfigWriter.php deleted file mode 100644 index 45c0825..0000000 --- a/src/Support/ModuleConfigWriter.php +++ /dev/null @@ -1,96 +0,0 @@ - $providers - * - * @throws FileNotFoundException - */ - public function updateProviders(Module $module, array $providers): void - { - $this->updateConfig($module, 'extra.laravel.providers', $providers); - } - - /** - * Update module aliases in composer.json - * - * @param Module $module - * @param array $aliases - * - * @throws FileNotFoundException - */ - public function updateAliases(Module $module, array $aliases): void - { - $this->updateConfig($module, 'extra.laravel.aliases', $aliases); - } - - /** - * Add a provider to the module - * - * @param Module $module - * @param class-string $providerClass - * - * @throws FileNotFoundException - */ - public function addProvider(Module $module, string $providerClass): void - { - $providers = $module->getProviders(); - - if (! in_array($providerClass, $providers, true)) { - $providers[] = $providerClass; - $this->updateProviders($module, $providers); - } - } - - /** - * Add an alias to the module - * - * @param Module $module - * @param string $alias - * @param class-string $class - * - * @throws FileNotFoundException - */ - public function addAlias(Module $module, string $alias, string $class): void - { - $aliases = $module->getAliases(); - - if (! isset($aliases[$alias]) || $aliases[$alias] !== $class) { - $aliases[$alias] = $class; - $this->updateAliases($module, $aliases); - } - } - - /** - * Update a config value in module's composer.json - * - * @param Module $module - * @param string $key - * @param mixed $value - * - * @throws FileNotFoundException|\Exception - */ - protected function updateConfig(Module $module, string $key, mixed $value): void - { - ComposerJsonFile::create($module->getPath() . '/composer.json') - ->set($key, $value) - ->save(); - - $this->modulesRepository->pruneModulesManifest(); - } -} diff --git a/src/Support/ModuleServiceProvider.php b/src/Support/ModuleServiceProvider.php deleted file mode 100644 index c72db3e..0000000 --- a/src/Support/ModuleServiceProvider.php +++ /dev/null @@ -1,111 +0,0 @@ - $pathsByNamespace - * - * @throws ReflectionException - */ - protected function loadCommandsFrom( - array $pathsByNamespace, - ): void { - $pathsByNamespace = array_unique(Arr::wrap($pathsByNamespace)); - $pathsByNamespace = array_filter($pathsByNamespace, static function ($path) { - return is_dir($path); - }); - - if (empty($pathsByNamespace)) { - return; - } - - foreach ($pathsByNamespace as $namespace => $path) { - foreach (Finder::create()->in($path)->files() as $file) { - $command = $this->commandClassFromFile($file, $path, $namespace); - - if ( - is_subclass_of($command, Command::class) && - ! (new ReflectionClass($command))->isAbstract() - ) { - Artisan::starting(function ($artisan) use ($command) { - $artisan->resolve($command); - }); - } - } - } - } - - /** - * Extract the command class name from the given file path. - */ - protected function commandClassFromFile( - SplFileInfo $file, - string $basePath, - string $baseNamespace - ): string { - return rtrim($baseNamespace, '\\') . '\\' . str_replace( - ['/', '.php'], - ['\\', ''], - Str::after($file->getRealPath(), realpath($basePath) . DIRECTORY_SEPARATOR) - ); - } - - /** - * Load files from directory - */ - protected function loadFiles(string $directory): void - { - if (File::isDirectory($directory)) { - foreach (File::files($directory) as $file) { - if ($file->getExtension() === 'php') { - require_once $file; - } - } - } - } - - /** - * Load all files from directory - */ - protected function loadAllFiles(string $directory): void - { - if (File::isDirectory($directory)) { - foreach (File::allFiles($directory) as $file) { - if ($file->getExtension() === 'php') { - require_once $file; - } - } - } - } -} diff --git a/tests/Arch/ArchTest.php b/tests/Arch/ArchTest.php new file mode 100644 index 0000000..95b383d --- /dev/null +++ b/tests/Arch/ArchTest.php @@ -0,0 +1,61 @@ +expect('Laraneat\Modules') + ->toUseStrictTypes(); + +arch('classes are final') + ->expect('Laraneat\Modules') + ->classes() + ->toBeFinal(); + +arch('exceptions are marked') + ->expect('Laraneat\Modules\Exceptions') + ->classes() + ->toImplement('Laraneat\Modules\Exceptions\ModulesException'); + +foreach (['dd', 'dump', 'ray', 'var_dump', 'print_r', 'die', 'exit', 'env', 'getenv', 'putenv'] as $function) { + arch("{$function}() is not used") + ->expect($function) + ->not->toBeUsedIn('Laraneat\Modules'); +} + +foreach (['exec', 'shell_exec', 'system', 'passthru', 'proc_open', 'popen', 'Symfony\Component\Process\Process'] as $process) { + arch("{$process} is not used: processes run through the Process facade") + ->expect($process) + ->not->toBeUsedIn('Laraneat\Modules'); +} + +arch('Composer runs only in the composer runner') + ->expect('Illuminate\Support\Facades\Process') + ->toOnlyBeUsedIn('Laraneat\Modules\Scaffold\ComposerRunner'); + +arch('the package does not depend on Composer internals') + ->expect('Laraneat\Modules') + ->not->toUse('Composer'); + +$runtime = [ + 'Laraneat\Modules\Module', + 'Laraneat\Modules\ModuleRepository', + 'Laraneat\Modules\Facades', + 'Laraneat\Modules\Manifest', + 'Laraneat\Modules\Registrars', +]; + +foreach ($runtime as $namespace) { + foreach ([ + 'Laraneat\Modules\Commands', + 'Laraneat\Modules\Generators', + 'Laraneat\Modules\Scaffold\ApplicationComposer', + 'Laraneat\Modules\Scaffold\ComposerJson', + 'Laraneat\Modules\Scaffold\ComposerRunner', + 'Laraneat\Modules\Scaffold\InstalledPackages', + 'Laraneat\Modules\Scaffold\TemplateRenderer', + ] as $console) { + arch("{$namespace} does not use the console-only {$console}") + ->expect($namespace) + ->not->toUse($console); + } +} diff --git a/tests/Commands/BaseCommandTest.php b/tests/Commands/BaseCommandTest.php deleted file mode 100644 index 63f62ea..0000000 --- a/tests/Commands/BaseCommandTest.php +++ /dev/null @@ -1,513 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -describe('single "module" argument', function () { - class CommandWithSingleModuleArgument extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'single-module-argument-command {module? : Module name}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $moduleToHandle = $this->getModuleArgumentOrFail(); - $this->line($moduleToHandle->getPackageName()); - } catch (ModuleNotFound $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithSingleModuleArgument($this->app[ModulesRepository::class])); - }); - - it('can accept a package name as a single "module" argument', function () { - $this->artisan('single-module-argument-command laraneat/article-category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/empty') - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command empty/empty') - ->expectsOutput('empty/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - }); - - it('can accept a module name as a single "module" argument', function () { - $this->artisan('single-module-argument-command Article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command article-category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command ArticleCategory') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command Location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command navigation') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command Navigation') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - }); - - it('displays an error message when passing an invalid single "module" argument', function () { - $this->artisan('single-module-argument-command laraneat/articlee') - ->expectsOutput("Module with 'laraneat/articlee' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command laraneat') - ->expectsOutput("Module with 'laraneat' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command laraneat/article/') - ->expectsOutput("Module with 'laraneat/article/' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command /article') - ->expectsOutput("Module with '/article' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command articlee') - ->expectsOutput("Module with 'articlee' name or package name does not exist!") - ->assertFailed(); - }); - - it('gives a module selection if the "module" argument is not passed', function () { - $this->artisan('single-module-argument-command') - ->expectsChoice( - question: 'Select one module', - answer: 'laraneat/empty', - answers: [ - 'laraneat/article-category', - 'laraneat/article', - 'laraneat/author', - 'laraneat/empty', - 'empty/empty', - 'laraneat/location', - ] - ) - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - }); - - it('gives a module selection if 2 or more modules with the same names are found', function () { - $expectedChoiceOptions = [ - 'laraneat/empty', - 'empty/empty', - ]; - - $this->artisan('single-module-argument-command Empty') - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'empty/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('empty/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - }); -}); - -describe('multiple "module" argument', function () { - class CommandWithMultipleModuleArgument extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'multiple-module-argument-command {module?* : Module name(s) or package name(s)}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $modulesToHandle = $this->getModuleArgumentOrFail(); - $this->line( - collect($modulesToHandle) - ->map(fn (\Laraneat\Modules\Module $module) => $module->getPackageName()) - ->join(', ') - ); - } catch (ModuleNotFound $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithMultipleModuleArgument($this->app[ModulesRepository::class])); - }); - - it('can accept a package name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command laraneat/article laraneat/empty empty/empty') - ->expectsOutput('laraneat/article, laraneat/empty, empty/empty') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/article laraneat/article laraneat/empty empty/empty laraneat/empty') - ->expectsOutput('laraneat/article, laraneat/empty, empty/empty') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author laraneat/author laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - }); - - it('can accept "all" as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command all') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command all laraneat/empty laraneat/author') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/empty all laraneat/author') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author laraneat/empty all') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - }); - - it('can accept a module name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command Article location Navigation ArticleCategory Location article-category') - ->expectsOutput('laraneat/article, laraneat/location, laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command ArticleCategory articleCategory article-category article_category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - }); - - it('can accept a module name and package name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command Article location Navigation empty/empty ArticleCategory Location article-category') - ->expectsOutput('laraneat/article, laraneat/location, empty/empty, laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command ArticleCategory articleCategory laraneat/article-category article-category article_category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - }); - - it('displays an error message when passing an invalid multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command laraneat/empty laraneat/articlee laraneat/navigation') - ->expectsOutput("Module with 'laraneat/articlee' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command laraneat laraneat/navigation') - ->expectsOutput("Module with 'laraneat' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command Author laraneat/navigation') - ->expectsOutput("Module with 'laraneat/navigation' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command article /article') - ->expectsOutput("Module with '/article' name or package name does not exist!") - ->assertFailed(); - }); - - it('gives a module selection if the multiple "module" argument is not passed', function () { - // Laravel's expectsChoice expects keys and values merged when using associative arrays - // The options are: ['all' => 'All modules', 'laraneat/article' => 'laraneat/article', ...] - // This results in: ['All modules', 'all', 'empty/empty', 'empty/empty', ...] - // In Laravel 10, Symfony adds empty string at position 0 and 'None' at position 2 for multiselect prompts - $isLaravel10 = version_compare(app()->version(), '11.0', '<'); - - $expectedAnswers = $isLaravel10 - ? [ - '', // empty string added by Symfony in Laravel 10 - 'All modules', - 'None', // 'None' option added by Symfony in Laravel 10 - 'all', - 'empty/empty', - 'empty/empty', - 'laraneat/article', - 'laraneat/article', - 'laraneat/article-category', - 'laraneat/article-category', - 'laraneat/author', - 'laraneat/author', - 'laraneat/empty', - 'laraneat/empty', - 'laraneat/location', - 'laraneat/location', - ] - : [ - 'All modules', - 'all', - 'empty/empty', - 'empty/empty', - 'laraneat/article', - 'laraneat/article', - 'laraneat/article-category', - 'laraneat/article-category', - 'laraneat/author', - 'laraneat/author', - 'laraneat/empty', - 'laraneat/empty', - 'laraneat/location', - 'laraneat/location', - ]; - - $this->artisan('multiple-module-argument-command') - ->expectsChoice( - question: 'Select one or more module', - answer: ['laraneat/article', 'empty/empty', 'laraneat/location'], - answers: $expectedAnswers - ) - ->expectsOutput('laraneat/article, empty/empty, laraneat/location') - ->assertSuccessful(); - }); - - it('gives a module selection if 2 or more modules with the same names are found', function () { - $expectedChoiceOptions = [ - 'laraneat/empty', - 'empty/empty', - ]; - - $this->artisan('multiple-module-argument-command empty laraneat/article Author Empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty, laraneat/article, laraneat/author') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command empty laraneat/article Author Empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'empty/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty, laraneat/article, laraneat/author, empty/empty') - ->assertSuccessful(); - }); -}); - -describe('getOptionOrAsk', function () { - class CommandWithOptionAsking extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'command-with-option-asking {--foo=}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $this->line($this->getOptionOrAsk('foo', 'Enter "foo" option')); - } catch (InvalidOptionException $exception) { - $this->line($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithOptionAsking($this->app[ModulesRepository::class])); - }); - - it('asks for the option value if it is not specified', function () { - $this->artisan('command-with-option-asking') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: 'some foo value', - ) - ->expectsOutput('some foo value') - ->assertSuccessful(); - - $this->artisan('command-with-option-asking --foo=') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: 'some foo value 2', - ) - ->expectsOutput('some foo value 2') - ->assertSuccessful(); - - $this->artisan('command-with-option-asking') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: '', - ) - ->expectsOutput("The 'foo' option is required") - ->assertFailed(); - }); - - it('does not ask for an option value if it specified', function () { - $this->artisan('command-with-option-asking --foo=some-foo-value') - ->expectsOutput('some-foo-value') - ->assertSuccessful(); - }); -}); - -describe('getOptionOrChoice', function () { - class CommandWithOptionChoice extends BaseCommand - { - public const CHOICES = ['first', 'second', 'third']; - - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'command-with-option-choice {--foo=}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $this->line($this->getOptionOrChoice('foo', 'Choice the "foo" option', self::CHOICES)); - } catch (InvalidOptionException $exception) { - $this->line($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithOptionChoice($this->app[ModulesRepository::class])); - }); - - it('let you choose an option value if it is not specified', function () { - $this->artisan('command-with-option-choice') - ->expectsChoice( - question: 'Choice the "foo" option', - answer: 'first', - answers: CommandWithOptionChoice::CHOICES - ) - ->expectsOutput('first') - ->assertSuccessful(); - - $this->artisan('command-with-option-choice --foo=') - ->expectsChoice( - question: 'Choice the "foo" option', - answer: 'second', - answers: CommandWithOptionChoice::CHOICES - ) - ->expectsOutput('second') - ->assertSuccessful(); - }); - - it('doesnt let you choose an option value if it specified', function () { - $this->artisan('command-with-option-choice --foo=first') - ->expectsOutput('first') - ->assertSuccessful(); - }); - - it('shows an error if the passed option value is not valid', function () { - $this->artisan('command-with-option-choice --foo=some-invalid-value') - ->expectsOutput("Wrong 'foo' option value provided. Value should be one of 'first' or 'second' or 'third'.") - ->assertFailed(); - }); -}); diff --git a/tests/Commands/CacheClearCommandTest.php b/tests/Commands/CacheClearCommandTest.php deleted file mode 100644 index 779de5b..0000000 --- a/tests/Commands/CacheClearCommandTest.php +++ /dev/null @@ -1,13 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - }); - $this->artisan('module:clear') - ->expectsOutputToContain('Modules manifest cache cleared!') - ->assertSuccessful(); -}); diff --git a/tests/Commands/CacheCommandTest.php b/tests/Commands/CacheCommandTest.php deleted file mode 100644 index 6206208..0000000 --- a/tests/Commands/CacheCommandTest.php +++ /dev/null @@ -1,13 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('buildModulesManifest')->once(); - }); - $this->artisan('module:cache') - ->expectsOutputToContain('Modules manifest cached!') - ->assertSuccessful(); -}); diff --git a/tests/Commands/Generators/ActionMakeCommandTest.php b/tests/Commands/Generators/ActionMakeCommandTest.php deleted file mode 100644 index 33cfc04..0000000 --- a/tests/Commands/Generators/ActionMakeCommandTest.php +++ /dev/null @@ -1,106 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'PlainAuthorAction', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/PlainAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'CreateAuthorAction', - 'module' => 'Author', - '--stub' => 'create', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - '--request' => 'CreateAuthorRequest', - '--resource' => 'AuthorResource', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/CreateAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'UpdateAuthorAction', - 'module' => 'Author', - '--stub' => 'update', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - '--request' => 'UpdateAuthorRequest', - '--resource' => 'AuthorResource', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/UpdateAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'DeleteAuthorAction', - 'module' => 'Author', - '--stub' => 'delete', - '--model' => 'Author', - '--request' => 'DeleteAuthorRequest', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/DeleteAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "view" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'ViewAuthorAction', - 'module' => 'Author', - '--stub' => 'view', - '--model' => 'Author', - '--request' => 'ViewAuthorRequest', - '--resource' => 'AuthorResource', - '--wizard' => 'AuthorQueryWizard', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/ViewAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "list" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'ListAuthorsAction', - 'module' => 'Author', - '--stub' => 'list', - '--model' => 'Author', - '--request' => 'ListAuthorsRequest', - '--resource' => 'AuthorResource', - '--wizard' => 'AuthorsQueryWizard', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/ListAuthorsAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php b/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php deleted file mode 100644 index a29f586..0000000 --- a/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php +++ /dev/null @@ -1,160 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../../fixtures/stubs/modules/valid/article', - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -describe('getFullClassFromOptionOrAsk()', function () { - class GetFullClassFromOptionOrAskTestCommand extends BaseComponentGeneratorCommand - { - protected $signature = 'test:command {module?} {--model=}'; - - public function handle(): int - { - $module = $this->getModuleArgumentOrFail(); - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter "model" class name', - componentType: ModuleComponentType::Model, - module: $module - ); - $this->line($modelClass); - - return self::SUCCESS; - } - - protected function getContents(): string - { - return ''; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new GetFullClassFromOptionOrAskTestCommand( - $this->app[ModulesRepository::class], - $this->app[Filesystem::class], - )); - }); - - - it('returns the full class of component if only the base class name is specified', function () { - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => 'SomeGeoLocationModel', - ]) - ->expectsOutput('Modules\\GeoLocation\\Models\\SomeGeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => 'Some\\GeoLocationModel', - ]) - ->expectsOutput('Modules\\GeoLocation\\Models\\Some\\GeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', 'SomeGeoLocationModel') - ->expectsOutput('Modules\\GeoLocation\\Models\\SomeGeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', 'Some\\GeoLocationModel') - ->expectsOutput('Modules\\GeoLocation\\Models\\Some\\GeoLocationModel'); - }); - - it('returns the specified class if it begins with a backslash', function () { - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', '\\Modules\\Article\\Models\\Article') - ->expectsOutput('\\Modules\\Article\\Models\\Article'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => '\\Modules\\Article\\Models\\Article', - ]) - ->expectsOutput('\\Modules\\Article\\Models\\Article'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', '\\Modules\\GeoLocation\\Models\\GeoLocation') - ->expectsOutput('\\Modules\\GeoLocation\\Models\\GeoLocation'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => '\\Modules\\GeoLocation\\Models\\GeoLocation', - ]) - ->expectsOutput('\\Modules\\GeoLocation\\Models\\GeoLocation'); - }); -}); - -describe('class name validation', function () { - it('rejects invalid class names starting with a number', function () { - $this->artisan('module:make:action', [ - 'name' => '123Invalid', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('rejects invalid class names with special characters', function () { - $this->artisan('module:make:action', [ - 'name' => 'Invalid-Class', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('rejects invalid class names with spaces', function () { - $this->artisan('module:make:action', [ - 'name' => 'Invalid Class', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('accepts valid class names', function () { - $this->artisan('module:make:action', [ - 'name' => 'ValidAction', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $this->artisan('module:make:action', [ - 'name' => '_ValidAction', - 'module' => 'Author', - '--stub' => 'plain', - '--force' => true, - ]) - ->assertSuccessful(); - - $this->artisan('module:make:action', [ - 'name' => 'Valid123Action', - 'module' => 'Author', - '--stub' => 'plain', - '--force' => true, - ]) - ->assertSuccessful(); - }); -}); diff --git a/tests/Commands/Generators/CommandMakeCommandTest.php b/tests/Commands/Generators/CommandMakeCommandTest.php deleted file mode 100644 index fc827b6..0000000 --- a/tests/Commands/Generators/CommandMakeCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates console command for the module', function () { - $this->artisan('module:make:command', [ - 'name' => 'SomeAuthorCommand', - 'module' => 'Author', - '--signature' => 'author:some-command {--foo : foo option}', - '--description' => 'Some author command description', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/CLI/Commands/SomeAuthorCommand.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ControllerMakeCommandTest.php b/tests/Commands/Generators/ControllerMakeCommandTest.php deleted file mode 100644 index b5b8296..0000000 --- a/tests/Commands/Generators/ControllerMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "api" controller for the module', function () { - $this->artisan('module:make:controller', [ - 'name' => 'ApiAuthorController', - 'module' => 'Author', - '--ui' => 'api', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Controllers/ApiAuthorController.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "web" controller for the module', function () { - $this->artisan('module:make:controller', [ - 'name' => 'WebAuthorController', - 'module' => 'Author', - '--ui' => 'web', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Controllers/WebAuthorController.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/DTOMakeCommandTest.php b/tests/Commands/Generators/DTOMakeCommandTest.php deleted file mode 100644 index fe221d6..0000000 --- a/tests/Commands/Generators/DTOMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates dto for the module', function () { - $this->artisan('module:make:dto', [ - 'name' => 'SomeAuthorDTO', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/DTO/SomeAuthorDTO.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/EventMakeCommandTest.php b/tests/Commands/Generators/EventMakeCommandTest.php deleted file mode 100644 index d0482ff..0000000 --- a/tests/Commands/Generators/EventMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates event for the module', function () { - $this->artisan('module:make:event', [ - 'name' => 'SomeAuthorEvent', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Events/SomeAuthorEvent.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ExceptionMakeCommandTest.php b/tests/Commands/Generators/ExceptionMakeCommandTest.php deleted file mode 100644 index 820b44b..0000000 --- a/tests/Commands/Generators/ExceptionMakeCommandTest.php +++ /dev/null @@ -1,50 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates exception for the module', function () { - $this->artisan('module:make:exception', [ - 'name' => 'SomeAuthorException', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Exceptions/SomeAuthorException.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('rejects names that would escape the component directory', function (string $name) { - $filesBefore = getRelativeFilePathsInDirectory($this->app->basePath()); - - $this->artisan('module:make:exception', [ - 'name' => $name, - 'module' => 'Author', - ]) - ->expectsOutputToContain('is not a valid PHP class name') - ->assertFailed(); - - expect(getRelativeFilePathsInDirectory($this->app->basePath()))->toBe($filesBefore); -})->with([ - '../../../../SecOne', - 'Nested/../../Escape', - 'Foo/Bar-Baz', - 'Foo/./Bar', -]); - -it('accepts sub-namespaces in the name', function () { - $this->artisan('module:make:exception', [ - 'name' => 'Nested\\Deeper/SomeAuthorException', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - assertFileExists($this->app->basePath('/modules/author/src/Exceptions/Nested/Deeper/SomeAuthorException.php')); -}); diff --git a/tests/Commands/Generators/FactoryMakeCommandTest.php b/tests/Commands/Generators/FactoryMakeCommandTest.php deleted file mode 100644 index 8dc4410..0000000 --- a/tests/Commands/Generators/FactoryMakeCommandTest.php +++ /dev/null @@ -1,23 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates factory for the module', function () { - $this->artisan('module:make:factory', [ - 'name' => 'SomeAuthorFactory', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/factories/SomeAuthorFactory.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/JobMakeCommandTest.php b/tests/Commands/Generators/JobMakeCommandTest.php deleted file mode 100644 index d43b528..0000000 --- a/tests/Commands/Generators/JobMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" job for the module', function () { - $this->artisan('module:make:job', [ - 'name' => 'PlainAuthorJob', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Jobs/PlainAuthorJob.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" job for the module', function () { - $this->artisan('module:make:job', [ - 'name' => 'QueuedAuthorJob', - 'module' => 'Author', - '--stub' => 'queued', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Jobs/QueuedAuthorJob.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ListenerMakeCommandTest.php b/tests/Commands/Generators/ListenerMakeCommandTest.php deleted file mode 100644 index 5b2b467..0000000 --- a/tests/Commands/Generators/ListenerMakeCommandTest.php +++ /dev/null @@ -1,38 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" listener for the module', function () { - $this->artisan('module:make:listener', [ - 'name' => 'PlainAuthorListener', - 'module' => 'Author', - '--stub' => 'plain', - '--event' => 'SomeAuthorEvent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Listeners/PlainAuthorListener.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" listener for the module', function () { - $this->artisan('module:make:listener', [ - 'name' => 'QueuedAuthorListener', - 'module' => 'Author', - '--stub' => 'queued', - '--event' => 'SomeAuthorEvent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Listeners/QueuedAuthorListener.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MailMakeCommandTest.php b/tests/Commands/Generators/MailMakeCommandTest.php deleted file mode 100644 index 1c04001..0000000 --- a/tests/Commands/Generators/MailMakeCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates mail for the module', function () { - $this->artisan('module:make:mail', [ - 'name' => 'SomeAuthorMail', - 'module' => 'Author', - '--subject' => 'Some testing subject', - '--view' => 'author.mail.some-view', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Mails/SomeAuthorMail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MiddlewareMakeCommandTest.php b/tests/Commands/Generators/MiddlewareMakeCommandTest.php deleted file mode 100644 index 4ea6e63..0000000 --- a/tests/Commands/Generators/MiddlewareMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates middleware for the module', function () { - $this->artisan('module:make:middleware', [ - 'name' => 'SomeAuthorMiddleware', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Middleware/SomeAuthorMiddleware.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MigrationMakeCommandTest.php b/tests/Commands/Generators/MigrationMakeCommandTest.php deleted file mode 100644 index 431e612..0000000 --- a/tests/Commands/Generators/MigrationMakeCommandTest.php +++ /dev/null @@ -1,231 +0,0 @@ -setModules([ - realpath(__DIR__ . '/../../fixtures/stubs/modules/valid/article'), - ], $this->app->basePath('/modules')); - - $date = Carbon::parse('2024-01-01'); - $this->travelTo($date); - $this->dateStamp = $date->format('Y_m_d_His_'); -}); - -it('generates "plain" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'some_plain_migration', - 'module' => 'Article', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}some_plain_migration.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "plain" migration for the module if the stub is not recognized by name', function () { - $this->artisan('module:make:migration', [ - 'name' => 'some_plain_migration', - 'module' => 'Article', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}some_plain_migration.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'new_articles_table', - 'module' => 'Article', - '--stub' => 'create', - '--fields' => 'title:string,excerpt:text,content:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}new_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'new_articles_table', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}new_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "create" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_articles_table', - 'module' => 'Article', - '--fields' => 'title:string,excerpt:text,content:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}create_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "add" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'add', - '--fields' => 'title:string,excerpt:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "add" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'add', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "add" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'add_title_to_articles_table', - 'module' => 'Article', - '--fields' => 'title:string', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}add_title_to_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'delete', - '--fields' => 'title:string,excerpt:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "delete" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'delete_title_from_articles_table', - 'module' => 'Article', - '--fields' => 'title:string', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}delete_title_from_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "pivot" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_article_author_table', - 'module' => 'Article', - '--stub' => 'pivot', - ]) - ->expectsQuestion('Enter the name of first table', 'articles') - ->expectsQuestion('Enter the name of second table', 'authors') - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}create_article_author_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -describe('table name validation', function () { - it('rejects invalid table names with special characters', function () { - // Use a migration name that doesn't auto-detect the table name, - // so we can enter an invalid one via the prompt - $this->artisan('module:make:migration', [ - 'name' => 'modify_something', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->expectsQuestion('Enter the table name', 'invalid-table') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('rejects invalid table names starting with a number', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_something', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->expectsQuestion('Enter the table name', '123table') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('rejects invalid pivot table names', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_article_author_table', - 'module' => 'Article', - '--stub' => 'pivot', - ]) - ->expectsQuestion('Enter the name of first table', 'invalid-table') - ->expectsQuestion('Enter the name of second table', 'authors') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('accepts valid table names', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_valid_table_table', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->assertSuccessful(); - - $this->artisan('module:make:migration', [ - 'name' => 'create__underscore_table_table', - 'module' => 'Article', - '--stub' => 'create', - '--force' => true, - ]) - ->assertSuccessful(); - }); -}); diff --git a/tests/Commands/Generators/ModelMakeCommandTest.php b/tests/Commands/Generators/ModelMakeCommandTest.php deleted file mode 100644 index 88d76f9..0000000 --- a/tests/Commands/Generators/ModelMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates plain model for the module', function () { - $this->artisan('module:make:model', [ - 'name' => 'AuthorEmail', - 'module' => 'Author', - ]) - ->expectsQuestion('Enter the class name of the factory to be used in the model (optional)', '') - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Models/AuthorEmail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates model with factory for the module', function () { - $this->artisan('module:make:model', [ - 'name' => 'AuthorEmail', - 'module' => 'Author', - '--factory' => 'AuthorEmailFactory', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Models/AuthorEmail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ModuleMakeCommandTest.php b/tests/Commands/Generators/ModuleMakeCommandTest.php deleted file mode 100644 index e52da3b..0000000 --- a/tests/Commands/Generators/ModuleMakeCommandTest.php +++ /dev/null @@ -1,152 +0,0 @@ -backupComposerJson(); - $this->setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../../fixtures/stubs/modules/valid/article', - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ]); - - $this->travelTo(Carbon::parse('2024-01-01')); -}); - -it('generates a "plain" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'plain', - '--entity' => 'ArticleComment', - ]) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('generates a "base" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'base', - '--entity' => 'ArticleComment', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('generates a "api" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'api', - '--entity' => 'ArticleComment', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('displays an error message if the passed module name is not valid', function () { - $this->artisan('module:make', [ - 'name' => '1foo', - '--preset' => 'plain', - '--entity' => 'Foo', - ]) - ->expectsOutputToContain("The module name passed is not valid!") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('displays an error message when a module with the same package name already exists', function () { - $this->artisan('module:make', [ - 'name' => 'laraneat/article', - '--preset' => 'plain', - '--entity' => 'Article', - ]) - ->expectsOutputToContain("Module 'laraneat/article' already exist!") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('displays an error message when a module with the same folder name already exists', function () { - $this->artisan('module:make', [ - 'name' => 'demo/article', - '--preset' => 'plain', - '--entity' => 'Article', - ]) - ->expectsOutputToContain("File already exists") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('rejects a vendor that is not a valid composer package name', function (string $name) { - $composer = Mockery::mock(Composer::class); - $composer->shouldNotReceive('updatePackages'); - $this->instance(Composer::class, $composer); - $composerJsonBefore = file_get_contents($this->app->basePath('/composer.json')); - - $this->artisan('module:make', ['name' => $name, '--preset' => 'plain']) - ->expectsOutputToContain('is not a valid composer package name') - ->assertFailed(); - - expect($this->app->basePath('/modules/blog'))->not->toBeDirectory() - ->and(file_get_contents($this->app->basePath('/composer.json')))->toBe($composerJsonBefore); -})->with([ - 'json injection' => ['evil","scripts":{"x":"y"},"a":"/blog'], - 'option-like vendor' => ['-evil/blog'], -]); - -it('escapes the configured author when generating composer.json', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - config([ - 'modules.composer.author.name' => 'Conan O"Brien \\ Co', - 'modules.composer.author.email' => 'conan@example.com', - ]); - - $this->artisan('module:make', ['name' => 'demo/blog', '--preset' => 'plain']) - ->assertSuccessful(); - - $composerJson = json_decode(file_get_contents($this->app->basePath('/modules/blog/composer.json')), true, 512, JSON_THROW_ON_ERROR); - - expect($composerJson['authors'][0])->toBe(['name' => 'Conan O"Brien \\ Co', 'email' => 'conan@example.com']) - ->and(array_keys($composerJson))->not->toContain('scripts'); -}); diff --git a/tests/Commands/Generators/NotificationMakeCommandTest.php b/tests/Commands/Generators/NotificationMakeCommandTest.php deleted file mode 100644 index b659a9c..0000000 --- a/tests/Commands/Generators/NotificationMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" notification for the module', function () { - $this->artisan('module:make:notification', [ - 'name' => 'PlainAuthorNotification', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Notifications/PlainAuthorNotification.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" notification for the module', function () { - $this->artisan('module:make:notification', [ - 'name' => 'QueuedAuthorNotification', - 'module' => 'Author', - '--stub' => 'queued', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Notifications/QueuedAuthorNotification.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ObserverMakeCommandTest.php b/tests/Commands/Generators/ObserverMakeCommandTest.php deleted file mode 100644 index 77c64d8..0000000 --- a/tests/Commands/Generators/ObserverMakeCommandTest.php +++ /dev/null @@ -1,23 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates observer for the module', function () { - $this->artisan('module:make:observer', [ - 'name' => 'SomeAuthorObserver', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Observers/SomeAuthorObserver.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/PolicyMakeCommandTest.php b/tests/Commands/Generators/PolicyMakeCommandTest.php deleted file mode 100644 index bf926ae..0000000 --- a/tests/Commands/Generators/PolicyMakeCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates plain policy for the module', function () { - $this->artisan('module:make:policy', [ - 'name' => 'AuthorPolicy', - 'module' => 'Author', - ]) - ->expectsQuestion('Enter the class name of the model to be used in the policy (optional)', '') - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Policies/AuthorPolicy.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates full policy for the module', function () { - $this->artisan('module:make:policy', [ - 'name' => 'AuthorPolicy', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Policies/AuthorPolicy.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ProviderMakeCommandTest.php b/tests/Commands/Generators/ProviderMakeCommandTest.php deleted file mode 100644 index 99a8c74..0000000 --- a/tests/Commands/Generators/ProviderMakeCommandTest.php +++ /dev/null @@ -1,77 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'TestServiceProvider', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/TestServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "module" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomModuleServiceProvider', - 'module' => 'Author', - '--stub' => 'module', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/CustomModuleServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "event" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'EventServiceProvider', - 'module' => 'Author', - '--stub' => 'event', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/EventServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "route" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomRouteServiceProvider', - 'module' => 'Author', - '--stub' => 'route', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/CustomRouteServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('registers provider in module composer.json', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomServiceProvider', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $composerJsonPath = $this->app->basePath('/modules/author/composer.json'); - $composerJson = json_decode(file_get_contents($composerJsonPath), true); - - expect($composerJson['extra']['laravel']['providers']) - ->toContain('Modules\\Author\\Providers\\CustomServiceProvider'); -}); diff --git a/tests/Commands/Generators/QueryWizardMakeCommandTest.php b/tests/Commands/Generators/QueryWizardMakeCommandTest.php deleted file mode 100644 index f2852f0..0000000 --- a/tests/Commands/Generators/QueryWizardMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "eloquent" query-wizard for the module', function () { - $this->artisan('module:make:query-wizard', [ - 'name' => 'AuthorsQueryWizard', - 'module' => 'Author', - '--stub' => 'eloquent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/QueryWizards/AuthorsQueryWizard.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "model" query-wizard for the module', function () { - $this->artisan('module:make:query-wizard', [ - 'name' => 'AuthorQueryWizard', - 'module' => 'Author', - '--stub' => 'model', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/QueryWizards/AuthorQueryWizard.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RequestMakeCommandTest.php b/tests/Commands/Generators/RequestMakeCommandTest.php deleted file mode 100644 index aed4fdf..0000000 --- a/tests/Commands/Generators/RequestMakeCommandTest.php +++ /dev/null @@ -1,162 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'PlainAuthorRequest', - 'module' => 'Author', - '--stub' => 'plain', - '--ui' => 'web', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/PlainAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'CreateAuthorRequest', - 'module' => 'Author', - '--stub' => 'create', - '--ui' => 'web', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/CreateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'UpdateAuthorRequest', - 'module' => 'Author', - '--stub' => 'update', - '--ui' => 'web', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/UpdateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'DeleteAuthorRequest', - 'module' => 'Author', - '--stub' => 'delete', - '--ui' => 'web', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/DeleteAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "plain" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'PlainAuthorRequest', - 'module' => 'Author', - '--stub' => 'plain', - '--ui' => 'api', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/PlainAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'CreateAuthorRequest', - 'module' => 'Author', - '--stub' => 'create', - '--ui' => 'api', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/CreateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'UpdateAuthorRequest', - 'module' => 'Author', - '--stub' => 'update', - '--ui' => 'api', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/UpdateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'DeleteAuthorRequest', - 'module' => 'Author', - '--stub' => 'delete', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/DeleteAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "view" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'ViewAuthorRequest', - 'module' => 'Author', - '--stub' => 'view', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/ViewAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "list" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'ListAuthorsRequest', - 'module' => 'Author', - '--stub' => 'list', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/ListAuthorsRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ResourceMakeCommandTest.php b/tests/Commands/Generators/ResourceMakeCommandTest.php deleted file mode 100644 index 8894423..0000000 --- a/tests/Commands/Generators/ResourceMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "single" resource for the module', function () { - $this->artisan('module:make:resource', [ - 'name' => 'AuthorResource', - 'module' => 'Author', - '--stub' => 'single', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Resources/AuthorResource.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "collection" resource for the module', function () { - $this->artisan('module:make:resource', [ - 'name' => 'AuthorResourceCollection', - 'module' => 'Author', - '--stub' => 'collection', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Resources/AuthorResourceCollection.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RouteMakeCommandTest.php b/tests/Commands/Generators/RouteMakeCommandTest.php deleted file mode 100644 index 13d8c46..0000000 --- a/tests/Commands/Generators/RouteMakeCommandTest.php +++ /dev/null @@ -1,214 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "get" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'list_authors', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'ListAuthorsAction', - '--method' => 'get', - '--url' => 'authors', - '--name' => 'web.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/list_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "post" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'create_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'CreateAuthorAction', - '--method' => 'post', - '--url' => 'authors', - '--name' => 'web.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/create_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "put" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'UpdateAuthorAction', - '--method' => 'put', - '--url' => 'authors/{author}', - '--name' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "patch" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'UpdateAuthorAction', - '--method' => 'patch', - '--url' => 'authors/{author}', - '--name' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'delete_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'DeleteAuthorAction', - '--method' => 'delete', - '--url' => 'authors/{author}', - '--name' => 'web.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/delete_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "options" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'options_authors', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'ListAuthorsAction', - '--method' => 'options', - '--url' => 'authors', - '--name' => 'web.authors.options', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/options_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "get" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'list_authors', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'ListAuthorsAction', - '--method' => 'get', - '--url' => 'authors', - '--name' => 'web.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/list_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "post" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'create_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'CreateAuthorAction', - '--method' => 'post', - '--url' => 'authors', - '--name' => 'api.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/create_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "put" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'UpdateAuthorAction', - '--method' => 'put', - '--url' => 'authors/{author}', - '--name' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "patch" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'UpdateAuthorAction', - '--method' => 'patch', - '--url' => 'authors/{author}', - '--name' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'delete_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'DeleteAuthorAction', - '--method' => 'delete', - '--url' => 'authors/{author}', - '--name' => 'api.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/delete_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "options" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'options_authors', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'ListAuthorsAction', - '--method' => 'options', - '--url' => 'authors', - '--name' => 'api.authors.options', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/options_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RuleMakeCommandTest.php b/tests/Commands/Generators/RuleMakeCommandTest.php deleted file mode 100644 index 7ff2d59..0000000 --- a/tests/Commands/Generators/RuleMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates rule for the module', function () { - $this->artisan('module:make:rule', [ - 'name' => 'SomeAuthorRule', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Rules/SomeAuthorRule.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/SeederMakeCommandTest.php b/tests/Commands/Generators/SeederMakeCommandTest.php deleted file mode 100644 index 4873e25..0000000 --- a/tests/Commands/Generators/SeederMakeCommandTest.php +++ /dev/null @@ -1,45 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" seeder for the module', function () { - $this->artisan('module:make:seeder', [ - 'name' => 'AuthorsSeeder', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/seeders/AuthorsSeeder.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "permissions" seeder for the module', function () { - $this->artisan('module:make:seeder', [ - 'name' => 'AuthorPermissionsSeeder', - 'module' => 'Author', - '--stub' => 'permissions', - '--model' => 'Author', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/seeders/AuthorPermissionsSeeder.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/TestMakeCommandTest.php b/tests/Commands/Generators/TestMakeCommandTest.php deleted file mode 100644 index e56875e..0000000 --- a/tests/Commands/Generators/TestMakeCommandTest.php +++ /dev/null @@ -1,209 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "unit" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'unit', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/Unit/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "feature" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'feature', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/Feature/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "cli" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'cli', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/CLI/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'plain', - '--model' => 'Author', - '--route' => 'web.authors.example', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates create "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'CreateAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'create', - '--model' => 'Author', - '--route' => 'web.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/CreateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates update "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'UpdateAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'update', - '--model' => 'Author', - '--route' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/UpdateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates delete "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'DeleteAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'delete', - '--model' => 'Author', - '--route' => 'web.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/DeleteAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'plain', - '--model' => 'Author', - '--route' => 'api.authors.example', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates create "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'CreateAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'create', - '--model' => 'Author', - '--route' => 'api.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/CreateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates update "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'UpdateAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'update', - '--model' => 'Author', - '--route' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/UpdateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates delete "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'DeleteAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'delete', - '--model' => 'Author', - '--route' => 'api.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/DeleteAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates list "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ListAuthorsTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'list', - '--model' => 'Author', - '--route' => 'api.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ListAuthorsTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates view "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ViewAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'view', - '--model' => 'Author', - '--route' => 'api.authors.view', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ViewAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/ListCommandTest.php b/tests/Commands/ListCommandTest.php deleted file mode 100644 index 4a026e8..0000000 --- a/tests/Commands/ListCommandTest.php +++ /dev/null @@ -1,26 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -it('outputs a table of modules', function () { - $this->artisan('module:list') - ->expectsTable(['Package Name', 'Namespace', 'Path'], [ - ['laraneat/article-category', 'Modules\\ArticleCategory', $this->app->basePath('/modules/article-category')], - ['laraneat/article', 'Modules\\Article', $this->app->basePath('/modules/article')], - ['laraneat/author', 'Modules\\Author', $this->app->basePath('/modules/author')], - ['laraneat/empty', 'Modules\\Empty', $this->app->basePath('/modules/empty-module')], - ['empty/empty', 'Empty\\Empty', $this->app->basePath('/modules/empty')], - ['laraneat/location', 'Modules\\GeoLocation', $this->app->basePath('/modules/navigation')], - - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateCommandTest.php b/tests/Commands/MigrateCommandTest.php deleted file mode 100644 index a477d21..0000000 --- a/tests/Commands/MigrateCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('runs migrations for the specified module', function () { - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateRefreshCommandTest.php b/tests/Commands/MigrateRefreshCommandTest.php deleted file mode 100644 index cfb0555..0000000 --- a/tests/Commands/MigrateRefreshCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('refreshes migrations for the specified module', function () { - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateResetCommandTest.php b/tests/Commands/MigrateResetCommandTest.php deleted file mode 100644 index 9e8812f..0000000 --- a/tests/Commands/MigrateResetCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('resets migrations for the specified module', function () { - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateRollbackCommandTest.php b/tests/Commands/MigrateRollbackCommandTest.php deleted file mode 100644 index 9c07dba..0000000 --- a/tests/Commands/MigrateRollbackCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('rolls back migrations for the specified module', function () { - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateStatusCommandTest.php b/tests/Commands/MigrateStatusCommandTest.php deleted file mode 100644 index 457911e..0000000 --- a/tests/Commands/MigrateStatusCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('shows migration status for the specified module', function () { - $this->artisan('module:migrate:status', [ - 'module' => 'Author', - ]) - ->assertSuccessful(); -}); - -it('does not require confirmation in production', function () { - $this->app['env'] = 'production'; - - // migrate:status should not require confirmation as it's read-only - $this->artisan('module:migrate:status', [ - 'module' => 'Author', - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/ModuleDeleteCommandTest.php b/tests/Commands/ModuleDeleteCommandTest.php deleted file mode 100644 index 5341916..0000000 --- a/tests/Commands/ModuleDeleteCommandTest.php +++ /dev/null @@ -1,106 +0,0 @@ -instance(Composer::class, $this->mockComposer(['removePackages' => true])); - - // Rebind ModulesRepository with the mocked Composer - $this->app->singleton(ModulesRepository::class, function ($app) { - return new ModulesRepository( - filesystem: $app['files'], - composer: $app[Composer::class], - modulesPath: $app['config']->get('modules.path'), - basePath: $app->basePath(), - modulesManifestPath: $app['config']->get('modules.cache.enabled') - ? $app->bootstrapPath('cache/laraneat-modules.php') - : null - ); - }); - - $this->setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); - - /** @var ModulesRepository $modulesRepository */ - $modulesRepository = $this->app[ModulesRepository::class]; - $this->modulesRepository = $modulesRepository; -}); - -it('deletes a module', function () { - expect($this->modulesRepository->has('laraneat/article'))->toBe(true); - - $this->artisan('module:delete article') - ->assertSuccessful(); - - expect($this->modulesRepository->has('laraneat/article'))->toBe(false); -}); - -it('removes the composer package before deleting the module files', function () { - $modulePath = $this->modulesRepository->findOrFail('laraneat/article')->getPath(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldReceive('removePackages') - ->once() - ->withArgs(function (array $packages) use ($modulePath) { - // the files must still exist while composer runs - expect(is_dir($modulePath))->toBeTrue(); - - return $packages === ['laraneat/article']; - }) - ->andReturn(true); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - $this->artisan('module:delete article') - ->assertSuccessful(); - - expect(is_dir($modulePath))->toBeFalse(); -}); - -it('keeps the module files and fails when composer cannot remove the package', function () { - $modulePath = $this->modulesRepository->findOrFail('laraneat/article')->getPath(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldReceive('removePackages')->once()->andReturn(false); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - $this->artisan('module:delete article') - ->expectsOutputToContain('Failed to remove package with composer.') - ->assertFailed(); - - expect(is_dir($modulePath))->toBeTrue(); -}); - -it('refuses to delete a symlinked module and leaves its target intact', function () { - $target = sys_get_temp_dir() . '/laraneat-symlink-target-' . uniqid(); - $this->filesystem->copyDirectory(__DIR__ . '/../fixtures/stubs/modules/valid/navigation', $target); - $this->filesystem->deleteDirectory($this->app->basePath('/modules/navigation')); - symlink($target, $this->app->basePath('/modules/navigation')); - $this->app[ModulesRepository::class]->pruneModulesManifest(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldNotReceive('removePackages'); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - try { - $this->artisan('module:delete navigation') - ->expectsOutputToContain('is a symbolic link') - ->assertFailed(); - - expect(is_link($this->app->basePath('/modules/navigation')))->toBeTrue() - ->and(is_file($target . '/composer.json'))->toBeTrue(); - } finally { - @unlink($this->app->basePath('/modules/navigation')); - $this->filesystem->deleteDirectory($target); - } -}); diff --git a/tests/Commands/StubPublishCommandTest.php b/tests/Commands/StubPublishCommandTest.php deleted file mode 100644 index 482dd3f..0000000 --- a/tests/Commands/StubPublishCommandTest.php +++ /dev/null @@ -1,18 +0,0 @@ -customStubsPath = $this->app['config']->get('modules.custom_stubs'); -}); - -afterEach(function () { - $this->filesystem->deleteDirectory($this->customStubsPath); -}); - -it('publish all laraneat/modules stubs', function () { - $this->artisan('module:stub:publish') - ->assertSuccessful(); - - assertMatchesSnapshot(getRelativeFilePathsInDirectory($this->customStubsPath)); -}); diff --git a/tests/Commands/SyncCommandTest.php b/tests/Commands/SyncCommandTest.php deleted file mode 100644 index bc9c5de..0000000 --- a/tests/Commands/SyncCommandTest.php +++ /dev/null @@ -1,62 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once(); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('Modules completed successfully!') - ->assertSuccessful(); -}); - -it('handles ModuleHasNoNamespace exception', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ModuleHasNoNamespace::make('test/module') - ); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('No namespace specified for module') - ->assertFailed(); -}); - -it('handles ModuleHasNonUniquePackageName exception', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ModuleHasNonUniquePackageName::make('test/module', ['/path1', '/path2']) - ); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('test/module') - ->assertFailed(); -}); - -it('handles ComposerException and shows manual update hint', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ComposerException::make('Failed to update package with composer.') - ); - $mock->shouldReceive('getModules')->once()->andReturn([ - 'test/module-a' => (object) [], - 'test/module-b' => (object) [], - ]); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('Failed to update package with composer') - ->expectsOutputToContain('composer update test/module-a test/module-b') - ->assertFailed(); -}); diff --git a/tests/Feature/CacheTest.php b/tests/Feature/CacheTest.php new file mode 100644 index 0000000..d437c81 --- /dev/null +++ b/tests/Feature/CacheTest.php @@ -0,0 +1,77 @@ +artisan('module:cache')->expectsOutputToContain('Modules cached successfully.')->assertSuccessful(); + + $cache = require $this->path('bootstrap/cache/modules.php'); + + expect(array_keys($cache))->toBe(['blog', 'shop-order']) + ->and($cache['blog']['path'])->toBe('modules/blog') + ->and(file_get_contents($this->path('bootstrap/cache/modules.php')))->not->toContain($this->basePath); +}); + +it('clears the cache', function () { + $this->artisan('module:cache'); + + $this->artisan('module:clear')->expectsOutputToContain('Module cache cleared successfully.')->assertSuccessful(); + $this->artisan('module:clear')->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeFalse(); +}); + +it('boots from the cache without scanning the modules', function () { + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + $this->reboot(); + + expect(array_keys(Modules::all()))->toBe(['blog', 'shop-order']) + ->and(config('blog.title'))->toBe('Blog') + ->and(Route::has('blog.posts'))->toBeTrue(); +}); + +it('skips the files of a module deleted since the cache was written', function () { + $this->artisan('module:cache'); + TemporaryDirectory::delete($this->path('modules/blog')); + $this->reboot(); + + expect(config('blog'))->toBeNull() + ->and(Route::has('blog.posts'))->toBeFalse() + ->and(Route::has('shop-order.orders'))->toBeTrue(); + + $this->artisan('module:clear')->assertSuccessful(); +}); + +it('works after the application moved', function () { + $this->artisan('module:cache'); + $moved = $this->temporaryDirectory($this->basePath); + TemporaryDirectory::delete($this->basePath); + $this->basePath = $moved; + $this->reboot(); + + expect(app(ModuleRepository::class)->isCached())->toBeTrue() + ->and(Modules::get('blog')->path)->toBe($moved.'/modules/blog') + ->and(config('blog.title'))->toBe('Blog'); + + $this->get('/blog')->assertOk()->assertSee('Welcome to the blog'); +}); + +it('keeps absolute paths outside of the application', function () { + $modules = $this->temporaryDirectory($this->path('modules')); + $this->files(['config/modules.php' => ' "'.$modules.'"];']); + $this->reboot(); + + $this->artisan('module:cache'); + + expect((require $this->path('bootstrap/cache/modules.php'))['blog']['path'])->toBe($modules.'/blog'); + + $this->reboot(); + + expect(Modules::get('blog')->path)->toBe($modules.'/blog'); +}); diff --git a/tests/Feature/Commands/ComposerRunnerTest.php b/tests/Feature/Commands/ComposerRunnerTest.php new file mode 100644 index 0000000..7a30715 --- /dev/null +++ b/tests/Feature/Commands/ComposerRunnerTest.php @@ -0,0 +1,42 @@ +files(['composer.phar' => '']); + Process::fake(['*' => Process::result()]); + + expect(app(ComposerRunner::class)->remove(['app/blog', 'app/shop-order']))->toBeTrue(); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === [php_binary(), 'composer.phar', 'remove', '--', 'app/blog', 'app/shop-order']); +}); + +it('tells whether Composer succeeded', function (int $exitCode, bool $successful) { + $this->expectComposer(['update', '--', 'app/blog'], exitCode: $exitCode); + + expect(app(ComposerRunner::class)->update(['app/blog']))->toBe($successful); +})->with([ + 'success' => [0, true], + 'failure' => [1, false], +]); + +it('refuses package names that are not package names', function (string $package) { + $runner = app(ComposerRunner::class); + + expect(fn () => $runner->update([$package]))->toThrow(InvalidName::class) + ->and(fn () => $runner->commandLine('update', [$package]))->toThrow(InvalidName::class); + + Process::assertNothingRan(); +})->with(['--no-scripts', 'app/blog --dev', 'app/blog;rm', 'App/Blog', 'blog']); + +it('prints the command to run by hand', function () { + expect(app(ComposerRunner::class)->commandLine('update', ['app/blog', 'app/shop-order'])) + ->toBe('composer update app/blog app/shop-order'); +}); diff --git a/tests/Feature/Commands/DeleteCommandTest.php b/tests/Feature/Commands/DeleteCommandTest.php new file mode 100644 index 0000000..eda677a --- /dev/null +++ b/tests/Feature/Commands/DeleteCommandTest.php @@ -0,0 +1,147 @@ +installModules(); + $this->composerJson = file_get_contents($this->path('composer.json')); +}); + +it('uninstalls the module with Composer, then deletes it', function () { + $this->expectComposer(['remove', '--', 'app/blog'], during: function () { + // Composer removes the "require" entry itself, while the module still exists. + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toHaveKey('app/blog') + ->and($this->readJson('composer.json'))->not->toHaveKey('autoload-dev.psr-4.Modules\\Blog\\Tests\\'); + }); + + $this->artisan('module:delete blog') + ->expectsConfirmation('Delete the [blog] module and all of its files?', 'yes') + ->expectsOutputToContain('Module [blog] deleted.') + ->assertSuccessful(); + + expect(file_exists($this->path('modules/blog')))->toBeFalse() + ->and(array_keys(Modules::all()))->toBe(['shop-order']) + ->and($this->readJson('composer.json')['autoload-dev']['psr-4'])->toBe([]); +}); + +it('deletes without confirmation with --force', function () { + $this->expectComposer(['remove', '--', 'app/shop-order']); + + $this->artisan('module:delete shop-order --force')->assertSuccessful(); + + expect(file_exists($this->path('modules/shop-order')))->toBeFalse(); +}); + +it('keeps the module when the deletion is not confirmed', function () { + $this->artisan('module:delete blog') + ->expectsConfirmation('Delete the [blog] module and all of its files?', 'no') + ->expectsOutputToContain('The module was not deleted.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +}); + +it('requires --force in non-interactive mode', function () { + $this->artisan('module:delete blog --no-interaction') + ->expectsOutputToContain('Use --force to delete a module in non-interactive mode.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue(); +}); + +it('restores composer.json and keeps the module when Composer fails', function () { + $this->expectComposer(['remove', '--', 'app/blog'], exitCode: 1); + + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('Composer failed to remove the module, nothing was deleted.') + ->assertFailed(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(Modules::all()))->toBe(['blog', 'shop-order']); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:delete blog --force --no-update') + ->expectsOutputToContain('Uninstall it with: composer update app/blog') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/blog')))->toBeFalse() + ->and(is_link($this->path('vendor/app/blog')))->toBeFalse() + ->and(is_link($this->path('vendor/app/shop-order')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toBe([ + 'php' => '^8.3', + 'app/shop-order' => '*@dev', + 'laravel/framework' => '^13.0', + ]); +}); + +it('updates the module cache when it exists', function () { + $this->artisan('module:cache'); + $this->expectComposer(['remove', '--', 'app/blog']); + + $this->artisan('module:delete blog --force')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['shop-order']); +}); + +it('refuses to delete a missing module', function (string $name) { + $this->artisan('module:delete', ['name' => $name, '--force' => true]) + ->expectsOutputToContain("Module [{$name}] not found.") + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +})->with(['notes', 'wiki', '../modules/blog']); + +it('refuses to delete a module linked from elsewhere', function () { + $target = $this->temporaryDirectory(); + file_put_contents($target.'/composer.json', '{"name": "app/linked", "autoload": {"psr-4": {"Modules\\\\Linked\\\\": "src/"}}}'); + symlink($target, $this->path('modules/linked')); + $this->reboot(); + + $this->artisan('module:delete linked --force') + ->expectsOutputToContain('[modules/linked] is a symbolic link. Remove the module by hand.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_exists($target.'/composer.json'))->toBeTrue(); +})->skipOnWindows(); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '[']); + + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('composer.json] is not valid JSON') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue(); +}); + +it('reports a module directory that can not be deleted', function () { + $this->expectComposer(['remove', '--', 'app/blog']); + chmod($this->path('modules/blog/config'), 0555); + + try { + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('[modules/blog] could not be deleted completely. Delete it by hand.') + ->assertFailed(); + } finally { + chmod($this->path('modules/blog/config'), 0755); + } +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php new file mode 100644 index 0000000..4b21ebd --- /dev/null +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -0,0 +1,207 @@ +installModules(); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain('Configuration', 'blog', 'shop-order', 'No problems found.') + ->not->toContain('FAIL', 'WARN'); +}); + +it('reports modules that are not installed', function () { + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain( + '✗ The application does not require [app/blog]: run "php artisan module:sync".', + '✗ [app/blog] can be installed from Packagist: run "php artisan module:sync" to exclude the vendor.', + '✗ [app/blog] is not installed: run "php artisan module:sync".', + '! The application does not autoload [Modules\\Blog\\Tests\\] for development: run "php artisan module:sync".', + '✗ [app/shop-order] is not installed', + 'Found 6 error(s) and 1 warning(s).', + ); +}); + +it('reports a package linked elsewhere', function () { + $this->installModules(); + removeLink($this->path('vendor/app/blog')); + symlink($this->path('modules/shop-order'), $this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +}); + +it('warns about a mirrored package', function () { + $this->installModules(); + removeLink($this->path('vendor/app/blog')); + mkdir($this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain('! [vendor/app/blog] is a copy, not a link to [modules/blog]: changes of the module need "composer update".'); +}); + +it('reports a missing installed package', function () { + $this->installModules(); + removeLink($this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +}); + +it('reports a package installed from another repository', function (array $dist) { + $this->installModules(); + removeLink($this->path('vendor/app/blog')); + mkdir($this->path('vendor/app/blog')); + $installed = $this->readJson('vendor/composer/installed.json'); + $installed['packages'][0]['dist'] = $dist; + $this->files(['vendor/composer/installed.json' => json_encode($installed)]); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +})->with([ + 'Packagist' => [['type' => 'zip', 'url' => 'https://api.github.com/repos/app/blog/zipball/1', 'reference' => '1']], + 'another path' => [['type' => 'path', 'url' => 'modules/shop-order', 'reference' => null]], +]); + +it('reports outdated installed metadata', function () { + $this->installModules(); + $this->files(['modules/blog/composer.json' => json_encode([...$this->readJson('modules/blog/composer.json'), 'require' => ['php' => '^8.4']])]); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toMatch('/^ blog \\.+ WARN\n ! composer\\.json changed since the module was installed: run "php artisan module:sync"\\.$/m') + ->toMatch('/^ shop-order \\.+ OK$/m') + ->toContain('Found 1 warning(s).'); +}); + +it('reports problems of the module structure', function () { + $this->installModules(); + $composerJson = $this->readJson('modules/blog/composer.json'); + $composerJson['autoload']['psr-4'] = ['Modules\\Blog\\' => 'app/', 'Modules\\Blog\\Database\\Factories\\' => 'database/Factories/']; + $composerJson['extra'] = ['laravel' => ['providers' => ['Modules\\Blog\\Providers\\MissingServiceProvider', 42]]]; + $this->files([ + 'modules/blog/composer.json' => json_encode($composerJson), + 'modules/blog/routes/zeta/users.php' => ' ' ' ' ' 'reboot(); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain( + '✗ The directory of the [Modules\\Blog] namespace does not exist.', + '✗ "autoload.psr-4" must map [Modules\\Blog\\Database\\Factories\\] to [database/factories/].', + '✗ "autoload.psr-4" must map [Modules\\Blog\\Database\\Seeders\\] to [database/seeders/].', + '✗ The provider [Modules\\Blog\\Providers\\MissingServiceProvider] does not exist.', + '✗ The provider [int] does not exist.', + " ! The route file [routes/admin/users.php] is not in a route group of config/modules.php.\n ! The route file [routes/zeta/users.php] is not in a route group of config/modules.php.", + ) + ->toMatch('/^ blog \\.+ FAIL$/m') + ->not->toContain('secret.php', 'fixture.php', 'lib/routes', 'routes/api/posts.php'); +}); + +it('skips directories it can not read', function () { + $this->installModules(); + $this->files(['modules/blog/private/routes/web.php' => 'path('modules/blog/private'), 0); + + try { + [$status, $output] = doctor(); + } finally { + chmod($this->path('modules/blog/private'), 0755); + } + + expect($status)->toBe(0)->and($output)->toContain('No problems found.'); +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); + +it('accepts equivalent autoload paths and skips directories a module does not have', function () { + // shop-order has no database/factories directory. + $composerJson = $this->readJson('modules/shop-order/composer.json'); + $composerJson['autoload']['psr-4'] = ['Modules\\ShopOrder\\' => './src', 'Modules\\ShopOrder\\Database\\Seeders\\' => ['lib/', './database/seeders']]; + $composerJson['extra'] = ['laravel' => ['providers' => ['Modules\\ShopOrder\\Providers\\MissingServiceProvider']]]; + $this->files(['modules/shop-order/composer.json' => json_encode($composerJson)]); + $this->installModules(); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toMatch('/^ shop-order \\.+ FAIL\n ✗ The provider \\[Modules\\\\ShopOrder\\\\Providers\\\\MissingServiceProvider\\] does not exist\\.\n\n/m') + ->toContain('Found 1 error(s) and 0 warning(s).'); +}); + +it('reports problems of the configuration', function () { + $this->installModules(); + $this->files([ + 'config/modules.php' => ' []];', + 'config/octane.php' => ' ["app"]];', + ]); + $this->reboot(); + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain( + '! Unknown keys in config/modules.php: paths.', + '! The module cache is outdated: run "php artisan module:cache".', + ) + ->not->toContain('octane:start'); +}); + +it('warns when Octane can not watch the modules', function () { + $modules = $this->temporaryDirectory($this->path('modules')); + $this->files([ + 'config/modules.php' => ' "'.$modules.'"];', + 'config/octane.php' => ' ["app"]];', + ]); + $this->reboot(); + + [, $output] = doctor(); + + expect($output)->toContain('! The modules path is outside of the application, so "octane:start --watch" does not watch it.'); +}); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '[']); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('composer.json] is not valid JSON'); +}); diff --git a/tests/Feature/Commands/ListCommandTest.php b/tests/Feature/Commands/ListCommandTest.php new file mode 100644 index 0000000..b58f3ce --- /dev/null +++ b/tests/Feature/Commands/ListCommandTest.php @@ -0,0 +1,47 @@ +toBe(<<<'TXT' + +------------+----------------+-------------------+--------------------+ + | Name | Package | Namespace | Path | + +------------+----------------+-------------------+--------------------+ + | blog | app/blog | Modules\Blog | modules/blog | + | shop-order | app/shop-order | Modules\ShopOrder | modules/shop-order | + +------------+----------------+-------------------+--------------------+ + + TXT); +}); + +it('lists what is loaded from the modules with -v', function () { + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + $this->reboot(); + + expect(listModules(['-v' => true]))->toBe(<<<'TXT' + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + | Name | Package | Namespace | Path | Loads | + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + | blog | app/blog | Modules\Blog | modules/blog | config (blog), lang (+json), views, migrations, api routes (3), web routes (1), seeders (4), commands (2) | + | shop-order | app/shop-order | Modules\ShopOrder | modules/shop-order | config (shop-order), lang, web routes (1), seeders (2) | + | wiki | app/wiki | Modules\Wiki | modules/wiki | - | + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + + TXT); +}); + +it('warns when there are no modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + $this->reboot(); + + expect(listModules())->toBe("\n WARN No modules found in [empty].\n\n"); +}); diff --git a/tests/Feature/Commands/MakeCommandTest.php b/tests/Feature/Commands/MakeCommandTest.php new file mode 100644 index 0000000..47198c6 --- /dev/null +++ b/tests/Feature/Commands/MakeCommandTest.php @@ -0,0 +1,211 @@ +composerJson = file_get_contents($this->path('composer.json')); +}); + +it('creates a module and installs it with Composer', function () { + $this->expectComposer(['update', '--', 'app/wiki-pages'], during: function () { + // Composer reads the module and the updated composer.json. + expect($this->readJson('composer.json')['require'])->toHaveKey('app/wiki-pages') + ->and(file_exists($this->path('modules/wiki-pages/composer.json')))->toBeTrue(); + }); + + $this->artisan('module:make', ['name' => 'WikiPages']) + ->expectsOutputToContain('Module [wiki-pages] created in [modules/wiki-pages].') + ->assertSuccessful(); + + Process::assertRanTimes(fn () => true, 1); + + expect($this->readJson('modules/wiki-pages/composer.json'))->toBe([ + 'name' => 'app/wiki-pages', + 'description' => 'The wiki-pages module.', + 'type' => 'library', + 'autoload' => ['psr-4' => [ + 'Modules\\WikiPages\\' => 'src/', + 'Modules\\WikiPages\\Database\\Factories\\' => 'database/factories/', + 'Modules\\WikiPages\\Database\\Seeders\\' => 'database/seeders/', + ]], + 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'tests/']], + ]) + ->and(collect(File::allFiles($this->path('modules/wiki-pages'), true)) + ->map(fn (SplFileInfo $file): string => str_replace('\\', '/', $file->getRelativePathname()))->sort()->values()->all()) + ->toBe([ + 'composer.json', + 'database/factories/.gitkeep', + 'database/migrations/.gitkeep', + 'database/seeders/.gitkeep', + 'src/Models/.gitkeep', + 'tests/.gitkeep', + ]) + ->and($this->readJson('composer.json'))->toBe([ + 'name' => 'laraneat/fixture-app', + 'type' => 'project', + 'require' => [ + 'php' => '^8.3', + 'app/wiki-pages' => '*@dev', + 'laravel/framework' => '^13.0', + ], + 'autoload' => ['psr-4' => ['App\\' => 'app/']], + 'config' => ['sort-packages' => true], + 'minimum-stability' => 'stable', + 'repositories' => [ + ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], + ], + 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'modules/wiki-pages/tests/']], + ]) + ->and(Modules::get('wiki-pages')->namespace)->toBe('Modules\\WikiPages'); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:make wiki --no-update') + ->expectsOutputToContain('Install it with: composer update app/wiki') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect($this->readJson('composer.json')['require'])->toHaveKey('app/wiki'); +}); + +it('reports a Composer failure and keeps the module', function () { + $this->expectComposer(['update', '--', 'app/wiki'], exitCode: 2); + + $this->artisan('module:make wiki') + ->expectsOutputToContain('Composer failed to install the module. Fix the problem and run: composer update app/wiki') + ->assertFailed(); + + expect(is_dir($this->path('modules/wiki')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toHaveKey('app/wiki'); +}); + +it('uses the vendor and the namespace of the config', function () { + $this->files(['config/modules.php' => ' "acme", "namespace" => "Acme\\\\Domain"];']); + $this->reboot(); + + $this->artisan('module:make billing --no-update')->assertSuccessful(); + + expect($this->readJson('modules/billing/composer.json')) + ->name->toBe('acme/billing') + ->autoload->toBe(['psr-4' => [ + 'Acme\\Domain\\Billing\\' => 'src/', + 'Acme\\Domain\\Billing\\Database\\Factories\\' => 'database/factories/', + 'Acme\\Domain\\Billing\\Database\\Seeders\\' => 'database/seeders/', + ]]) + ->and($this->readJson('composer.json')['repositories'][1]['exclude'])->toBe(['acme/*']); +}); + +it('updates the module cache when it exists', function () { + $this->artisan('module:cache'); + + $this->artisan('module:make wiki --no-update')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('renders a preset of the application', function () { + Date::setTestNow('2026-09-23 10:20:30'); + $this->files([ + 'stubs/module/api/composer.json.stub' => <<<'JSON' + { + "name": "{{ package }}", + "autoload": {"psr-4": {"{{ namespace|json }}\\": "src/"}}, + "extra": {"module": "{{ name|studly }}", "vendor": "{{ vendor }}"} + } + JSON, + 'stubs/module/api/src/Models/{{ name|studly|singular }}.php.stub' => " ' '# {{ name }} stays as is', + ]); + + $this->artisan('module:make shop-items --preset=api --no-update')->assertSuccessful(); + + expect(file_get_contents($this->path('modules/shop-items/src/Models/ShopItem.php'))) + ->toBe("and(file_get_contents($this->path('modules/shop-items/database/migrations/2026_09_23_102030_create_shop_items_table.php'))) + ->toBe('and(file_get_contents($this->path('modules/shop-items/README.md')))->toBe('# {{ name }} stays as is') + ->and($this->readJson('modules/shop-items/composer.json')['extra'])->toBe(['module' => 'ShopItems', 'vendor' => 'app']); +})->skip(DIRECTORY_SEPARATOR === '\\', 'Windows does not allow "|" in file names.'); + +it('prefers the published default template', function () { + $this->files([ + 'stubs/module/default/composer.json.stub' => '{"name": "{{ package }}", "autoload": {"psr-4": {"{{ namespace|json }}\\\\": "lib/"}}}', + ]); + + $this->artisan('module:make wiki --no-update')->assertSuccessful(); + + expect(Modules::get('wiki')->sourcePath)->toBe($this->path('modules/wiki/lib')); +}); + +it('refuses invalid input without touching anything', function (string $command, string $error) { + $this->files(['stubs/module/broken/src/.gitkeep' => '']); + + $this->artisan($command)->expectsOutputToContain($error)->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(app(ModuleRepository::class)->manifest()))->toBe(['blog', 'shop-order']); +})->with([ + 'invalid name' => ['module:make 1blog', 'Invalid module name [1blog]'], + 'existing module' => ['module:make Blog', 'The directory [modules/blog] already exists.'], + 'existing directory' => ['module:make notes', 'The directory [modules/notes] already exists.'], + 'unsafe preset' => ['module:make wiki --preset=../api', 'the preset name may contain'], + 'missing preset' => ['module:make wiki --preset=api', 'the preset does not exist.'], + 'preset without composer.json' => ['module:make wiki --preset=broken', 'the template must contain a "composer.json"'], +]); + +it('removes the module when its composer.json is invalid', function (string $composerJson, string $error) { + $this->files(['stubs/module/default/composer.json.stub' => $composerJson]); + + $this->artisan('module:make wiki')->expectsOutputToContain($error)->assertFailed(); + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(app(ModuleRepository::class)->manifest()))->toBe(['blog', 'shop-order']); +})->with([ + 'another package name' => ['{"name": "app/other", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}', 'the package name must be [app/wiki], [app/other] given.'], + 'no namespace' => ['{"name": "{{ package }}"}', 'the first "autoload.psr-4" entry of composer.json must map'], + 'invalid JSON' => ['{"name": ', 'composer.json is not valid JSON'], +]); + +it('refuses an invalid vendor', function () { + $this->files(['config/modules.php' => ' "Acme Inc"];']); + $this->reboot(); + + $this->artisan('module:make wiki')->expectsOutputToContain('Invalid vendor [Acme Inc]')->assertFailed(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse(); +}); + +it('removes the module when writing it fails', function () { + chmod($this->path('modules'), 0555); + + try { + expect(fn () => $this->artisan('module:make wiki')->run())->toThrow(ErrorException::class); + } finally { + chmod($this->path('modules'), 0755); + } + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); + +it('uses the default template for --preset without a value', function () { + $this->artisan('module:make wiki --preset --no-update')->assertSuccessful(); + + expect(file_exists($this->path('modules/wiki/src/Models/.gitkeep')))->toBeTrue(); +}); diff --git a/tests/Feature/Commands/SyncCommandTest.php b/tests/Feature/Commands/SyncCommandTest.php new file mode 100644 index 0000000..d32d86a --- /dev/null +++ b/tests/Feature/Commands/SyncCommandTest.php @@ -0,0 +1,140 @@ +expectComposer(['update', '--', 'app/blog', 'app/shop-order'], during: function () { + expect($this->readJson('composer.json')['require'])->toHaveKeys(['app/blog', 'app/shop-order']); + }); + + $this->artisan('module:sync') + ->expectsOutputToContain('Modules are in sync with Composer.') + ->assertSuccessful(); + + expect($this->readJson('composer.json')) + ->require->toBe([ + 'php' => '^8.3', + 'app/blog' => '*@dev', + 'app/shop-order' => '*@dev', + 'laravel/framework' => '^13.0', + ]) + ->repositories->toBe([ + ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], + ]) + ->{'autoload-dev'}->toBe(['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']]); +}); + +it('does nothing when the modules are in sync', function () { + $this->installModules(); + $composerJson = file_get_contents($this->path('composer.json')); + + $this->artisan('module:sync') + ->expectsOutputToContain('Modules are in sync with Composer.') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($composerJson); +}); + +it('installs the modules whose composer.json changed', function (array $change) { + $this->installModules(); + $composerJson = $this->readJson('modules/shop-order/composer.json'); + $this->files(['modules/shop-order/composer.json' => json_encode(array_replace_recursive($composerJson, $change))]); + + $this->expectComposer(['update', '--', 'app/shop-order']); + + $this->artisan('module:sync')->assertSuccessful(); +})->with([ + 'require' => [['require' => ['php' => '^8.4']]], + 'autoload' => [['autoload' => ['files' => ['helpers.php']]]], + 'extra' => [['extra' => ['laravel' => ['providers' => ['Modules\\ShopOrder\\Providers\\ShopOrderServiceProvider']]]]], +]); + +it('ignores the key order of the installed metadata', function () { + $this->installModules(); + $installed = $this->readJson('vendor/composer/installed.json'); + $installed['packages'][0]['autoload']['psr-4'] = array_reverse($installed['packages'][0]['autoload']['psr-4'], true); + $this->files(['vendor/composer/installed.json' => json_encode($installed)]); + + $this->artisan('module:sync')->assertSuccessful(); + + Process::assertNothingRan(); +}); + +it('installs a module missing from installed.json', function () { + $this->installModules(['blog']); + $this->expectComposer(['update', '--', 'app/shop-order']); + + $this->artisan('module:sync')->assertSuccessful(); +}); + +it('installs a module that is not required yet', function () { + $this->installModules(); + $composerJson = $this->readJson('composer.json'); + unset($composerJson['require']['app/blog']); + $this->files(['composer.json' => json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)]); + + $this->expectComposer(['update', '--', 'app/blog']); + + $this->artisan('module:sync')->assertSuccessful(); + + expect($this->readJson('composer.json')['require'])->toHaveKey('app/blog'); +}); + +it('picks up a module created by hand', function () { + $this->installModules(); + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + + $this->expectComposer(['update', '--', 'app/wiki']); + + $this->artisan('module:sync')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:sync --no-update') + ->expectsOutputToContain('Install the outdated modules with: composer update app/blog app/shop-order') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect($this->readJson('composer.json')['require'])->toHaveKeys(['app/blog', 'app/shop-order']); +}); + +it('fails when Composer fails', function () { + $this->expectComposer(['update', '--', 'app/blog', 'app/shop-order'], exitCode: 1); + + $this->artisan('module:sync') + ->expectsOutputToContain('Composer failed to install the modules. Fix the problem and run: composer update app/blog app/shop-order') + ->assertFailed(); +}); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '{"repositories": "packagist"}']); + + $this->artisan('module:sync') + ->expectsOutputToContain('"repositories" must be an array or an object.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe('{"repositories": "packagist"}'); +}); + +it('uses the vendor directory of the Composer config', function () { + $this->installModules(); + rename($this->path('vendor'), $this->path('libraries')); + $composerJson = $this->readJson('composer.json'); + $composerJson['config']['vendor-dir'] = 'libraries'; + $this->files(['composer.json' => json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"]); + + $this->artisan('module:sync')->assertSuccessful(); + + Process::assertNothingRan(); +}); diff --git a/tests/Feature/Conventions/ConfigTest.php b/tests/Feature/Conventions/ConfigTest.php new file mode 100644 index 0000000..6935384 --- /dev/null +++ b/tests/Feature/Conventions/ConfigTest.php @@ -0,0 +1,64 @@ +toBe(['title' => 'Blog', 'per_page' => 15]) + ->and(config('shop-order.currency'))->toBe('EUR'); +}); + +it('lets the application config override the module config', function () { + $this->files(['config/blog.php' => ' 50, "author" => "admin"];']); + $this->reboot(); + + expect(config('blog'))->toBe(['title' => 'Blog', 'per_page' => 50, 'author' => 'admin']); +}); + +it('merges the config before the providers boot', function () { + $this->app->register(new class($this->app) extends ServiceProvider + { + public ?string $title = null; + + public function boot(): void + { + $this->title = config('blog.title'); + } + }); + + expect(collect($this->app->getProviders(ServiceProvider::class))->last()->title)->toBe('Blog'); +}); + +it('does not read the module config when the config is cached', function () { + // Testbench always loads the config files, so the registrar is run by hand. + $this->app->instance('config_loaded_from_cache', true); + config(['blog' => null]); + + (new ResourceRegistrar($this->app, app(ModuleRepository::class)->manifest()))->register(); + + expect($this->app->configurationIsCached())->toBeTrue() + ->and(config('blog'))->toBeNull(); +}); + +it('refuses a config file that does not return an array', function () { + $this->files(['modules/blog/config/broken.php' => ' $this->reboot())->toThrow(InvalidModule::class, 'config/broken.php must return an array.'); +}); + +it('checks the config files only of a cached manifest', function (bool $cached) { + $manifest = app(ModuleRepository::class)->manifest(); + array_unshift($manifest['blog']['config'], 'deleted'); + config(['blog' => null]); + + $register = fn () => (new ResourceRegistrar($this->app, $manifest, $cached))->register(); + + $cached + ? expect($register)->not->toThrow(Throwable::class) + ->and(config('blog.title'))->toBe('Blog') + : expect($register)->toThrow(ErrorException::class, 'Failed to open stream'); +})->with(['cached' => true, 'built' => false]); diff --git a/tests/Feature/Conventions/MigrationsTest.php b/tests/Feature/Conventions/MigrationsTest.php new file mode 100644 index 0000000..e67d156 --- /dev/null +++ b/tests/Feature/Conventions/MigrationsTest.php @@ -0,0 +1,21 @@ +paths())->toBe([$this->path('modules/blog/database/migrations')]); +}); + +it('runs the migrations of the modules', function () { + config(['database.default' => 'testing']); + + $this->artisan('migrate')->assertSuccessful(); + + expect(Schema::hasTable('posts'))->toBeTrue(); + + $this->artisan('migrate:rollback')->assertSuccessful(); + + expect(Schema::hasTable('posts'))->toBeFalse(); +}); diff --git a/tests/Feature/Conventions/RoutesTest.php b/tests/Feature/Conventions/RoutesTest.php new file mode 100644 index 0000000..02559b1 --- /dev/null +++ b/tests/Feature/Conventions/RoutesTest.php @@ -0,0 +1,94 @@ +getRoutes()) + ->reject(static fn (RouteObject $route): bool => str_starts_with($route->uri(), 'storage/')) + ->mapWithKeys(static fn (RouteObject $route): array => [$route->uri() => $route->gatherMiddleware()]) + ->all(); +} + +it('loads the route files into the route groups', function () { + expect(moduleRoutes())->toBe([ + 'api/posts' => ['api'], + 'api/v1/feed' => ['api'], + 'api/v1/admin/stats' => ['api'], + 'blog' => ['web'], + 'orders' => ['web'], + ]); + + $this->get('/api/v1/admin/stats')->assertOk()->assertSee('blog stats'); + $this->get('/blog')->assertOk()->assertSee('Blog: Welcome to the blog'); + $this->get('/orders')->assertOk(); + expect(route('blog.posts', absolute: false))->toBe('/api/posts'); +}); + +it('applies every attribute of a route group', function () { + $this->files(['config/modules.php' => <<<'PHP' + [ + 'api' => ['path' => 'routes/api', 'prefix' => '/api/', 'as' => 'api.', 'middleware' => ['api', 'auth:sanctum']], + 'web' => ['path' => 'routes/web', 'domain' => 'shop.test'], + ]]; + PHP]); + $this->reboot(); + + // Routes with a domain are listed first. + expect(moduleRoutes())->toBe([ + 'blog' => [], + 'orders' => [], + 'api/posts' => ['api', 'auth:sanctum'], + 'api/v1/feed' => ['api', 'auth:sanctum'], + 'api/v1/admin/stats' => ['api', 'auth:sanctum'], + ]) + ->and(Route::getRoutes()->getByName('api.blog.posts')?->uri())->toBe('api/posts') + ->and(Route::getRoutes()->getByName('shop-order.orders')?->getDomain())->toBe('shop.test'); +}); + +it('uses the directory as the prefix of a group without a prefix', function () { + $this->files(['config/modules.php' => ' ["api" => ["path" => "routes/api"]]];']); + $this->reboot(); + + expect(array_keys(moduleRoutes()))->toBe(['posts', 'v1/feed', 'v1/admin/stats']); +}); + +it('does not load the route files when the routes are cached', function () { + // Laravel loads the cached routes after the providers boot, Testbench does not load them at all. + $this->files(['bootstrap/cache/routes-v7.php' => 'reboot(); + + expect(app()->routesAreCached())->toBeTrue() + ->and(moduleRoutes())->toBe([]); +}); + +it('skips route groups that are not arrays', function () { + // Route files register their routes through the facade. + app()->instance('router', $router = new Router(app('events'), app())); + Route::clearResolvedInstance('router'); + $groups = ['api' => 'routes/api', 'web' => ['path' => 'routes/web']]; + + (new RouteRegistrar($router, $groups, app(ModuleRepository::class)->manifest()))->register(); + + expect(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toBe(['blog', 'orders']); +}); + +it('checks the route files only of a cached manifest', function (bool $cached) { + app()->instance('router', $router = new Router(app('events'), app())); + Route::clearResolvedInstance('router'); + $manifest = app(ModuleRepository::class)->manifest(); + $manifest['blog']['routes']['web'][''][] = 'routes/web/deleted.php'; + + $register = fn () => (new RouteRegistrar($router, config('modules.routes'), $manifest, $cached))->register(); + + $cached + ? expect($register)->not->toThrow(Throwable::class) + ->and(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toContain('api/posts', 'blog') + : expect($register)->toThrow(ErrorException::class, 'Failed to open stream'); +})->with(['cached' => true, 'built' => false]); diff --git a/tests/Feature/Conventions/TranslationsTest.php b/tests/Feature/Conventions/TranslationsTest.php new file mode 100644 index 0000000..2f23e48 --- /dev/null +++ b/tests/Feature/Conventions/TranslationsTest.php @@ -0,0 +1,49 @@ +toBe('Welcome to the blog') + ->and(__('shop-order::orders.created'))->toBe('Order created'); +}); + +it('registers the JSON translations of the modules that have them', function () { + expect(__('Read more'))->toBe('Read the whole post') + ->and(app('translation.loader')->jsonPaths())->toBe([$this->path('modules/blog/lang')]) + ->and(app('translation.loader')->namespaces())->toMatchArray([ + 'blog' => $this->path('modules/blog/lang'), + 'shop-order' => $this->path('modules/shop-order/lang'), + ]); +}); + +it('lets the application override the module translations', function () { + $this->files(['lang/vendor/blog/en/messages.php' => ' "Hello"];']); + + expect(__('blog::messages.welcome'))->toBe('Hello'); +}); + +it('adds the translations whether the translator is resolved before or after the modules boot', function (bool $before) { + $manifest = app(ModuleRepository::class)->manifest(); + $app = new Application($this->path()); + Container::setInstance($this->app); + $app->instance('files', new Filesystem); + $app->instance('config', new Repository(['app' => ['locale' => 'en', 'fallback_locale' => 'en']])); + (new TranslationServiceProvider($app))->register(); + + $translator = $before ? $app->make('translator') : null; + + (new ResourceRegistrar($app, $manifest))->register(); + + $translator ??= $app->make('translator'); + + expect($translator->get('blog::messages.welcome'))->toBe('Welcome to the blog') + ->and($translator->get('Read more'))->toBe('Read the whole post'); +})->with(['resolved before' => true, 'resolved after' => false]); diff --git a/tests/Feature/Conventions/ViewsTest.php b/tests/Feature/Conventions/ViewsTest.php new file mode 100644 index 0000000..9c23efa --- /dev/null +++ b/tests/Feature/Conventions/ViewsTest.php @@ -0,0 +1,88 @@ + 'News'])->render())->toBe("Blog: News\n") + ->and(app('view')->getFinder()->getHints())->toMatchArray(['blog' => [$this->path('modules/blog/resources/views')]]) + ->and(app('view')->getFinder()->getHints())->not->toHaveKey('shop-order'); +}); + +it('does not look for view overrides in the vendor directory', function () { + $this->files(['resources/views/vendor/blog/index.blade.php' => 'Overridden']); + + expect(view('blog::index', ['title' => 'News'])->render())->toBe("Blog: News\n"); +}); + +it('registers the Blade components of the modules under their names', function () { + $this->files([ + 'modules/blog/src/View/Components/Badge.php' => <<<'PHP' + 'anonymous card', + ]); + + expect(Blade::render(' '))->toBe('class badge anonymous card'); +}); + +it('registers the Blade components in the module root with an empty make:component namespace', function () { + $this->files([ + 'config/modules.php' => ' ["make:component" => ""]];', + 'modules/blog/src/Badge.php' => <<<'PHP' + reboot(); + + expect(Blade::render(''))->toBe('root badge'); +}); + +it('registers the Blade components in the namespace of make:component', function () { + $this->files([ + 'config/modules.php' => ' ["make:component" => "\\\\UI\\\\Components\\\\"]];', + 'modules/blog/src/UI/Components/Badge.php' => <<<'PHP' + reboot(); + + expect(Blade::render(''))->toBe('mapped badge'); +}); diff --git a/tests/Feature/Discovery/CommandsTest.php b/tests/Feature/Discovery/CommandsTest.php new file mode 100644 index 0000000..1d4b530 --- /dev/null +++ b/tests/Feature/Discovery/CommandsTest.php @@ -0,0 +1,66 @@ +artisan('blog:publish')->expectsOutputToContain('blog:publish done')->assertSuccessful(); + $this->artisan('blog:archive')->expectsOutputToContain('blog:archive done')->assertSuccessful(); + + expect(Artisan::all()) + ->toHaveKey('blog:publish') + ->toHaveKey('blog:archive') + ->and(Artisan::all()['blog:publish'])->toBeInstanceOf(PublishPostsCommand::class) + ->and(Artisan::all()['blog:archive'])->toBeInstanceOf(ArchivePostsCommand::class); +}); + +it('skips abstract classes and files that are not commands', function () { + $commands = collect(Artisan::all())->map(static fn (object $command): string => $command::class)->values(); + + expect($commands->filter(static fn (string $class): bool => str_starts_with($class, 'Modules\\'))->sort()->values()->all()) + ->toBe([ArchivePostsCommand::class, PublishPostsCommand::class]); +}); + +it('registers the commands lazily', function () { + $this->artisan('module:list')->assertSuccessful(); + + $console = (new ReflectionProperty(Kernel::class, 'artisan'))->getValue(app(Illuminate\Contracts\Console\Kernel::class)); + $loader = (new ReflectionProperty(Application::class, 'commandLoader'))->getValue($console); + + expect($loader->has('blog:publish'))->toBeTrue() + ->and((new ReflectionProperty(Application::class, 'commands'))->getValue($console)) + ->not->toHaveKey('blog:publish'); +}); + +it('uses the "make:command" generator namespace', function () { + $this->files([ + 'config/modules.php' => ' ["make:command" => "Commands"]];', + 'modules/blog/src/Commands/SyncPostsCommand.php' => <<<'PHP' + info('synced'); + } + } + PHP, + ]); + $this->reboot(); + + $this->artisan('blog:sync')->expectsOutputToContain('synced')->assertSuccessful(); + + expect(Artisan::all())->not->toHaveKey('blog:publish'); +}); diff --git a/tests/Feature/Discovery/FactoriesTest.php b/tests/Feature/Discovery/FactoriesTest.php new file mode 100644 index 0000000..2c24da2 --- /dev/null +++ b/tests/Feature/Discovery/FactoriesTest.php @@ -0,0 +1,96 @@ +toBe($factory); +})->with([ + 'model' => ['Modules\\Blog\\Models\\Post', 'Modules\\Blog\\Database\\Factories\\PostFactory'], + 'nested model' => ['Modules\\Blog\\Models\\Admin\\Role', 'Modules\\Blog\\Database\\Factories\\Admin\\RoleFactory'], + 'model outside Models' => ['Modules\\ShopOrder\\Entities\\Order', 'Modules\\ShopOrder\\Database\\Factories\\Entities\\OrderFactory'], + 'application model' => ['App\\Models\\User', 'Database\\Factories\\UserFactory'], + 'application class' => ['App\\User', 'Database\\Factories\\UserFactory'], + 'package model' => ['Vendor\\Package\\Models\\Thing', 'Database\\Factories\\Vendor\\Package\\Models\\ThingFactory'], + 'unknown module namespace' => ['Modules\\Wiki\\Models\\Page', 'Database\\Factories\\Modules\\Wiki\\Models\\PageFactory'], +]); + +it('pairs module factories with the models of the module', function () { + expect(PostFactory::new()->modelName())->toBe(Post::class) + ->and(Post::factory()->make()->title)->toBe('Hello'); +}); + +it('falls back to the root namespace of the module when the model is not in Models', function () { + $this->files(['modules/shop-order/database/factories/InvoiceFactory.php' => <<<'PHP' + modelName())->toBe('Modules\\ShopOrder\\Invoice'); +}); + +it('pairs a factory with a module model outside Models', function () { + $this->files([ + 'modules/shop-order/src/Entities/Order.php' => ' 'modelName())->toBe('Modules\\ShopOrder\\Entities\\Order'); +}); + +it('keeps the default behavior for application factories', function () { + eval('namespace Database\\Factories; class WidgetFactory extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory { public function definition(): array { return []; } }'); + + expect((new WidgetFactory)->modelName())->toBe('App\\Widget'); +}); + +it('picks the most specific module namespace', function () { + $this->files(['modules/blog-comments/composer.json' => json_encode([ + 'name' => 'app/blog-comments', + 'autoload' => ['psr-4' => ['Modules\\Blog\\Comments\\' => 'src/']], + ])]); + $this->reboot(); + + expect(Factory::resolveFactoryName('Modules\\Blog\\Comments\\Models\\Comment')) + ->toBe('Modules\\Blog\\Comments\\Database\\Factories\\CommentFactory') + ->and(Factory::resolveFactoryName('Modules\\Blog\\Models\\Post')) + ->toBe('Modules\\Blog\\Database\\Factories\\PostFactory'); +}); + +it('keeps the default resolvers without modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + Factory::flushState(); + $this->reboot(); + + expect(Factory::resolveFactoryName('Modules\\Blog\\Models\\Post')) + ->toBe('Database\\Factories\\Modules\\Blog\\Models\\PostFactory'); +}); + +it('falls back to the App namespace without an application', function () { + $container = Container::getInstance(); + Container::setInstance(new Container); + + try { + expect(Factory::resolveFactoryName('App\\Models\\User'))->toBe('Database\\Factories\\UserFactory'); + } finally { + Container::setInstance($container); + } +}); diff --git a/tests/Feature/Discovery/OctaneWatchTest.php b/tests/Feature/Discovery/OctaneWatchTest.php new file mode 100644 index 0000000..ee2060d --- /dev/null +++ b/tests/Feature/Discovery/OctaneWatchTest.php @@ -0,0 +1,48 @@ +files(['config/octane.php' => ' '.var_export($watch, true).'];']); + $this->reboot(); + + expect(config('octane.watch'))->toBe($expected); +})->with([ + 'appended' => [['app', 'routes'], ['app', 'routes', 'modules']], + 'already watched' => [['modules', 'app'], ['modules', 'app']], + 'empty' => [[], ['modules']], +]); + +it('watches a nested modules directory by its relative path', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' base_path("src/modules")];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app', 'src/modules']); +}); + +it('does not configure the watcher when Octane is not configured', function () { + expect(config('octane'))->toBeNull(); +}); + +it('can not watch a modules directory outside of the application', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' "'.sys_get_temp_dir().'/elsewhere/modules"];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app']); +}); + +it('can not watch the application directory itself', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' base_path()];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app']); +}); diff --git a/tests/Feature/Discovery/TinkerTest.php b/tests/Feature/Discovery/TinkerTest.php new file mode 100644 index 0000000..8db4d2b --- /dev/null +++ b/tests/Feature/Discovery/TinkerTest.php @@ -0,0 +1,21 @@ +toBe(['Modules\\Blog\\', 'Modules\\ShopOrder\\']); +}); + +it('keeps the aliases of the application', function () { + $this->files(['config/tinker.php' => ' ["Vendor\\\\Package\\\\", "Modules\\\\Blog\\\\"]];']); + $this->reboot(); + + expect(config('tinker.alias'))->toBe(['Vendor\\Package\\', 'Modules\\Blog\\', 'Modules\\ShopOrder\\']); +}); + +it('does not add aliases without modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + $this->reboot(); + + expect(config('tinker.alias'))->toBeNull(); +}); diff --git a/tests/Feature/ModuleRepositoryTest.php b/tests/Feature/ModuleRepositoryTest.php new file mode 100644 index 0000000..1d559ec --- /dev/null +++ b/tests/Feature/ModuleRepositoryTest.php @@ -0,0 +1,153 @@ +toEqual([ + 'blog' => new Module( + name: 'blog', + package: 'app/blog', + namespace: 'Modules\\Blog', + path: $this->path('modules/blog'), + sourcePath: $this->path('modules/blog/src'), + ), + 'shop-order' => new Module( + name: 'shop-order', + package: 'app/shop-order', + namespace: 'Modules\\ShopOrder', + path: $this->path('modules/shop-order'), + sourcePath: $this->path('modules/shop-order/src'), + ), + ]); +}); + +it('finds a module by name', function () { + expect(Modules::find('shop-order')?->package)->toBe('app/shop-order') + ->and(Modules::get('blog')->namespace)->toBe('Modules\\Blog') + ->and(Modules::find('notes'))->toBeNull() + ->and(Modules::find('Blog'))->toBeNull(); +}); + +it('fails to get a missing module', function () { + expect(fn () => Modules::get('notes')) + ->toThrow(ModuleNotFound::class, 'Module [notes] not found.') + ->and(ModuleNotFound::named('notes'))->toBeInstanceOf(ModulesException::class); +}); + +it('uses the module root as the source path when the root namespace maps to it', function () { + $this->files(['modules/flat/composer.json' => json_encode([ + 'name' => 'app/flat', + 'autoload' => ['psr-4' => ['Modules\\Flat\\' => '']], + ])]); + $this->reboot(); + + expect(Modules::get('flat')->sourcePath)->toBe($this->path('modules/flat')); +}); + +it('orders seeders by the number at the end of their class name', function () { + expect(Modules::seeders())->toBe([ + OrderSeeder_1::class, + PostSeeder_2::class, + CategorySeeder_10::class, + BlogSeeder::class, + ]); +}); + +it('collects seeders of the given subdirectories', function (array $directories) { + expect(Modules::seeders(...$directories))->toBe([ + DemoSeeder_1::class, + ShipmentSeeder_1::class, + OrderSeeder_1::class, + PostSeeder_2::class, + CategorySeeder_10::class, + BlogSeeder::class, + ]); +})->with([ + 'root first' => [['', 'Deployment']], + 'root last' => [['Deployment', '']], + 'slashes and duplicates' => [['/Deployment/', 'Deployment', '']], +]); + +it('collects only the seeders of a subdirectory', function () { + expect(Modules::seeders('Deployment'))->toBe([DemoSeeder_1::class, ShipmentSeeder_1::class]) + ->and(Modules::seeders('/Deployment\\'))->toBe([DemoSeeder_1::class, ShipmentSeeder_1::class]) + ->and(Modules::seeders('Missing'))->toBe([]); +}); + +it('builds the manifest once per process', function () { + $manifest = app(ModuleRepository::class)->manifest(); + + $this->files(['modules/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ])]); + $this->reloadApplication(); + + expect(app(ModuleRepository::class)->manifest())->toBe($manifest) + ->and(Modules::find('wiki'))->toBeNull(); + + app(ModuleRepository::class)->refresh(); + + expect(Modules::find('wiki'))->not->toBeNull(); +}); + +it('keeps separate manifests for separate settings', function () { + app(ModuleRepository::class)->manifest(); + + $this->files([ + 'config/modules.php' => ' base_path("packages")];', + 'packages/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ]), + ]); + $this->reloadApplication(); + + expect(array_keys(Modules::all()))->toBe(['wiki']); +}); + +it('reads the manifest from the cache', function () { + $modules = app(ModuleRepository::class); + $modules->cache(); + + $this->files(['modules/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ])]); + $this->reboot(); + $modules = app(ModuleRepository::class); + + expect($modules->isCached())->toBeTrue() + ->and($modules->isCacheStale())->toBeTrue() + ->and(array_keys($modules->all()))->toBe(['blog', 'shop-order']); + + $modules->refresh(); + + expect(array_keys($modules->all()))->toBe(['blog', 'shop-order', 'wiki']) + ->and($modules->isCacheStale())->toBeFalse(); + + $modules->clearCache(); + + expect($modules->isCached())->toBeFalse() + ->and($modules->isCacheStale())->toBeFalse() + ->and(array_keys($modules->all()))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('does not create the cache on refresh', function () { + $modules = app(ModuleRepository::class); + $modules->refresh(); + + expect($modules->isCached())->toBeFalse(); +}); diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php new file mode 100644 index 0000000..83b78a7 --- /dev/null +++ b/tests/Feature/ProviderTest.php @@ -0,0 +1,176 @@ +toBe([ + 'providers' => [ModulesServiceProvider::class], + 'aliases' => ['Modules' => Modules::class], + ]) + ->and(app()->getProviders(ModulesServiceProvider::class))->toHaveCount(1) + ->and(\Modules::get('blog')->package)->toBe('app/blog'); +}); + +it('merges its config with the config of the application', function () { + expect(config('modules'))->toBe([ + 'path' => base_path('modules'), + 'namespace' => 'Modules', + 'vendor' => 'app', + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], + 'generators' => [], + ]); + + $this->files(['config/modules.php' => " 'acme'];"]); + $this->reboot(); + + expect(config('modules.vendor'))->toBe('acme') + ->and(config('modules.namespace'))->toBe('Modules'); +}); + +it('publishes the config and the module template', function () { + $published = static fn (string $tag): array => collect(ServiceProvider::pathsToPublish(ModulesServiceProvider::class, $tag)) + ->mapWithKeys(static fn (string $to, string $from): array => [realpath($from) => $to]) + ->all(); + + expect($published('modules-config')) + ->toBe([realpath(__DIR__.'/../../config/modules.php') => config_path('modules.php')]) + ->and($published('modules-stubs')) + ->toBe([realpath(__DIR__.'/../../resources/stubs/module/default') => base_path('stubs/module/default')]); + + $this->artisan('vendor:publish --tag=modules-stubs')->assertSuccessful(); + + expect(file_exists($this->path('stubs/module/default/composer.json.stub')))->toBeTrue(); +}); + +it('adds its cache to optimize and optimize:clear', function () { + // The framework caches are skipped: they would boot the application from bootstrap/app.php. + $this->artisan('optimize --except=config,events,routes,views') + ->expectsOutputToContain('modules') + ->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeTrue(); + + $this->artisan('optimize:clear --except=config,cache,compiled,events,routes,views') + ->expectsOutputToContain('modules') + ->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeFalse(); +}); + +it('shows the modules in the about command', function () { + $about = static function (): array { + Artisan::call('about', ['--only' => 'modules', '--json' => true]); + + return json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR); + }; + + expect($about())->toBe(['modules' => ['modules' => 2, 'path' => 'modules', 'cache' => false]]); + + $this->artisan('module:cache'); + + expect($about())->toBe(['modules' => ['modules' => 2, 'path' => 'modules', 'cache' => true]]); +}); + +it('keeps the HTTP runtime free of console conventions', function () { + $this->rebootForHttp(); + + expect(app()->runningInConsole())->toBeFalse() + ->and(Factory::resolveFactoryName(Post::class))->toBe('Database\\Factories\\Modules\\Blog\\Models\\PostFactory') + ->and(config('tinker.alias'))->toBeNull() + ->and(app('migrator')->paths())->toBe([]) + ->and(Artisan::all())->not->toHaveKeys(['blog:publish', 'module:make']) + ->and(Artisan::all()['make:model']->getDefinition()->hasOption('module'))->toBeFalse(); + + $this->get('/api/posts')->assertOk()->assertSee('blog posts'); +}); + +it('refuses an invalid route group', function (mixed $group) { + $this->files(['config/modules.php' => ' ["api" => '.var_export($group, true).']];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'Invalid config/modules.php: the [api] route group must have a "path".'); +})->with([ + 'no path' => [['prefix' => 'api']], + 'path is not a string' => [['path' => ['routes/api']]], + 'not an array' => ['routes/api'], +]); + +it('refuses a generator namespace that is not a string', function () { + $this->files(['config/modules.php' => ' ["make:controller" => ["Http"]]];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:controller] generator must be a string.'); +}); + +it('refuses an empty make:command namespace', function (string $namespace) { + $this->files(['config/modules.php' => ' ["make:command" => '.var_export($namespace, true).']];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:command] generator can not be empty'); +})->with(['', '\\']); + +it('resolves a relative modules path against the application', function () { + $this->files(['config/modules.php' => ' "modules"];']); + chdir(sys_get_temp_dir()); + $this->reboot(); + + expect(array_keys(Modules::all()))->toBe(['blog', 'shop-order']) + ->and(Modules::get('blog')->path)->toBe($this->path('modules/blog')); +}); + +it('lets module providers that boot first use the module translations, views and components', function () { + $this->providersBeforeModules = [ModuleProviderBootingFirst::class]; + $this->reboot(); + + expect(ModuleProviderBootingFirst::$seen)->toBe([ + 'Welcome to the blog', + true, + 'Modules\\Blog\\View\\Components', + ]); +}); + +it('stores the manifest cache where MODULES_CACHE points', function (string $path, string $expected) { + $_ENV['MODULES_CACHE'] = $path; + $this->reboot(); + // Registered after the reboot, which destroys the previous application. + $this->beforeApplicationDestroyed(static function (): void { + unset($_ENV['MODULES_CACHE']); + }); + + $this->artisan('module:cache')->assertSuccessful(); + + expect(app(ModuleRepository::class)->cachePath())->toBe($expected) + ->and(file_exists($expected))->toBeTrue(); +})->with([ + 'relative' => ['storage/modules.php', fn () => base_path('storage/modules.php')], + 'absolute' => [fn () => $this->path('storage/framework/modules.php'), fn () => $this->path('storage/framework/modules.php')], +]); + +final class ModuleProviderBootingFirst extends ServiceProvider +{ + /** + * @var list + */ + public static array $seen = []; + + public function boot(): void + { + self::$seen = [ + __('blog::messages.welcome'), + view()->exists('blog::index'), + Blade::getClassComponentNamespaces()['blog'] ?? null, + ]; + } +} diff --git a/tests/fixtures/laravel/storage/app/public/.gitignore b/tests/Fixtures/app/app/.gitkeep similarity index 100% rename from tests/fixtures/laravel/storage/app/public/.gitignore rename to tests/Fixtures/app/app/.gitkeep diff --git a/tests/Fixtures/app/bootstrap/cache/.gitkeep b/tests/Fixtures/app/bootstrap/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/app/composer.json b/tests/Fixtures/app/composer.json new file mode 100644 index 0000000..fe5abb9 --- /dev/null +++ b/tests/Fixtures/app/composer.json @@ -0,0 +1,17 @@ +{ + "name": "laraneat/fixture-app", + "type": "project", + "require": { + "php": "^8.3", + "laravel/framework": "^13.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable" +} diff --git a/tests/Fixtures/app/modules/blog/composer.json b/tests/Fixtures/app/modules/blog/composer.json new file mode 100644 index 0000000..95760e0 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/composer.json @@ -0,0 +1,17 @@ +{ + "name": "app/blog", + "description": "The blog module.", + "type": "library", + "autoload": { + "psr-4": { + "Modules\\Blog\\": "src/", + "Modules\\Blog\\Database\\Factories\\": "database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "tests/" + } + } +} diff --git a/tests/Fixtures/app/modules/blog/config/blog.php b/tests/Fixtures/app/modules/blog/config/blog.php new file mode 100644 index 0000000..fb69eda --- /dev/null +++ b/tests/Fixtures/app/modules/blog/config/blog.php @@ -0,0 +1,6 @@ + 'Blog', + 'per_page' => 15, +]; diff --git a/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php b/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php new file mode 100644 index 0000000..c8e64c4 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php @@ -0,0 +1,17 @@ + + */ +class PostFactory extends Factory +{ + public function definition(): array + { + return ['title' => 'Hello']; + } +} diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php b/tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php similarity index 62% rename from tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php rename to tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php index 13b5e38..82c2577 100644 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php +++ b/tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php @@ -6,23 +6,17 @@ return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { - Schema::create('articles', static function (Blueprint $table) { + Schema::create('posts', function (Blueprint $table) { $table->id(); - + $table->string('title'); $table->timestamps(); }); } - /** - * Reverse the migrations. - */ public function down(): void { - Schema::dropIfExists('articles'); + Schema::dropIfExists('posts'); } }; diff --git a/src/Commands/Generators/stubs/seeder/plain.stub b/tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php similarity index 57% rename from src/Commands/Generators/stubs/seeder/plain.stub rename to tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php index e8c583f..3d90995 100644 --- a/src/Commands/Generators/stubs/seeder/plain.stub +++ b/tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php @@ -1,10 +1,10 @@ 'Welcome to the blog', +]; diff --git a/tests/Fixtures/app/modules/blog/resources/views/index.blade.php b/tests/Fixtures/app/modules/blog/resources/views/index.blade.php new file mode 100644 index 0000000..5ee9015 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/resources/views/index.blade.php @@ -0,0 +1 @@ +Blog: {{ $title }} diff --git a/tests/Fixtures/app/modules/blog/routes/api/README.md b/tests/Fixtures/app/modules/blog/routes/api/README.md new file mode 100644 index 0000000..e846db1 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/README.md @@ -0,0 +1 @@ +Routes of the blog API. diff --git a/tests/Fixtures/app/modules/blog/routes/api/posts.php b/tests/Fixtures/app/modules/blog/routes/api/posts.php new file mode 100644 index 0000000..1457364 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/posts.php @@ -0,0 +1,5 @@ + 'blog posts')->name('blog.posts'); diff --git a/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php b/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php new file mode 100644 index 0000000..2d37580 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php @@ -0,0 +1,5 @@ + 'blog stats'); diff --git a/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php b/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php new file mode 100644 index 0000000..57d8205 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php @@ -0,0 +1,5 @@ + 'blog feed v1'); diff --git a/tests/Fixtures/app/modules/blog/routes/web/pages.php b/tests/Fixtures/app/modules/blog/routes/web/pages.php new file mode 100644 index 0000000..fcd8ca4 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/web/pages.php @@ -0,0 +1,5 @@ + view('blog::index', ['title' => __('blog::messages.welcome')])); diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php new file mode 100644 index 0000000..d940311 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php @@ -0,0 +1,7 @@ +info('blog:archive done'); + + return self::SUCCESS; + } +} diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php new file mode 100644 index 0000000..801450c --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php @@ -0,0 +1,19 @@ +info('blog:publish done'); + + return self::SUCCESS; + } +} diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php new file mode 100644 index 0000000..28c81b9 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php @@ -0,0 +1,4 @@ + */ + use HasFactory; + + protected $guarded = []; +} diff --git a/tests/Fixtures/app/modules/blog/tests/.gitkeep b/tests/Fixtures/app/modules/blog/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/app/modules/notes/README.md b/tests/Fixtures/app/modules/notes/README.md new file mode 100644 index 0000000..4921d5a --- /dev/null +++ b/tests/Fixtures/app/modules/notes/README.md @@ -0,0 +1 @@ +A directory without composer.json is not a module. diff --git a/tests/Fixtures/app/modules/shop-order/composer.json b/tests/Fixtures/app/modules/shop-order/composer.json new file mode 100644 index 0000000..037ac49 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/composer.json @@ -0,0 +1,12 @@ +{ + "name": "app/shop-order", + "description": "The shop order module.", + "type": "library", + "autoload": { + "psr-4": { + "Modules\\ShopOrder\\": "src/", + "Modules\\ShopOrder\\Database\\Factories\\": "database/factories/", + "Modules\\ShopOrder\\Database\\Seeders\\": "database/seeders/" + } + } +} diff --git a/tests/Fixtures/app/modules/shop-order/config/shop-order.php b/tests/Fixtures/app/modules/shop-order/config/shop-order.php new file mode 100644 index 0000000..74ba99d --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/config/shop-order.php @@ -0,0 +1,5 @@ + 'EUR', +]; diff --git a/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php b/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php new file mode 100644 index 0000000..8a23f65 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php @@ -0,0 +1,13 @@ + 'Order created', +]; diff --git a/tests/Fixtures/app/modules/shop-order/routes/web/orders.php b/tests/Fixtures/app/modules/shop-order/routes/web/orders.php new file mode 100644 index 0000000..d4efdb4 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/routes/web/orders.php @@ -0,0 +1,5 @@ + 'orders')->name('shop-order.orders'); diff --git a/tests/Fixtures/app/modules/shop-order/src/Models/Order.php b/tests/Fixtures/app/modules/shop-order/src/Models/Order.php new file mode 100644 index 0000000..5e25dd2 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/src/Models/Order.php @@ -0,0 +1,7 @@ +generate('make:model Comment -mfs --policy --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Models/Comment.php', + 'modules/blog/src/Policies/CommentPolicy.php', + 'modules/blog/database/factories/CommentFactory.php', + 'modules/blog/database/migrations/{date}_create_comments_table.php', + 'modules/blog/database/seeders/CommentSeeder.php', + ]) + ->and($files['modules/blog/src/Models/Comment.php'])->toContain('/** @use HasFactory<\\Modules\\Blog\\Database\\Factories\\CommentFactory> */') + ->and($files['modules/blog/database/factories/CommentFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories;', + 'use Modules\\Blog\\Models\\Comment;', + '@extends Factory', + ) + ->and($files['modules/blog/database/seeders/CommentSeeder.php'])->toContain('namespace Modules\\Blog\\Database\\Seeders;') + ->and($files['modules/blog/src/Policies/CommentPolicy.php'])->toContain('use Modules\\Blog\\Models\\Comment;'); +}); + +it('creates the factory of a nested model where the factory resolver looks for it', function () { + $files = $this->generate('make:model Admin/Role --factory --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Models/Admin/Role.php', + 'modules/blog/database/factories/Admin/RoleFactory.php', + ]) + ->and($files['modules/blog/src/Models/Admin/Role.php'])->toContain('HasFactory<\\Modules\\Blog\\Database\\Factories\\Admin\\RoleFactory>') + ->and($files['modules/blog/database/factories/Admin/RoleFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories\\Admin;', + 'use Modules\\Blog\\Models\\Admin\\Role;', + ); +}); + +it('registers a module provider in the composer.json of the module', function () { + $this->artisan('make:provider EventServiceProvider --module=blog') + ->expectsOutputToContain('Provider [Modules\\Blog\\Providers\\EventServiceProvider] registered in [modules/blog/composer.json]. Run "php artisan module:sync" to install it.') + ->assertSuccessful(); + + $this->artisan('make:provider EventServiceProvider --module=blog') + ->expectsOutputToContain('Provider already exists.') + ->doesntExpectOutputToContain('registered in') + ->assertSuccessful(); + + expect($this->readJson('modules/blog/composer.json')['extra']) + ->toBe(['laravel' => ['providers' => ['Modules\\Blog\\Providers\\EventServiceProvider']]]) + ->and(file_exists($this->path('modules/blog/src/Providers/EventServiceProvider.php')))->toBeTrue() + ->and(file_exists($this->path('bootstrap/providers.php')))->toBeFalse(); +}); + +it('uses the namespaces of the "generators" config', function (string $command, string $path) { + $this->files(['config/modules.php' => ' ["make:action" => "UI\\\\Actions\\\\", "make:controller" => "Http\\\\Api"]];']); + $this->reboot(); + + expect(array_keys($this->generate($command)))->toBe([$path]); +})->with([ + 'action' => ['make:action PublishPost --module=blog', 'modules/blog/src/UI/Actions/PublishPost.php'], + 'nested name' => ['make:action Posts/PublishPost --module=blog', 'modules/blog/src/UI/Actions/Posts/PublishPost.php'], + 'backslashes' => ['make:action \\\\Posts\\\\PublishPost --module=shop-order', 'modules/shop-order/src/UI/Actions/Posts/PublishPost.php'], + 'fully qualified name' => ['make:action "Modules\\\\Blog\\\\Domain\\\\PublishPost" --module=blog', 'modules/blog/src/Domain/PublishPost.php'], + 'controller' => ['make:controller PostController --module=blog', 'modules/blog/src/Http/Api/PostController.php'], + 'other generators' => ['make:job PublishPost --module=blog', 'modules/blog/src/Jobs/PublishPost.php'], + 'without --module' => ['make:action PublishPost', 'app/Actions/PublishPost.php'], +]); + +it('applies the "generators" config to the generators a generator calls', function () { + $this->files(['config/modules.php' => ' ['.implode(', ', [ + '"make:model" => "Domain\\\\Models"', + '"make:controller" => "UI\\\\API\\\\Controllers"', + '"make:request" => "UI\\\\API\\\\Requests"', + '"make:policy" => "Domain\\\\Policies"', + ]).']];']); + $this->reboot(); + + $files = $this->generate('make:model Book -a --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Domain/Models/Book.php', + 'modules/blog/src/Domain/Policies/BookPolicy.php', + 'modules/blog/src/UI/API/Controllers/BookController.php', + 'modules/blog/src/UI/API/Requests/StoreBookRequest.php', + 'modules/blog/src/UI/API/Requests/UpdateBookRequest.php', + 'modules/blog/database/factories/Domain/Models/BookFactory.php', + 'modules/blog/database/migrations/{date}_create_books_table.php', + 'modules/blog/database/seeders/BookSeeder.php', + ]) + ->and($files['modules/blog/src/Domain/Models/Book.php'])->toContain('HasFactory<\\Modules\\Blog\\Database\\Factories\\Domain\\Models\\BookFactory>') + ->and($files['modules/blog/src/UI/API/Controllers/BookController.php'])->toContain( + 'namespace Modules\\Blog\\UI\\API\\Controllers;', + 'use Modules\\Blog\\Domain\\Models\\Book;', + "use Modules\\Blog\\UI\\API\\Requests\\StoreBookRequest;\nuse Modules\\Blog\\UI\\API\\Requests\\UpdateBookRequest;", + ) + ->and($files['modules/blog/database/factories/Domain/Models/BookFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories\\Domain\\Models;', + 'use Modules\\Blog\\Domain\\Models\\Book;', + ); +}); + +it('looks up --model and --parent in the "make:model" namespace', function () { + $this->files(['config/modules.php' => ' ["make:model" => "Domain\\\\Models"]];']); + $this->reboot(); + + $policy = $this->generate('make:policy ShelfPolicy --model=Shelf --module=blog'); + $controller = $this->generate('make:controller VolumeController --model=Volume --parent=Shelf --no-interaction --module=blog'); + $qualified = $this->generate('make:observer PostObserver --model="Modules\\\\Blog\\\\Models\\\\Post" --module=blog'); + + expect($policy['modules/blog/src/Policies/ShelfPolicy.php'])->toContain('use Modules\\Blog\\Domain\\Models\\Shelf;') + ->and($controller['modules/blog/src/Http/Controllers/VolumeController.php'])->toContain( + 'use Modules\\Blog\\Domain\\Models\\Shelf;', + 'use Modules\\Blog\\Domain\\Models\\Volume;', + ) + ->and(array_keys($controller))->toEqualCanonicalizing([ + 'modules/blog/src/Domain/Models/Shelf.php', + 'modules/blog/src/Domain/Models/Volume.php', + 'modules/blog/src/Http/Controllers/VolumeController.php', + ]) + ->and($qualified['modules/blog/src/Observers/PostObserver.php'])->toContain('use Modules\\Blog\\Models\\Post;'); +}); + +it('maps to the module root with an empty namespace', function () { + $this->files(['config/modules.php' => ' ["make:model" => "", "make:policy" => ""]];']); + $this->reboot(); + + $files = $this->generate('make:policy RackPolicy --model=Rack --module=blog'); + + expect($files['modules/blog/src/RackPolicy.php'])->toContain('namespace Modules\\Blog;', 'use Modules\\Blog\\Rack;'); +}); + +it('imports form requests and names component views in the module root with an empty namespace', function () { + $this->files(['config/modules.php' => ' ["make:request" => "", "make:component" => ""]];']); + $this->reboot(); + + $controller = $this->generate('make:controller PostController --model=Post --requests --module=blog'); + $component = $this->generate('make:component Forms/Input --module=blog'); + + expect($controller['modules/blog/src/Http/Controllers/PostController.php']) + ->toContain("use Modules\\Blog\\StorePostRequest;\nuse Modules\\Blog\\UpdatePostRequest;") + ->and($controller)->toHaveKeys(['modules/blog/src/StorePostRequest.php', 'modules/blog/src/UpdatePostRequest.php']) + ->and($component['modules/blog/src/Forms/Input.php'])->toContain("view('blog::components.forms.input')") + ->and($component)->toHaveKey('modules/blog/resources/views/components/forms/input.blade.php'); +}); + +it('leaves the --model option of other generators alone', function () { + $this->files(['config/modules.php' => ' ["make:model" => "Domain\\\\Models"]];']); + $this->reboot(); + + $command = new class(app('files')) extends GeneratorCommand + { + protected $name = 'make:prompt'; + + protected function getStub(): string + { + return __FILE__; + } + + public function handle(): int + { + $this->line('model: '.$this->option('model')); + + return self::SUCCESS; + } + + protected function getOptions(): array + { + return [['model', null, InputOption::VALUE_REQUIRED, 'The language model']]; + } + }; + + ModuleOption::addTo($command); + Artisan::registerCommand($command); + + $this->artisan('make:prompt Summary --model=gpt-4o --module=blog') + ->expectsOutputToContain('model: gpt-4o') + ->assertSuccessful(); +}); + +it('imports the form requests of a controller from the module', function () { + $files = $this->generate('make:controller PostController --model=Post --requests --module=blog'); + + expect($files['modules/blog/src/Http/Controllers/PostController.php']) + ->toContain("use Modules\\Blog\\Http\\Requests\\StorePostRequest;\nuse Modules\\Blog\\Http\\Requests\\UpdatePostRequest;") + ->not->toContain('App\\') + ->and($files)->toHaveKeys(['modules/blog/src/Http/Requests/StorePostRequest.php', 'modules/blog/src/Http/Requests/UpdatePostRequest.php']); +}); + +it('keeps the form requests of an application controller', function () { + $files = $this->generate('make:controller PostController --model=Post --requests --no-interaction'); + + expect($files['app/Http/Controllers/PostController.php']) + ->toContain("use App\\Http\\Requests\\StorePostRequest;\nuse App\\Http\\Requests\\UpdatePostRequest;"); +}); + +it('maps namespaces relative to the root namespace of the generator', function (string $command, array $expected) { + $this->files(['config/modules.php' => ' ['.implode(', ', [ + '"make:test" => "Feature\\\\Api"', + '"make:seeder" => "Demo"', + '"make:factory" => "Testing"', + '"make:component" => "UI\\\\Components"', + ]).']];']); + $this->reboot(); + + $files = $this->generate($command); + + expect(array_keys($files))->toEqualCanonicalizing(array_keys($expected)); + + foreach ($expected as $path => $contains) { + expect($files[$path])->toContain(...$contains); + } +})->with([ + 'test' => ['make:test PostTest --phpunit --module=blog', [ + 'modules/blog/tests/Feature/Api/PostTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\Api;'], + ]], + 'seeder' => ['make:seeder PostSeeder --module=blog', [ + 'modules/blog/database/seeders/Demo/PostSeeder.php' => ['namespace Modules\\Blog\\Database\\Seeders\\Demo;'], + ]], + 'factory' => ['make:factory PostFactory --module=blog', [ + 'modules/blog/database/factories/Testing/PostFactory.php' => ['namespace Modules\\Blog\\Database\\Factories\\Testing;'], + ]], + 'test of another generator' => ['make:job PublishPost --test --phpunit --module=blog', [ + 'modules/blog/src/Jobs/PublishPost.php' => ['namespace Modules\\Blog\\Jobs;'], + 'modules/blog/tests/Feature/Api/Jobs/PublishPostTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\Api\\Jobs;'], + ]], + 'component' => ['make:component Forms/Input --module=blog', [ + 'modules/blog/src/UI/Components/Forms/Input.php' => ['namespace Modules\\Blog\\UI\\Components\\Forms;', "view('blog::components.forms.input')"], + 'modules/blog/resources/views/components/forms/input.blade.php' => ['
'], + ]], +]); + +it('creates a factory with a fully qualified name in the module', function () { + $files = $this->generate('make:factory "Modules\\\\Blog\\\\Database\\\\Factories\\\\Admin\\\\TagFactory" --module=blog'); + + expect(array_keys($files))->toBe(['modules/blog/database/factories/Admin/TagFactory.php']) + ->and($files['modules/blog/database/factories/Admin/TagFactory.php'])->toContain('namespace Modules\\Blog\\Database\\Factories\\Admin;'); +}); + +it('maps only the names of class generators', function () { + $this->files(['config/modules.php' => ' ["make:migration" => "Migrations"]];']); + $this->reboot(); + + expect(array_keys($this->generate('make:migration create_tags_table --module=blog'))) + ->toBe(['modules/blog/database/migrations/{date}_create_tags_table.php']); +}); + +it('generates into the application without --module', function (string $command, array $paths) { + expect(array_keys($this->generate($command)))->toEqualCanonicalizing($paths); +})->with([ + 'model' => ['make:model Tag -mfs', [ + 'app/Tag.php', + 'database/factories/TagFactory.php', + 'database/migrations/{date}_create_tags_table.php', + 'database/seeders/TagSeeder.php', + ]], + 'test' => ['make:test PostTest --phpunit', ['tests/Feature/PostTest.php']], + 'view' => ['make:view posts.show --test --phpunit', ['resources/views/posts/show.blade.php', 'tests/Feature/View/Posts/ShowTest.php']], + 'component' => ['make:component Alert', ['app/View/Components/Alert.php', 'resources/views/components/alert.blade.php']], + 'provider' => ['make:provider EventServiceProvider', ['app/Providers/EventServiceProvider.php']], +]); + +it('names the default markdown view of a notification after the module', function () { + $files = $this->generate('make:notification PostPublished --markdown --module=blog'); + + expect($files['modules/blog/src/Notifications/PostPublished.php'])->toContain("->markdown('blog::mail.post-published')") + ->and($files)->toHaveKey('modules/blog/resources/views/mail/post-published.blade.php'); +})->skip(! method_exists(NotificationMakeCommand::class, 'getView'), 'make:notification names a default markdown view since Laravel 13.33.'); + +it('keeps application view names without --module', function () { + $files = $this->generate('make:mail PostPublished --markdown'); + + expect($files['app/Mail/PostPublished.php'])->toContain("markdown: 'mail.post-published'"); +}); + +it('points the application at the module while a generator runs', function () { + $state = static fn (): array => [ + app()->path(), + app()->databasePath(), + app()->configPath(), + app()->getNamespace(), + config('view.paths'), + app(ModuleContext::class)->module(), + ]; + $before = $state(); + $module = Modules::get('blog'); + + $during = app(ModuleContext::class)->run($module, $state); + + expect($during)->toBe([ + $this->path('modules/blog/src'), + $this->path('modules/blog/database'), + $this->path('modules/blog/config'), + 'Modules\\Blog\\', + [$this->path('modules/blog/resources/views')], + $module, + ]) + ->and($state())->toBe($before) + ->and($before[3])->toBe('App\\'); + + expect(fn () => app(ModuleContext::class)->run($module, static fn () => throw new RuntimeException('Generator failed'))) + ->toThrow(RuntimeException::class, 'Generator failed') + ->and($state())->toBe($before); + + Artisan::call('make:model Tag -f --module=blog'); + + expect($state())->toBe($before); +}); + +it('fails for a missing module', function () { + $this->artisan('make:model Tag --module=wiki') + ->expectsOutputToContain('Module [wiki] not found.') + ->assertFailed(); + + expect(file_exists($this->path('app/Models/Tag.php')))->toBeFalse(); +}); + +it('leaves a generator with its own --module option alone', function () { + $command = new class(app('files')) extends GeneratorCommand + { + protected $name = 'make:own-module'; + + protected function getStub(): string + { + return __FILE__; + } + + public function handle(): int + { + $this->line('own module: '.$this->option('module')); + + return self::SUCCESS; + } + + protected function getOptions(): array + { + return [['module', null, InputOption::VALUE_REQUIRED, 'Its own option']]; + } + }; + + ModuleOption::addTo($command); + Artisan::registerCommand($command); + + $this->artisan('make:own-module Thing --module=anything') + ->expectsOutputToContain('own module: anything') + ->assertSuccessful(); + + expect($command->getDefinition()->getOption('module')->getDescription())->toBe('Its own option'); +}); diff --git a/tests/Generators/GeneratorsTestCase.php b/tests/Generators/GeneratorsTestCase.php new file mode 100644 index 0000000..c1b4862 --- /dev/null +++ b/tests/Generators/GeneratorsTestCase.php @@ -0,0 +1,56 @@ + Contents by path relative to the application. + */ + protected function generate(string $command): array + { + $before = $this->applicationFiles(); + + expect(Artisan::call($command))->toBe(0, Artisan::output()); + + $files = []; + + foreach (array_diff($this->applicationFiles(), $before) as $path) { + $files[(string) preg_replace('/\d{4}_\d{2}_\d{2}_\d{6}_/', '{date}_', $path)] = str_replace("\r\n", "\n", (string) file_get_contents($this->path($path))); + } + + return $files; + } + + /** + * @return list + */ + private function applicationFiles(): array + { + return collect(File::allFiles($this->basePath, true)) + ->map(static fn (SplFileInfo $file): string => str_replace('\\', '/', $file->getRelativePathname())) + ->reject(static fn (string $path): bool => str_starts_with($path, 'vendor/')) + ->values() + ->all(); + } +} diff --git a/tests/Generators/ModuleOptionTest.php b/tests/Generators/ModuleOptionTest.php new file mode 100644 index 0000000..649729f --- /dev/null +++ b/tests/Generators/ModuleOptionTest.php @@ -0,0 +1,106 @@ +filter(static fn (object $command): bool => $command instanceof GeneratorCommand + || $command instanceof MigrateMakeCommand + || $command instanceof MigrationGeneratorCommand); + + expect($generators->count())->toBeGreaterThan(40) + ->and($generators->reject(static fn (object $command): bool => $command->getDefinition()->hasOption('module'))->keys()->all())->toBe([]) + ->and($generators->keys()->all())->toContain('make:action', 'make:data', 'make:migration', 'make:queue-table'); +}); + +it('generates into the module', function (string $command, array $expected) { + $files = $this->generate($command.' --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing(array_keys($expected)); + + foreach ($expected as $path => $contains) { + if (str_ends_with($path, '.php') && ! str_ends_with($path, '.blade.php')) { + PhpToken::tokenize($files[$path], TOKEN_PARSE); + } + + expect($files[$path])->toContain(...$contains); + } +})->with([ + 'action' => ['make:action PublishPost', ['modules/blog/src/Actions/PublishPost.php' => ['namespace Modules\\Blog\\Actions;', 'class PublishPost']]], + 'cast' => ['make:cast Money', ['modules/blog/src/Casts/Money.php' => ['namespace Modules\\Blog\\Casts;']]], + 'channel' => ['make:channel PostChannel', ['modules/blog/src/Broadcasting/PostChannel.php' => ['namespace Modules\\Blog\\Broadcasting;']]], + 'class' => ['make:class Support/Slugger', ['modules/blog/src/Support/Slugger.php' => ['namespace Modules\\Blog\\Support;']]], + 'command' => ['make:command SendDigest', ['modules/blog/src/Console/Commands/SendDigest.php' => ['namespace Modules\\Blog\\Console\\Commands;']]], + 'component' => ['make:component Forms/Input', [ + 'modules/blog/src/View/Components/Forms/Input.php' => ['namespace Modules\\Blog\\View\\Components\\Forms;', "view('blog::components.forms.input')"], + 'modules/blog/resources/views/components/forms/input.blade.php' => ['
'], + ]], + 'config' => ['make:config blog-extra', ['modules/blog/config/blog-extra.php' => ['return [']]], + 'controller' => ['make:controller Api/PostController --api --model=Post', ['modules/blog/src/Http/Controllers/Api/PostController.php' => [ + 'namespace Modules\\Blog\\Http\\Controllers\\Api;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'data' => ['make:data PostData', ['modules/blog/src/Data/PostData.php' => ['namespace Modules\\Blog\\Data;']]], + 'enum' => ['make:enum Status', ['modules/blog/src/Status.php' => ['namespace Modules\\Blog;', 'enum Status']]], + 'event' => ['make:event PostPublished', ['modules/blog/src/Events/PostPublished.php' => ['namespace Modules\\Blog\\Events;']]], + 'exception' => ['make:exception PostNotFound', ['modules/blog/src/Exceptions/PostNotFound.php' => ['namespace Modules\\Blog\\Exceptions;']]], + 'factory' => ['make:factory CommentFactory --model=Comment', ['modules/blog/database/factories/CommentFactory.php' => [ + 'namespace Modules\\Blog\\Database\\Factories;', + 'use Modules\\Blog\\Models\\Comment;', + ]]], + 'interface' => ['make:interface Contracts/Publisher', ['modules/blog/src/Contracts/Publisher.php' => ['namespace Modules\\Blog\\Contracts;']]], + 'job' => ['make:job PublishPost', ['modules/blog/src/Jobs/PublishPost.php' => ['namespace Modules\\Blog\\Jobs;']]], + 'job middleware' => ['make:job-middleware RateLimited', ['modules/blog/src/Jobs/Middleware/RateLimited.php' => ['namespace Modules\\Blog\\Jobs\\Middleware;']]], + 'listener' => ['make:listener SendNotification --event=PostPublished', ['modules/blog/src/Listeners/SendNotification.php' => [ + 'namespace Modules\\Blog\\Listeners;', + 'use Modules\\Blog\\Events\\PostPublished;', + ]]], + 'mail' => ['make:mail PostPublished --markdown', [ + 'modules/blog/src/Mail/PostPublished.php' => ['namespace Modules\\Blog\\Mail;', "markdown: 'blog::mail.post-published'"], + 'modules/blog/resources/views/mail/post-published.blade.php' => [''], + ]], + 'mail with a view' => ['make:mail PostPublished --view=emails.post', [ + 'modules/blog/src/Mail/PostPublished.php' => ["view: 'blog::emails.post'"], + 'modules/blog/resources/views/emails/post.blade.php' => ['
'], + ]], + 'middleware' => ['make:middleware EnsureAuthor', ['modules/blog/src/Http/Middleware/EnsureAuthor.php' => ['namespace Modules\\Blog\\Http\\Middleware;']]], + 'migration' => ['make:migration create_comments_table', ['modules/blog/database/migrations/{date}_create_comments_table.php' => ["Schema::create('comments'"]]], + 'model' => ['make:model Tag', ['modules/blog/src/Models/Tag.php' => ['namespace Modules\\Blog\\Models;', 'class Tag extends Model']]], + 'notification' => ['make:notification PostPublished --markdown=notifications.post', [ + 'modules/blog/src/Notifications/PostPublished.php' => ['namespace Modules\\Blog\\Notifications;', "->markdown('blog::notifications.post')"], + 'modules/blog/resources/views/notifications/post.blade.php' => [''], + ]], + 'observer' => ['make:observer PostObserver --model=Post', ['modules/blog/src/Observers/PostObserver.php' => [ + 'namespace Modules\\Blog\\Observers;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'policy' => ['make:policy PostPolicy --model=Post', ['modules/blog/src/Policies/PostPolicy.php' => [ + 'namespace Modules\\Blog\\Policies;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'request' => ['make:request StorePostRequest', ['modules/blog/src/Http/Requests/StorePostRequest.php' => ['namespace Modules\\Blog\\Http\\Requests;']]], + 'resource' => ['make:resource PostCollection --collection', ['modules/blog/src/Http/Resources/PostCollection.php' => ['namespace Modules\\Blog\\Http\\Resources;']]], + 'rule' => ['make:rule Slug', ['modules/blog/src/Rules/Slug.php' => ['namespace Modules\\Blog\\Rules;']]], + 'scope' => ['make:scope PublishedScope', ['modules/blog/src/Models/Scopes/PublishedScope.php' => ['namespace Modules\\Blog\\Models\\Scopes;']]], + 'seeder' => ['make:seeder Deployment/TagSeeder_3', ['modules/blog/database/seeders/Deployment/TagSeeder_3.php' => [ + 'namespace Modules\\Blog\\Database\\Seeders\\Deployment;', + 'class TagSeeder_3 extends Seeder', + ]]], + 'feature test' => ['make:test Api/PostTest --phpunit', ['modules/blog/tests/Feature/Api/PostTest.php' => [ + 'namespace Modules\\Blog\\Tests\\Feature\\Api;', + 'use Tests\\TestCase;', + ]]], + 'unit test' => ['make:test PostTest --unit --phpunit', ['modules/blog/tests/Unit/PostTest.php' => ['namespace Modules\\Blog\\Tests\\Unit;']]], + 'pest test' => ['make:test PostTest --pest', ['modules/blog/tests/Feature/PostTest.php' => ["test('example'"]]], + 'trait' => ['make:trait Concerns/HasSlug', ['modules/blog/src/Concerns/HasSlug.php' => ['namespace Modules\\Blog\\Concerns;', 'trait HasSlug']]], + 'view' => ['make:view posts.show --test --phpunit', [ + 'modules/blog/resources/views/posts/show.blade.php' => ['
'], + 'modules/blog/tests/Feature/View/Posts/ShowTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\View\\Posts;', "\$this->view('blog::posts.show'"], + ]], + 'queue table' => ['make:queue-table', ['modules/blog/database/migrations/{date}_create_jobs_table.php' => ["Schema::create('jobs'"]]], + 'session table' => ['make:session-table', ['modules/blog/database/migrations/{date}_create_sessions_table.php' => ["Schema::create('sessions'"]]], +]); diff --git a/tests/Integration/OctaneTest.php b/tests/Integration/OctaneTest.php new file mode 100644 index 0000000..8102dc9 --- /dev/null +++ b/tests/Integration/OctaneTest.php @@ -0,0 +1,220 @@ + */ + public array $statuses = []; + + /** @var array */ + public array $contents = []; + + /** @var list */ + public array $errors = []; + + public function marshalRequest(RequestContext $context): array + { + return [$context->request, $context]; + } + + public function respond(RequestContext $context, OctaneResponse $response): void + { + $status = $response->response->getStatusCode(); + $this->statuses[$status] = ($this->statuses[$status] ?? 0) + 1; + $this->contents[$context->request->getPathInfo()] = (string) $response->response->getContent(); + } + + public function error(Throwable $e, Application $app, Request $request, RequestContext $context): void + { + $this->errors[] = $e->getMessage(); + } +} + +/** + * An application in a temporary directory, with or without the modules provider. Without it, + * the application serves the same routes from its own route files. + */ +function octaneApplication(bool $modules): string +{ + $basePath = TemporaryDirectory::create(__DIR__.'/../Fixtures/app'); + $providers = $modules ? '[Laravel\Octane\OctaneServiceProvider::class, '.ModulesServiceProvider::class.'::class]' : '[Laravel\Octane\OctaneServiceProvider::class]'; + $routes = $modules ? '' : <<<'PHP' + ->withRouting(function () { + Illuminate\Support\Facades\Route::middleware('api')->prefix('api')->group(function () { + Illuminate\Support\Facades\Route::get('posts', fn () => 'blog posts'); + Illuminate\Support\Facades\Route::get('v1/feed', fn () => 'blog feed v1'); + Illuminate\Support\Facades\Route::get('v1/admin/stats', fn () => 'blog stats'); + }); + Illuminate\Support\Facades\Route::middleware('web')->group(function () { + Illuminate\Support\Facades\Route::get('blog', fn () => "Blog: Welcome to the blog\n"); + Illuminate\Support\Facades\Route::get('orders', fn () => 'orders'); + }); + }) + PHP; + + file_put_contents($basePath.'/.env', "APP_KEY=base64:2fl+Ktvkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10=\nSESSION_DRIVER=array\nCACHE_STORE=array\n"); + file_put_contents($basePath.'/bootstrap/app.php', <<withProviders({$providers}) + {$routes} + ->withMiddleware() + ->withExceptions() + ->create(); + PHP); + + return $basePath; +} + +function octaneRequest(Worker $worker, string $uri): void +{ + $request = Request::create($uri); + + $worker->handle($request, new RequestContext(['request' => $request])); +} + +/** + * Memory kept by the worker after serving the requests. + * + * @param list $uris + */ +function retainedMemory(Worker $worker, array $uris, int $requests): int +{ + gc_collect_cycles(); + $before = memory_get_usage(); + + for ($i = 0; $i < $requests; $i++) { + octaneRequest($worker, $uris[$i % count($uris)]); + } + + gc_collect_cycles(); + + return memory_get_usage() - $before; +} + +beforeEach(function () { + // What vendor/laravel/octane/bin/bootstrap.php does. + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + ModuleRepository::flushState(); + $this->basePaths = [$workingDirectory = TemporaryDirectory::create()]; + $this->workingDirectory = (string) getcwd(); + chdir($workingDirectory); +}); + +afterEach(function () { + unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + ModuleRepository::flushState(); + HandleExceptions::flushState($this); + Facade::clearResolvedInstances(); + Container::setInstance(null); + chdir($this->workingDirectory); + array_map(TemporaryDirectory::delete(...), $this->basePaths); +}); + +it('serves module routes with the state of the first request', function () { + $client = new StatusClient; + $worker = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)), $client); + $worker->boot(); + + $snapshots = []; + $worker->onRequestHandled(static function (Request $request, $response, Application $sandbox) use (&$snapshots): void { + $snapshots[] = [ + count($sandbox['router']->getRoutes()), + $sandbox['view']->getFinder()->getHints(), + $sandbox['translation.loader']->namespaces(), + $sandbox['translation.loader']->jsonPaths(), + $sandbox['config']->get('blog'), + $sandbox['config']->get('tinker.alias'), + $sandbox->runningInConsole(), + ]; + }); + + for ($i = 0; $i < 500; $i++) { + octaneRequest($worker, MODULE_URIS[$i % count(MODULE_URIS)]); + } + + expect($client->errors)->toBe([]) + ->and($client->statuses)->toBe([200 => 500]) + ->and($client->contents)->toBe([ + '/api/posts' => 'blog posts', + '/api/v1/feed' => 'blog feed v1', + '/api/v1/admin/stats' => 'blog stats', + '/blog' => "Blog: Welcome to the blog\n", + '/orders' => 'orders', + ]) + ->and(array_unique($snapshots, SORT_REGULAR))->toHaveCount(1) + ->and($snapshots[0][1])->toMatchArray(['blog' => [$this->basePaths[1].'/modules/blog/resources/views']]) + ->and($snapshots[0][3])->toBe([$this->basePaths[1].'/modules/blog/lang']) + ->and($snapshots[0][4])->toBe(['title' => 'Blog', 'per_page' => 15]) + ->and($snapshots[0][5])->toBeNull() + ->and($snapshots[0][6])->toBeFalse(); + + $worker->terminate(); +}); + +it('keeps no more memory than the same routes of the application', function () { + $application = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: false)), $applicationClient = new StatusClient); + $application->boot(); + $modules = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)), $modulesClient = new StatusClient); + $modules->boot(); + + // Laravel and Octane themselves keep some memory per request, so the workers are compared. + retainedMemory($application, MODULE_URIS, 200); + retainedMemory($modules, MODULE_URIS, 200); + $retained = ['application' => 0, 'modules' => 0]; + + for ($round = 0; $round < 3; $round++) { + $retained['application'] += retainedMemory($application, MODULE_URIS, 500); + $retained['modules'] += retainedMemory($modules, MODULE_URIS, 500); + } + + expect($applicationClient->statuses)->toBe([200 => 1700]) + ->and($modulesClient->statuses)->toBe([200 => 1700]) + ->and($modulesClient->contents)->toBe($applicationClient->contents) + ->and($retained['modules'])->toBeLessThan($retained['application'] + 32 * 1024); + + $application->terminate(); + $modules->terminate(); +}); + +it('boots the application again in the same process', function () { + $factory = new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)); + $routes = static fn (Application $app): array => array_map( + static fn ($route): string => implode('|', $route->methods()).' '.$route->uri(), + $app['router']->getRoutes()->getRoutes(), + ); + + $first = $factory->createApplication(); + $second = $factory->createApplication(); + + expect($routes($second))->toBe($routes($first)) + ->and($routes($first))->toContain('GET|HEAD api/posts', 'GET|HEAD blog', 'GET|HEAD orders') + ->and($routes($first))->toBe(array_values(array_unique($routes($first)))) + ->and($second['config']->get('blog'))->toBe($first['config']->get('blog')); +}); diff --git a/tests/ModuleTest.php b/tests/ModuleTest.php deleted file mode 100644 index 1b49a92..0000000 --- a/tests/ModuleTest.php +++ /dev/null @@ -1,272 +0,0 @@ -createModule(['packageName' => 'some-vendor/testing-module'])->getPackageName())->toBe('some-vendor/testing-module'); - expect($this->createModule(['packageName' => 'testing-module'])->getPackageName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getPackageName())->toBe('some-vendor/module'); -}); - -it('can return the name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getName())->toBe('TestingModule'); - expect($this->createModule(['name' => ' SomeModule '])->getName())->toBe('SomeModule'); - expect($this->createModule(['name' => ' some-module '])->getName())->toBe('some-module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getName())->toBe('testing-module'); -}); - -it('can return the studly name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getStudlyName())->toBe('TestingModule'); - expect($this->createModule(['name' => ' SomeModule '])->getStudlyName())->toBe('SomeModule'); - expect($this->createModule(['name' => ' some-module '])->getStudlyName())->toBe('SomeModule'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getStudlyName())->toBe('TestingModule'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getStudlyName())->toBe('Module'); - expect($this->createModule(['packageName' => 'testing-module'])->getStudlyName())->toBe('TestingModule'); -}); - -it('can return the kebab name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getKebabName())->toBe('testing-module'); - expect($this->createModule(['name' => ' SomeModule '])->getKebabName())->toBe('some-module'); - expect($this->createModule(['name' => ' some-module '])->getKebabName())->toBe('some-module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getKebabName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getKebabName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getKebabName())->toBe('testing-module'); -}); - -it('can return the snake name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getSnakeName())->toBe('testing_module'); - expect($this->createModule(['name' => ' SomeModule '])->getSnakeName())->toBe('some_module'); - expect($this->createModule(['name' => ' some-module '])->getSnakeName())->toBe('some_module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getSnakeName())->toBe('testing_module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getSnakeName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getSnakeName())->toBe('testing_module'); -}); - -it('can return the path', function () { - expect($this->createModule([ - 'path' => $this->app->basePath('/modules/SomeTestingModule'), - ])->getPath())->toBe($this->app->basePath('/modules/SomeTestingModule')); -}); - -it('can return the namespace', function () { - expect($this->createModule(['namespace' => 'Some\\TestingModule\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => 'Some\\TestingModule\\\\\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => '\\\\Some\\TestingModule'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => '\\\\Some\\TestingModule\\\\\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); -}); - -it('can return providers', function () { - expect($this->createModule([ - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - ])->getProviders())->toBe([ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ]); -}); - -it('can return aliases', function () { - expect($this->createModule([ - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ])->getAliases())->toBe([ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ]); -}); - -it('can make sub path', function () { - expect($this->createModule(['path' => $this->app->basePath('/modules/SomeTestingModule')]) - ->subPath('resources/views/index.blade.php')) - ->toBe($this->app->basePath('/modules/SomeTestingModule/resources/views/index.blade.php')); - - expect($this->createModule(['path' => $this->app->basePath('/modules/SomeTestingModule')]) - ->subPath('///resources/views/index.blade.php')) - ->toBe($this->app->basePath('/modules/SomeTestingModule/resources/views/index.blade.php')); -}); - -it('can make sub namespace', function () { - expect($this->createModule(['namespace' => 'SomeVendor\\SomeTestingModule']) - ->subNamespace('Models\\SomeModel')) - ->toBe('SomeVendor\\SomeTestingModule\\Models\\SomeModel'); - - expect($this->createModule(['namespace' => 'SomeVendor\\SomeTestingModule']) - ->subNamespace('\\\\Models\\SomeModel\\\\')) - ->toBe('SomeVendor\\SomeTestingModule\\Models\\SomeModel'); -}); - -it('can return module as array', function () { - expect($this->createModule([ - 'path' => $this->app->basePath('modules/TestingModule'), - 'packageName' => 'some-vendor/testing-module', - 'name' => 'TestingModule', - 'namespace' => '\\\\\\SomeVendor\\TestingModule\\\\', - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ])->toArray())->toBe([ - 'path' => $this->app->basePath('modules/TestingModule'), - 'packageName' => 'some-vendor/testing-module', - 'name' => 'TestingModule', - 'namespace' => 'SomeVendor\\TestingModule', - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ]); -}); - -it('can update providers via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->updateProviders($module, [ - 'Modules\\Author\\Providers\\FooServiceProvider', - 'Modules\\Foo\\Providers\\BarServiceProvider', - ]); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can update aliases via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->updateAliases($module, [ - 'foo' => 'Modules\\Author\\Services\\Foo', - 'bar' => 'Modules\\Bar\\Services\\Bar', - ]); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can add providers via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->addProvider($module, 'Modules\\Author\\Providers\\FooServiceProvider'); - $configWriter->addProvider($module, 'Modules\\Author\\Providers\\RouteServiceProvider'); // duplicate, should not be added - $configWriter->addProvider($module, 'Modules\\Foo\\Providers\\BarServiceProvider'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can add aliases via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->addAlias($module, 'foo', 'Modules\\Author\\Services\\Foo'); - $configWriter->addAlias($module, 'bar', 'Modules\\Bar\\Services\\Bar'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); diff --git a/tests/ModulesRepositoryTest.php b/tests/ModulesRepositoryTest.php deleted file mode 100644 index 85ea62b..0000000 --- a/tests/ModulesRepositoryTest.php +++ /dev/null @@ -1,476 +0,0 @@ -modulesManifestPath = $this->app->bootstrapPath('cache/testing-laraneat-modules.php'); - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); -}); - -afterEach(function () { - $this->filesystem->delete($this->modulesManifestPath); -}); - -describe('scan paths', function () { - it('sets the initial scan paths from the configuration', function () { - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - ]); - }); - - it('can add one scan path', function () { - $this->repository->addScanPath($this->app->basePath('/foo_bar')); - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo_bar/*'), - ]); - }); - - it('can add array of scan paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/foo/bar/test folder'), - $this->app->basePath('/foo/test/'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo/bar/test folder/*'), - $this->app->basePath('/foo/test/*'), - ]); - }); - - it('normalizes the added paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/foo/bar/some1////'), - $this->app->basePath('/foo/bar/some2'), - $this->app->basePath('/foo/bar/some3/*'), - $this->app->basePath('/foo/bar/some4/*/'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo/bar/some1/*'), - $this->app->basePath('/foo/bar/some2/*'), - $this->app->basePath('/foo/bar/some3/*'), - $this->app->basePath('/foo/bar/some4/*'), - ]); - }); - - it('rejects adding a path that is already in the scan paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/modules'), - $this->app->basePath('/modules/'), - $this->app->basePath('/modules////'), - $this->app->basePath('/modules/*'), - $this->app->basePath('/modules/Nested'), - $this->app->basePath('/modules/Nested/'), - $this->app->basePath('/modules/Nested////'), - $this->app->basePath('/modules/Nested/*'), - $this->app->basePath('/foo/test/'), - $this->app->basePath('/foo/test////'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/modules/Nested/*'), - $this->app->basePath('/foo/test/*'), - ]); - }); -}); - -describe('modules manifest', function () { - it('can build modules manifest', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $expectedManifest = [ - 'laraneat/article-category' => [ - 'path' => $this->app->basePath('/modules/article-category'), - 'name' => 'article-category', - 'namespace' => 'Modules\\ArticleCategory\\', - 'providers' => [ - 'Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider', - 'Modules\\ArticleCategory\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'name' => 'article', - 'namespace' => 'Modules\\Article\\', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/author' => [ - 'path' => $this->app->basePath('/modules/author'), - 'name' => 'author', - 'namespace' => 'Modules\\Author\\', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ], - 'laraneat/empty' => [ - 'path' => $this->app->basePath('/modules/empty-module'), - 'name' => 'empty-module', - 'namespace' => 'Modules\\Empty\\', - 'providers' => [], - 'aliases' => [], - ], - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation\\', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect($this->repository->buildModulesManifest()) - ->toBe($expectedManifest) - ->and($this->modulesManifestPath)->toBeFile(); - }); - - it('throws an exception when modules have the same package names', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-copy', - ]); - - $this->repository->buildModulesManifest(); - })->throws(ModuleHasNonUniquePackageName::class); - - it('can prune modules manifest', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->repository->buildModulesManifest(); - expect($this->modulesManifestPath)->toBeFile(); - $this->repository->pruneModulesManifest(); - expect($this->modulesManifestPath)->not->toBeFile(); - }); -}); - -it('can return modules as array', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->toArray())->toBe([ - 'laraneat/article-category' => [ - 'path' => $this->app->basePath('/modules/article-category'), - 'packageName' => 'laraneat/article-category', - 'name' => 'article-category', - 'namespace' => 'Modules\\ArticleCategory', - 'providers' => [ - 'Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider', - 'Modules\\ArticleCategory\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/author' => [ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ], - 'laraneat/empty' => [ - 'path' => $this->app->basePath('/modules/empty-module'), - 'packageName' => 'laraneat/empty', - 'name' => 'empty-module', - 'namespace' => 'Modules\\Empty', - 'providers' => [], - 'aliases' => [], - ], - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]); -}); - -it('can check the module for existence', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->has('laraneat/foo'))->toBe(false) - ->and($this->repository->has('laraneat/article'))->toBe(true) - ->and($this->repository->has('laraneat/author'))->toBe(true) - ->and($this->repository->has('laraneat/location'))->toBe(true) - ->and($this->repository->has('laraneat/navigation'))->toBe(false) - ->and($this->repository->has('laraneat/book'))->toBe(false) - ->and($this->repository->has('laraneat/author/some'))->toBe(false) - ->and($this->repository->has('laraneat'))->toBe(false); -}); - -it('can count the number of modules', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->count())->toBe(5); -}); - -it('can find a module', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->find('laraneat/article')?->toArray())->toBe([ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ]) - ->and($this->repository->find('laraneat')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/article/bar')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/location')?->toArray())->toBe([ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ]) - ->and($this->repository->find('laraneat/navigation')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/book')?->toArray())->toBe(null); -}); - -it('throws an exception when the module is not found', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->repository->findOrFail('laraneat/book'); -})->throws(ModuleNotFound::class); - -it('throws an exception when trying to remove a module that does not exist', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->delete('laraneat/book')); -})->throws(ModuleNotFound::class); - -it('can filter modules by name', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $articleModuleMatch = [ - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - $locationModuleMatch = [ - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect(collect($this->repository->filterByName('article'))->toArray())->toBe($articleModuleMatch) - ->and(collect($this->repository->filterByName('Article'))->toArray())->toBe($articleModuleMatch) - ->and(collect($this->repository->filterByName('ARTICLE'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('aarticle'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('location'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('navigation'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('Navigation'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('GeoLocation'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('foo'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('laraneat'))->toArray())->toBe([]); - -}); - -it('throws an exception when modules with the requested name are not found', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $articleModuleMatch = [ - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect(collect($this->repository->filterByNameOrFail('article'))->toArray())->toBe($articleModuleMatch); - expect(fn () => $this->repository->filterByNameOrFail('book'))->toThrow(ModuleNotFound::class); -}); - -it('can delete a module', function () { - // Set mock BEFORE setModules, because Module instances get Composer from container - $this->instance(Composer::class, $this->mockComposer(['removePackages' => true])); - - // Recreate repository with mocked Composer - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); - - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->has('laraneat/article'))->toBe(true) - ->and($this->repository->delete('laraneat/article'))->toBe(true) - ->and($this->repository->has('laraneat/article'))->toBe(false); -}); - -it('can sync modules with composer', function () { - // Set mock BEFORE setModules, because repository uses Composer from container - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - - // Recreate repository with mocked Composer - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); - - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->backupComposerJson(); - $composerJsonPath = $this->app->basePath('/composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - $this->repository->syncWithComposer(); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - $this->resetComposerJson(); -}); diff --git a/tests/Pest.php b/tests/Pest.php index 8b26e9c..b5af131 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,55 +1,12 @@ - */ -function getRelativeFilePathsInDirectory(string $directory): array -{ - $filesystem = new Illuminate\Filesystem\Filesystem(); - - return array_map(fn (SplFileInfo $fileInfo) => $fileInfo->getRelativePathname(), $filesystem->allFiles($directory)); -} - -/** - * @param array $paths - * @param string $basePath - * @return array - */ -function makeAbsolutePaths(array $paths, string $basePath): array -{ - $basePath = rtrim($basePath, '\\/'); - - return array_map(fn (string $path) => realpath($basePath . '/' . $path), $paths); -} - -function assertsMatchesDirectorySnapshot(string $directory): void -{ - expect($directory)->toBeDirectory(); - $filePaths = getRelativeFilePathsInDirectory($directory); - assertMatchesSnapshot($filePaths); - foreach (makeAbsolutePaths($filePaths, $directory) as $filePath) { - assertMatchesFileSnapshot($filePath); - } -} - -uses(Laraneat\Modules\Tests\TestCase::class) - ->afterEach(function () { - /** @var \Laraneat\Modules\Tests\TestCase $this */ - $this->pruneModulesPaths(); - $this->resetComposerJson(); - }) - ->in('.'); +pest()->extend(TestCase::class)->in('Feature'); +pest()->extend(UnitTestCase::class)->in('Unit'); +pest()->extend(PHPUnit\Framework\TestCase::class)->in('Integration'); +pest()->extend(GeneratorsTestCase::class)->in('Generators'); diff --git a/tests/Support/ComposerJsonFileTest.php b/tests/Support/ComposerJsonFileTest.php deleted file mode 100644 index 889c295..0000000 --- a/tests/Support/ComposerJsonFileTest.php +++ /dev/null @@ -1,85 +0,0 @@ - $content - * @return array - */ -function addModuleToComposerJson(array $content, string $packageName = 'app/blog', int $times = 1): array -{ - $path = sys_get_temp_dir() . '/laraneat-composer-' . uniqid() . '.json'; - file_put_contents($path, json_encode($content, JSON_PRETTY_PRINT)); - - try { - for ($i = 0; $i < $times; $i++) { - ComposerJsonFile::create($path)->addModule($packageName, 'modules/blog')->save(); - } - - return json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); - } finally { - @unlink($path); - } -} - -it('adds the module, its path repository and excludes its vendor from packagist', function () { - $composerJson = addModuleToComposerJson(['name' => 'demo/app', 'require' => ['php' => '^8.3']]); - - expect($composerJson['require'])->toBe(['php' => '^8.3', 'app/blog' => '*']) - ->and($composerJson['repositories'])->toBe([ - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - ['packagist.org' => false], - ]); -}); - -it('is idempotent', function () { - $once = addModuleToComposerJson(['name' => 'demo/app']); - $twice = addModuleToComposerJson(['name' => 'demo/app'], times: 2); - - expect($twice)->toBe($once); -}); - -it('extends an existing packagist repository instead of adding another one', function () { - $composerJson = addModuleToComposerJson([ - 'repositories' => [ - ['type' => 'composer', 'url' => 'https://repo.packagist.org/', 'exclude' => ['other/*']], - ['packagist.org' => false], - ], - ]); - - expect($composerJson['repositories'])->toBe([ - ['type' => 'composer', 'url' => 'https://repo.packagist.org/', 'exclude' => ['other/*', 'app/*']], - ['packagist.org' => false], - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ]); -}); - -it('leaves a packagist repository restricted with "only" untouched', function () { - $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; - $composerJson = addModuleToComposerJson(['repositories' => [$repository, ['packagist.org' => false]]]); - - expect($composerJson['repositories'][0])->toBe($repository); -}); - -it('does not re-enable packagist when it is disabled', function () { - $composerJson = addModuleToComposerJson(['repositories' => [['packagist.org' => false]]]); - - expect($composerJson['repositories'])->toBe([ - ['packagist.org' => false], - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ]); -}); - -it('supports repositories defined as an object', function () { - $composerJson = addModuleToComposerJson([ - 'repositories' => ['private' => ['type' => 'composer', 'url' => 'https://repo.example.com']], - ]); - - expect($composerJson['repositories'])->toBe([ - 'private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], - 0 => ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - 'packagist' => ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - 'packagist.org' => false, - ]); -}); diff --git a/tests/Support/ComposerTest.php b/tests/Support/ComposerTest.php deleted file mode 100644 index 1dea8d7..0000000 --- a/tests/Support/ComposerTest.php +++ /dev/null @@ -1,59 +0,0 @@ -> */ - public array $commands = []; - - protected function getProcess(array $command, array $env = []) - { - $this->commands[] = $command; - - return new Process([PHP_BINARY, '-r', 'exit(0);']); - } - }; -} - -it('passes package names after "--" so they can never be parsed as options', function (string $method, string $action) { - $composer = recordingComposer(); - - expect($composer->{$method}(['vendor/module-a', 'vendor/module-b']))->toBeTrue() - ->and(array_slice($composer->commands[0], -4))->toBe([$action, '--', 'vendor/module-a', 'vendor/module-b']); -})->with([ - ['updatePackages', 'update'], - ['removePackages', 'remove'], -]); - -it('rejects invalid package names without running composer', function (string $packageName) { - $composer = recordingComposer(); - - expect(fn () => $composer->updatePackages([$packageName]))->toThrow(ComposerException::class) - ->and(fn () => $composer->removePackages([$packageName]))->toThrow(ComposerException::class) - ->and($composer->commands)->toBe([]); -})->with([ - '--no-plugins', - '-vvv/module', - 'vendor', - 'Vendor/Module', - 'vendor/module name', - 'vendor/../module', - 'evil","scripts":{"x":"y"},"a":"/blog', -]); - -it('validates package names with the composer rules', function (string $packageName, bool $valid) { - expect(Composer::isValidPackageName($packageName))->toBe($valid); -})->with([ - ['laraneat/article', true], - ['my-vendor/article-category', true], - ['vendor.name/module_name', true], - ['vendor/module--double', true], - ['vendor/module-', false], - ['vendor/Module', false], - ["vendor/module\n", false], -]); diff --git a/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php b/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php deleted file mode 100644 index 653fded..0000000 --- a/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php +++ /dev/null @@ -1,41 +0,0 @@ -routesDirectory = sys_get_temp_dir() . '/laraneat-routes-' . uniqid(); - @mkdir($this->routesDirectory . '/v1', 0777, true); - - file_put_contents($this->routesDirectory . '/list_articles.php', " 'ok')->name('articles.list');\n"); - file_put_contents($this->routesDirectory . '/v1/view_article.php', " 'ok')->name('articles.view');\n"); - file_put_contents($this->routesDirectory . '/list_articles.php.orig', " 'ok')->name('articles.old');\n"); - file_put_contents($this->routesDirectory . '/README.md', 'This text must never be echoed.'); -}); - -afterEach(function () { - $this->app['files']->deleteDirectory($this->routesDirectory); -}); - -it('loads route files and prefixes nested directories', function () { - $loader = new class () { - use CanLoadRoutesFromDirectory; - - public function load(string $directory): void - { - $this->loadRoutesFromDirectory($directory); - } - }; - - ob_start(); - Route::prefix('api')->group(fn () => $loader->load($this->routesDirectory)); - $output = ob_get_clean(); - - $routes = Route::getRoutes(); - $routes->refreshNameLookups(); - - expect($output)->toBe('') - ->and($routes->getByName('articles.list')?->uri())->toBe('api/articles') - ->and($routes->getByName('articles.view')?->uri())->toBe('api/v1/articles/{id}') - ->and($routes->getByName('articles.old'))->toBeNull(); -}); diff --git a/tests/Support/Concerns/CanRunModuleSeedersTest.php b/tests/Support/Concerns/CanRunModuleSeedersTest.php deleted file mode 100644 index 062b6a4..0000000 --- a/tests/Support/Concerns/CanRunModuleSeedersTest.php +++ /dev/null @@ -1,82 +0,0 @@ - */ - public array $ranSeeders = []; - - public function call($class, $silent = false, array $parameters = []) - { - $this->ranSeeders[] = $class; - - return $this; - } - }; -} - -beforeEach(function () { - $this->setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); - - $author = $this->app->basePath('/modules/author/database/seeders'); - writeSeeder("$author/AuthorPermissionsSeeder_1.php", 'Modules\\Author\\Database\\Seeders', 'AuthorPermissionsSeeder_1'); - writeSeeder("$author/AuthorSeeder_10.php", 'Modules\\Author\\Database\\Seeders', 'AuthorSeeder_10'); - writeSeeder("$author/AuthorDataSeeder.php", 'Modules\\Author\\Database\\Seeders', 'AuthorDataSeeder'); - writeSeeder("$author/Deployment/AuthorDeploySeeder_2.php", 'Modules\\Author\\Database\\Seeders\\Deployment', 'AuthorDeploySeeder_2'); - writeSeeder("$author/Testing/AuthorTestingSeeder_3.php", 'Modules\\Author\\Database\\Seeders\\Testing', 'AuthorTestingSeeder_3'); - file_put_contents("$author/README.md", 'not a seeder'); - file_put_contents("$author/AuthorOldSeeder_1.php.orig", 'app->basePath('/modules/navigation/database/seeders'); - writeSeeder("$navigation/NavigationPermissionsSeeder_1.php", 'Modules\\Navigation\\Database\\Seeders', 'NavigationPermissionsSeeder_1'); - writeSeeder("$navigation/NavigationSeeder_2.php", 'Modules\\Navigation\\Database\\Seeders', 'NavigationSeeder_2'); -}); - -it('runs root seeders of all modules ordered by their integer suffix', function () { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules(); - - expect($seeder->ranSeeders)->toBe([ - 'Modules\\Author\\Database\\Seeders\\AuthorPermissionsSeeder_1', - 'Modules\\Navigation\\Database\\Seeders\\NavigationPermissionsSeeder_1', - 'Modules\\Navigation\\Database\\Seeders\\NavigationSeeder_2', - 'Modules\\Author\\Database\\Seeders\\AuthorSeeder_10', - 'Modules\\Author\\Database\\Seeders\\AuthorDataSeeder', - ]); -}); - -it('includes every requested subdirectory, not only the first one', function (array $subdirectories) { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules($subdirectories); - - expect($seeder->ranSeeders) - ->toContain('Modules\\Author\\Database\\Seeders\\Deployment\\AuthorDeploySeeder_2') - ->toContain('Modules\\Author\\Database\\Seeders\\Testing\\AuthorTestingSeeder_3') - ->toContain('Modules\\Author\\Database\\Seeders\\AuthorPermissionsSeeder_1') - ->and($seeder->ranSeeders)->toHaveCount(7) - ->and(array_unique($seeder->ranSeeders))->toHaveCount(7); -})->with([ - 'without root' => [['Deployment', 'Testing']], - 'with root' => [['/', '/Deployment', '/Testing']], -]); - -it('ignores files that are not php files', function () { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules(); - - expect($seeder->ranSeeders)->not->toContain('Old\\AuthorOldSeeder_1'); -}); diff --git a/tests/Support/Generator/GeneratorHelperTest.php b/tests/Support/Generator/GeneratorHelperTest.php deleted file mode 100644 index e9bda89..0000000 --- a/tests/Support/Generator/GeneratorHelperTest.php +++ /dev/null @@ -1,134 +0,0 @@ -toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some\\Actions/'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions///'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src/some/Actions\\'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src\\some/Actions////'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\src\\some\\Actions'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\\\src\\some\\Actions\\\\'))->toBe('src/some/Actions'); - }); - - it('correctly normalizes path using rtrim', function () { - expect(GeneratorHelper::normalizePath('src/some/Actions', true))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some\\Actions/', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions///', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src/some/Actions\\', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src\\some/Actions////', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\src\\some\\Actions', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\\\src\\some\\Actions\\\\', true))->toBe('/src/some/Actions'); - }); -}); - -describe('normalizeNamespace()', function () { - it('correctly normalizes namespace', function () { - expect(GeneratorHelper::normalizeNamespace('src/some/Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some\\Actions/'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions///'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src/some/Actions\\'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src\\some/Actions////'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\src\\some\\Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\\\src\\some\\Actions\\\\'))->toBe('src\\some\\Actions'); - }); - - it('correctly normalizes namespace using rtrim', function () { - expect(GeneratorHelper::normalizeNamespace('src/some/Actions', true))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some\\Actions/', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions///', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src/some/Actions\\', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src\\some/Actions////', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\src\\some\\Actions', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\\\src\\some\\Actions\\\\', true))->toBe('\\src\\some\\Actions'); - }); -}); - -describe('makeRelativePath()', function () { - it('correctly make relative', function () { - expect(GeneratorHelper::makeRelativePath('', ''))->toBe(''); - expect(GeneratorHelper::makeRelativePath('/', ''))->toBe(null); - expect(GeneratorHelper::makeRelativePath('', '/'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some/foo/bar'))->toBe(''); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar', 'src/some/foo/bar'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', '/src/some/foo/bar'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some'))->toBe('../..'); - expect(GeneratorHelper::makeRelativePath('src/some', 'src/some/foo/bar'))->toBe('foo/bar'); - expect(GeneratorHelper::makeRelativePath('/src/some', '/src/some/foo/bar'))->toBe('foo/bar'); - expect(GeneratorHelper::makeRelativePath('src/some/bar', 'src/some/foo/bar'))->toBe('../foo/bar'); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some/bar'))->toBe('../../bar'); - expect(GeneratorHelper::makeRelativePath('src/some/bar/baz', 'src/some/foo/bar/baz'))->toBe('../../foo/bar/baz'); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar/baz', 'src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar/baz', '/src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar/baz', '////src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/////src/some/foo/bar/baz', '/src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('foo/bar/baz', 'src/some/bar/baz'))->toBe(null); - }); -}); - -describe('getBasePath()', function () { - it('returns base path', function () { - $this->app['config']->set('modules.path', $this->app->basePath('/foo/bar')); - - expect(GeneratorHelper::getBasePath())->toBe($this->app->basePath('/foo/bar')); - - $this->app['config']->set('modules.path', $this->app->basePath('/foo/bar/') . '///'); - - expect(GeneratorHelper::getBasePath())->toBe($this->app->basePath('/foo/bar')); - }); - - it('throws an error when the modules path is not defined', function () { - $this->app['config']->set('modules.path', ''); - - expect(fn () => GeneratorHelper::getBasePath())->toThrow(InvalidConfigValue::class); - }); -}); - -describe('getBaseNamespace()', function () { - it('returns base namespace', function () { - $this->app['config']->set('modules.namespace', '\\Foo\\Bar\\'); - - expect(GeneratorHelper::getBaseNamespace())->toBe('Foo\\Bar'); - }); - - it('throws an error when the modules namespace is not defined', function () { - $this->app['config']->set('modules.namespace', ''); - - expect(fn () => GeneratorHelper::getBaseNamespace())->toThrow(InvalidConfigValue::class); - }); -}); - -describe('component(ModuleComponentType $componentType)', function () { - it('returns component config by type', function () { - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, [ - 'path' => '///Some/Actions///', - ]); - - expect(GeneratorHelper::component(ModuleComponentType::Action)->getPath())->toBe('Some/Actions'); - expect(GeneratorHelper::component(ModuleComponentType::Action)->getNamespace())->toBe('Some\\Actions'); - - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, [ - 'path' => '///src/Some/Actions///', - 'namespace' => '\\\\\\Some\\Actions\\\\\\', - ]); - - expect(GeneratorHelper::component(ModuleComponentType::Action)->getPath())->toBe('src/Some/Actions'); - expect(GeneratorHelper::component(ModuleComponentType::Action)->getNamespace())->toBe('Some\\Actions'); - }); - - it('throws an error when the component has invalid config', function () { - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, 'foo'); - expect(fn () => GeneratorHelper::component(ModuleComponentType::Action))->toThrow(InvalidConfigValue::class); - - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, ['namespace' => 'Some\\Actions']); - expect(fn () => GeneratorHelper::component(ModuleComponentType::Action))->toThrow(InvalidConfigValue::class); - }); -}); diff --git a/tests/TemporaryDirectory.php b/tests/TemporaryDirectory.php new file mode 100644 index 0000000..c794c43 --- /dev/null +++ b/tests/TemporaryDirectory.php @@ -0,0 +1,27 @@ +copyDirectory($copyOf, $path); + + return str_replace('\\', '/', (string) realpath($path)); + } + + public static function delete(string $path): void + { + (new Filesystem)->deleteDirectory($path); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 814ae6e..311058d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,208 +1,248 @@ + */ + protected array $providersBeforeModules = []; - /** @var string[] */ - protected array $modulesPaths = []; + protected string $basePath; - protected ?string $composerJsonBackupPath = null; + private Closure $autoloader; + + private string $workingDirectory; + + private ?int $composerRuns = null; /** - * Get application base path. - * Using both methods for compatibility with different Orchestra Testbench versions. + * @var list */ - public static function applicationBasePath(): string + private array $temporaryDirectories = []; + + protected function setUp(): void { - return __DIR__ . '/fixtures/laravel'; + $this->basePath = TemporaryDirectory::create(__DIR__.'/Fixtures/app'); + spl_autoload_register($this->autoloader = $this->autoloadModules(...)); + // Relative paths (and mutants that make paths relative) never reach the repository. + $this->workingDirectory = (string) getcwd(); + chdir($this->basePath); + + parent::setUp(); } - /** - * @deprecated Use applicationBasePath() instead - */ - protected function getBasePath(): string + protected function tearDown(): void { - return static::applicationBasePath(); + if ($this->composerRuns !== null) { + $this->assertSame(1, $this->composerRuns, 'Composer was expected to run once.'); + } + + parent::tearDown(); + + spl_autoload_unregister($this->autoloader); + chdir($this->workingDirectory); + + unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + ModuleRepository::flushState(); + // Static, so a package provider of one test would add its optimize task to the next ones. + ServiceProvider::$optimizeCommands = ServiceProvider::$optimizeClearCommands = []; + array_map(TemporaryDirectory::delete(...), [$this->basePath, ...$this->temporaryDirectories]); } /** - * Resolve application implementation. - * Override to skip testbench's bootstrap/app.php which ignores applicationBasePath(). + * A directory outside of the application, removed after the test. */ - protected function resolveApplication(): \Illuminate\Foundation\Application + protected function temporaryDirectory(?string $copyOf = null): string { - return $this->resolveDefaultApplication(); + return $this->temporaryDirectories[] = TemporaryDirectory::create($copyOf); } - protected function setUp(): void + /** + * @param Application $app + */ + protected function defineEnvironment($app): void { - parent::setUp(); - - $this->filesystem = $this->app['files']; + Process::fake([])->preventStrayProcesses(); } - protected function tearDown(): void + protected function getApplicationBasePath(): string { - parent::tearDown(); - - Mockery::close(); + return $this->basePath; } + /** + * The provider and the alias come from composer.json, as Laravel package discovery reads them. + */ protected function getPackageProviders($app): array { - return [ - ComposerServiceProvider::class, - ConsoleServiceProvider::class, - ModulesRepositoryServiceProvider::class, - ModulesServiceProvider::class, - ]; + return [...$this->providersBeforeModules, ...self::composerJson()['extra']['laravel']['providers']]; } protected function getPackageAliases($app): array { - return [ - 'Modules' => Modules::class, - ]; + return self::composerJson()['extra']['laravel']['aliases']; } /** - * @param Application $app + * Boot a fresh application, e.g. after the modules changed on disk. */ - protected function getEnvironmentSetUp($app): void + protected function reboot(): void { - $app['config']->set('database.default', 'sqlite'); - $app['config']->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + ModuleRepository::flushState(); + + $this->reloadApplication(); } /** - * @param string[] $paths + * Boot a fresh application that does not run in the console, like an HTTP worker. */ - public function setModules(array $paths, ?string $modulesPath = null): void + protected function rebootForHttp(): void { - $modulesPath = rtrim($modulesPath ?? GeneratorHelper::getBasePath(), '/'); - $this->addModulesPath($modulesPath); - $this->filesystem->ensureDirectoryExists($modulesPath); - - foreach ($paths as $modulePath) { - $modulePath = rtrim($modulePath, '/'); - $this->filesystem->copyDirectory($modulePath, $modulesPath . '/' . Str::afterLast($modulePath, '/')); - } + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; - $this->app[ModulesRepository::class]->pruneModulesManifest(); + $this->reboot(); } - public function pruneModulesPaths(): void + /** + * Expect exactly one Composer run with the given arguments, in the application directory. + * + * @param list $arguments + * @param (Closure(): void)|null $during Runs as Composer would, e.g. to check the filesystem. + */ + protected function expectComposer(array $arguments, int $exitCode = 0, ?Closure $during = null): void { - if ($this->modulesPaths) { - foreach ($this->modulesPaths as $path) { - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectory($path); - } + $this->composerRuns = 0; + + Process::fake(['*' => function (PendingProcess $process) use ($arguments, $exitCode, $during) { + $this->composerRuns++; + + expect($process->command)->toBe(['composer', ...$arguments]) + ->and($process->path)->toBe($this->basePath) + ->and($process->environment)->toBe(['COMPOSER_MEMORY_LIMIT' => '-1']) + ->and($process->timeout)->toBeNull(); + + if ($during !== null) { + $during(); } - $this->modulesPaths = []; - $this->app[ModulesRepository::class]->pruneModulesManifest(); - } + + return Process::result(exitCode: $exitCode); + }]); } - public function backupComposerJson(): void + /** + * Wire the fixture modules into the application as "composer update" would: required, + * installed (vendor/composer/installed.json) and linked from vendor. + * + * @param list $modules + */ + protected function installModules(array $modules = ['blog', 'shop-order']): void { - if ($this->composerJsonBackupPath !== null) { - return; + $root = ComposerJson::read($this->path('composer.json')); + $composer = new ApplicationComposer($this->basePath); + $packages = []; + + foreach ($modules as $name) { + $module = new Module( + $name, + 'app/'.$name, + 'Modules\\'.str_replace(' ', '', ucwords(str_replace('-', ' ', $name))), + $this->path('modules/'.$name), + $this->path('modules/'.$name.'/src'), + ); + $composer->addModule($root, $module, $this->path('modules')); + + $packages[] = [ + ...$this->readJson('modules/'.$name.'/composer.json'), + 'version' => 'dev-main', + 'dist' => ['type' => 'path', 'url' => 'modules/'.$name, 'reference' => null], + 'install-path' => '../app/'.$name, + ]; + + (new Filesystem)->ensureDirectoryExists($this->path('vendor/app')); + // Composer links path repositories with a relative symlink, or with an absolute junction on Windows. + symlink(DIRECTORY_SEPARATOR === '\\' ? $this->path('modules/'.$name) : '../../modules/'.$name, $this->path('vendor/app/'.$name)); } - $this->composerJsonBackupPath = $this->app->basePath('/composer.backup.json'); - if ($this->filesystem->isFile($this->composerJsonBackupPath)) { - $this->resetComposerJson(); - $this->composerJsonBackupPath = $this->app->basePath('/composer.backup.json'); - } - $this->filesystem->copy($this->app->basePath('/composer.json'), $this->composerJsonBackupPath); + $root->save(); + $this->files(['vendor/composer/installed.json' => json_encode(['packages' => $packages, 'dev' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)]); } - public function resetComposerJson(): void + /** + * @param array $files Contents by path relative to the application. + */ + protected function files(array $files): void { - if ($this->composerJsonBackupPath === null) { - return; - } + $filesystem = new Filesystem; - if ($this->filesystem->isFile($this->composerJsonBackupPath)) { - $this->filesystem->delete($this->app->basePath('/composer.json')); - $this->filesystem->copy($this->composerJsonBackupPath, $this->app->basePath('/composer.json')); - $this->filesystem->delete($this->composerJsonBackupPath); + foreach ($files as $path => $contents) { + $filesystem->ensureDirectoryExists(dirname($this->basePath.'/'.$path)); + $filesystem->put($this->basePath.'/'.$path, $contents); } - $this->composerJsonBackupPath = null; + } + + protected function path(string $path = ''): string + { + return $this->basePath.($path === '' ? '' : '/'.$path); } /** - * @param array $attributes + * @return array */ - public function createModule(array $attributes = []): Module - { - return new Module( - packageName: $attributes['packageName'] ?? 'some-vendor/testing-module', - name: $attributes['name'] ?? '', - path: $attributes['path'] ?? $this->app->basePath('modules/TestingModule'), - namespace: $attributes['namespace'] ?? 'SomeVendor\\TestingModule\\', - providers: $attributes['providers'] ?? [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - aliases: $attributes['aliases'] ?? [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - ], - ); + protected function readJson(string $path): array + { + return json_decode((string) file_get_contents($this->path($path)), true, 512, JSON_THROW_ON_ERROR); } /** - * Create a mock of Composer class for testing. - * - * @param array $methodExpectations Array of method names to their expected return values - * - * @return MockInterface&Composer + * Classes of modules created by a test: Composer only knows the fixture modules. */ - public function mockComposer(array $methodExpectations = []): MockInterface + private function autoloadModules(string $class): void { - $composer = Mockery::mock(Composer::class); - - foreach ($methodExpectations as $method => $returnValue) { - $composer->shouldReceive($method)->andReturn($returnValue); - } + foreach (glob($this->basePath.'/*/*/composer.json') ?: [] as $composerJson) { + $composer = json_decode((string) file_get_contents($composerJson), true); + $psr4 = [...(array) ($composer['autoload']['psr-4'] ?? []), ...(array) ($composer['autoload-dev']['psr-4'] ?? [])]; - return $composer; - } + foreach ($psr4 as $namespace => $directories) { + foreach ((array) $directories as $directory) { + $file = dirname($composerJson).'/'.$directory.'/'.str_replace('\\', '/', substr($class, strlen($namespace))).'.php'; - private function addModulesPath(string $path): void - { - if (in_array($path, $this->modulesPaths, true)) { - return; - } + if (str_starts_with($class, $namespace) && is_file($file)) { + require $file; - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectories($path); + return; + } + } + } } + } - $this->modulesPaths[] = $path; + /** + * @return array{extra: array{laravel: array{providers: list, aliases: array}}} + */ + private static function composerJson(): array + { + return json_decode((string) file_get_contents(__DIR__.'/../composer.json'), true, 512, JSON_THROW_ON_ERROR); } } diff --git a/tests/Unit/Manifest/ManifestBuilderTest.php b/tests/Unit/Manifest/ManifestBuilderTest.php new file mode 100644 index 0000000..ad4d87d --- /dev/null +++ b/tests/Unit/Manifest/ManifestBuilderTest.php @@ -0,0 +1,329 @@ + 'routes/api', 'web' => 'routes/web'], string $commands = 'Console\\Commands'): ManifestBuilder +{ + return new ManifestBuilder($modulesPath, $routes, $commands); +} + +function phpClass(string $class, string $declaration = 'class'): string +{ + return " 'src/'], array $extra = []): string +{ + return json_encode(['name' => $name, 'autoload' => ['psr-4' => $psr4], ...$extra], JSON_THROW_ON_ERROR); +} + +it('describes every module of the fixture application', function () { + $modules = str_replace('\\', '/', (string) realpath(FIXTURE_MODULES)); + + expect(manifestBuilder($modules)->build())->toBe([ + 'blog' => [ + 'package' => 'app/blog', + 'namespace' => 'Modules\\Blog', + 'path' => $modules.'/blog', + 'src' => 'src', + 'config' => ['blog'], + 'lang' => true, + 'json' => true, + 'views' => true, + 'migrations' => true, + 'routes' => [ + 'api' => [ + '' => ['routes/api/posts.php'], + 'v1' => ['routes/api/v1/feed.php'], + 'v1/admin' => ['routes/api/v1/admin/stats.php'], + ], + 'web' => ['' => ['routes/web/pages.php']], + ], + 'seeders' => [ + '' => [ + 'Modules\\Blog\\Database\\Seeders\\BlogSeeder', + 'Modules\\Blog\\Database\\Seeders\\CategorySeeder_10', + 'Modules\\Blog\\Database\\Seeders\\PostSeeder_2', + 'Modules\\Blog\\Database\\Seeders\\SeederHelper_3', + ], + 'Deployment' => [ + 'Modules\\Blog\\Database\\Seeders\\Deployment\\BaseDeploymentSeeder_0', + 'Modules\\Blog\\Database\\Seeders\\Deployment\\DemoSeeder_1', + ], + ], + 'commands' => [ + 'Modules\\Blog\\Console\\Commands\\BlogCommand', + 'Modules\\Blog\\Console\\Commands\\PublishPostsCommand', + 'Modules\\Blog\\Console\\Commands\\Nested\\ArchivePostsCommand', + ], + ], + 'shop-order' => [ + 'package' => 'app/shop-order', + 'namespace' => 'Modules\\ShopOrder', + 'path' => $modules.'/shop-order', + 'src' => 'src', + 'config' => ['shop-order'], + 'lang' => true, + 'json' => false, + 'views' => false, + 'migrations' => false, + 'routes' => [ + 'web' => ['' => ['routes/web/orders.php']], + ], + 'seeders' => [ + '' => ['Modules\\ShopOrder\\Database\\Seeders\\OrderSeeder_1'], + 'Deployment' => ['Modules\\ShopOrder\\Database\\Seeders\\Deployment\\ShipmentSeeder_1'], + ], + 'commands' => [], + ], + ]); +}); + +it('finds nothing when the modules directory does not exist', function () { + expect(manifestBuilder($this->directory.'/missing')->build())->toBe([]); +}); + +it('skips directories without composer.json, files and hidden directories', function () { + $modules = $this->files([ + 'notes/README.md' => 'Not a module', + 'README.md' => 'Not a module', + '.hidden/composer.json' => moduleComposerJson('app/hidden'), + 'demo/composer.json' => moduleComposerJson('app/demo'), + ]); + + expect(array_keys(manifestBuilder($modules)->build()))->toBe(['demo']); +}); + +it('orders modules by directory name', function () { + $modules = $this->files([ + 'zeta/composer.json' => moduleComposerJson('app/zeta', ['Modules\\Zeta\\' => 'src/']), + 'alpha/composer.json' => moduleComposerJson('app/alpha', ['Modules\\Alpha\\' => 'src/']), + 'mid-dle/composer.json' => moduleComposerJson('app/mid-dle', ['Modules\\MidDle\\' => 'src/']), + ]); + + expect(array_keys(manifestBuilder($modules)->build()))->toBe(['alpha', 'mid-dle', 'zeta']); +}); + +it('uses the first psr-4 entry as the root namespace and source directory', function (array $psr4, string $namespace, string $src) { + $modules = $this->files(['demo/composer.json' => moduleComposerJson('app/demo', $psr4)]); + + $module = manifestBuilder($modules)->build()['demo']; + + expect($module['namespace'])->toBe($namespace) + ->and($module['src'])->toBe($src); +})->with([ + 'src directory' => [['Modules\\Demo\\' => 'src/'], 'Modules\\Demo', 'src'], + 'without trailing slash' => [['Modules\\Demo\\' => 'src'], 'Modules\\Demo', 'src'], + 'with ./ prefix' => [['Modules\\Demo\\' => './app'], 'Modules\\Demo', 'app'], + 'module root' => [['Modules\\Demo\\' => ''], 'Modules\\Demo', ''], + 'list of directories' => [['App\\Demo\\' => ['src/', 'lib/']], 'App\\Demo', 'src'], + 'nested directory' => [['Acme\\Shop\\Demo\\' => 'code/php/'], 'Acme\\Shop\\Demo', 'code/php'], + 'first entry wins' => [['Modules\\Demo\\' => 'src/', 'Modules\\Demo\\Database\\Factories\\' => 'database/factories/'], 'Modules\\Demo', 'src'], +]); + +it('rejects invalid modules', function (string $composerJson, string $reason) { + $modules = $this->files(['broken/composer.json' => $composerJson]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, "Invalid module [{$modules}/broken]: {$reason}"); +})->with([ + 'invalid JSON' => ['{"name": ', 'composer.json is not valid JSON'], + 'not an object' => ['"app/broken"', 'composer.json must contain an object.'], + 'no name' => ['{"autoload": {"psr-4": {"Modules\\\\Broken\\\\": "src/"}}}', 'composer.json must have a valid "name".'], + 'invalid name' => [moduleComposerJson('App/Broken'), 'composer.json must have a valid "name".'], + 'no autoload' => ['{"name": "app/broken"}', 'the first "autoload.psr-4" entry'], + 'autoload is not an object' => ['{"name": "app/broken", "autoload": "src"}', 'the first "autoload.psr-4" entry'], + 'empty psr-4' => ['{"name": "app/broken", "autoload": {"psr-4": {}}}', 'the first "autoload.psr-4" entry'], + 'no trailing backslash' => [moduleComposerJson('app/broken', ['Modules\\Broken' => 'src/']), 'the first "autoload.psr-4" entry'], + 'invalid namespace' => [moduleComposerJson('app/broken', ['Modules\\1Broken\\' => 'src/']), 'the first "autoload.psr-4" entry'], + 'invalid directory' => [moduleComposerJson('app/broken', ['Modules\\Broken\\' => 42]), 'the first "autoload.psr-4" entry'], + 'empty directory list' => [moduleComposerJson('app/broken', ['Modules\\Broken\\' => []]), 'the first "autoload.psr-4" entry'], +]); + +it('rejects two modules with the same package name', function () { + $modules = $this->files([ + 'blog/composer.json' => moduleComposerJson('app/blog', ['Modules\\Blog\\' => 'src/']), + 'blog-copy/composer.json' => moduleComposerJson('app/blog', ['Modules\\BlogCopy\\' => 'src/']), + ]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, 'the package name [app/blog] is already used by the [blog] module.'); +}); + +it('rejects two modules with the same namespace', function () { + $modules = $this->files([ + 'blog/composer.json' => moduleComposerJson('app/blog', ['Modules\\Blog\\' => 'src/']), + 'blog-copy/composer.json' => moduleComposerJson('app/blog-copy', ['Modules\\Blog\\' => 'src/']), + ]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, 'the namespace [Modules\\Blog] is already used by the [blog] module.'); +}); + +it('finds config files by their key, top level php files only', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/config/demo.php' => ' ' ' '', + ]); + + expect(manifestBuilder($modules)->build()['demo']['config'])->toBe(['demo-extra', 'demo']); +}); + +it('records json translations only when the lang directory has json files', function (array $files, bool $lang, bool $json) { + $modules = $this->files(['demo/composer.json' => moduleComposerJson('app/demo'), ...$files]); + + $module = manifestBuilder($modules)->build()['demo']; + + expect($module['lang'])->toBe($lang)->and($module['json'])->toBe($json); +})->with([ + 'no lang directory' => [[], false, false], + 'php translations' => [['demo/lang/en/messages.php' => ' [['demo/lang/de.json' => '{}'], true, true], + 'json in a subdirectory only' => [['demo/lang/en/extra.json' => '{}'], true, false], +]); + +it('records views and migrations by their directories', function () { + $modules = $this->files([ + 'with/composer.json' => moduleComposerJson('app/with', ['Modules\\With\\' => 'src/']), + 'with/resources/views/.gitkeep' => '', + 'with/database/migrations/.gitkeep' => '', + 'without/composer.json' => moduleComposerJson('app/without', ['Modules\\Without\\' => 'src/']), + 'without/resources/.gitkeep' => '', + ]); + + $manifest = manifestBuilder($modules)->build(); + + expect($manifest['with']['views'])->toBeTrue() + ->and($manifest['with']['migrations'])->toBeTrue() + ->and($manifest['without']['views'])->toBeFalse() + ->and($manifest['without']['migrations'])->toBeFalse(); +}); + +it('collects route files per group, files before subdirectories, alphabetically', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/routes/api/b.php' => ' ' '', + 'demo/routes/api/.hidden.php' => ' ' ' '', + 'demo/routes/api/v2/x.php' => ' 'build()['demo']['routes'])->toBe([ + 'api' => [ + '' => ['routes/api/a.php', 'routes/api/b.php'], + 'a' => ['routes/api/a/z.php'], + 'a/b' => ['routes/api/a/b/c.php'], + 'v2' => ['routes/api/v2/x.php'], + ], + 'web' => ['' => ['routes/web/page.php']], + ]); +}); + +it('uses the route directories of the config', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/src/UI/API/routes/v1/posts.php' => ' ' '/src\\UI\\API/routes/'])->build()['demo']['routes']) + ->toBe(['api' => ['v1' => ['src/UI/API/routes/v1/posts.php']]]); +}); + +it('collects seeders of database/seeders and its direct subdirectories only', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/database/seeders/RootSeeder.php' => phpClass('RootSeeder'), + 'demo/database/seeders/Deployment/DeploySeeder_1.php' => phpClass('DeploySeeder_1'), + 'demo/database/seeders/Deployment/Deeper/IgnoredSeeder.php' => phpClass('IgnoredSeeder'), + 'demo/database/seeders/not-a-namespace/IgnoredSeeder.php' => phpClass('IgnoredSeeder'), + 'demo/database/seeders/not-a-class.php' => ' '', + ]); + + expect(manifestBuilder($modules)->build()['demo']['seeders'])->toBe([ + '' => ['Modules\\Demo\\Database\\Seeders\\RootSeeder'], + 'Deployment' => ['Modules\\Demo\\Database\\Seeders\\Deployment\\DeploySeeder_1'], + ]); +}); + +it('collects commands recursively from the make:command namespace', function (string $namespace, string $directory) { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => 'app/']), + "demo/app/{$directory}/SendCommand.php" => phpClass('SendCommand'), + "demo/app/{$directory}/Deep/Er/CleanCommand.php" => phpClass('CleanCommand'), + "demo/app/{$directory}/not-a-namespace/IgnoredCommand.php" => phpClass('IgnoredCommand'), + 'demo/app/Other/IgnoredCommand.php' => phpClass('IgnoredCommand'), + ]); + + expect(manifestBuilder($modules, [], $namespace)->build()['demo']['commands'])->toBe([ + 'Modules\\Demo\\'.trim($namespace, '\\').'\\SendCommand', + 'Modules\\Demo\\'.trim($namespace, '\\').'\\Deep\\Er\\CleanCommand', + ]); +})->with([ + 'laravel' => ['Console\\Commands', 'Console/Commands'], + 'porto' => ['\\UI\\CLI\\Commands\\', 'UI/CLI/Commands'], +]); + +it('collects commands of a module whose root namespace is its directory', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => '']), + 'demo/Console/Commands/SendCommand.php' => phpClass('SendCommand'), + ]); + + expect(manifestBuilder($modules)->build()['demo']['commands'])->toBe(['Modules\\Demo\\Console\\Commands\\SendCommand']); +}); + +it('leaves out files that do not declare a class of their name', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/src/Console/Commands/AbstractCommand.php' => phpClass('AbstractCommand', 'abstract class'), + 'demo/src/Console/Commands/AttributeCommand.php' => " phpClass('CommandContract', 'interface'), + 'demo/src/Console/Commands/CommandTrait.php' => phpClass('CommandTrait', 'trait'), + 'demo/src/Console/Commands/IndentedCommand.php' => " phpClass('Status', 'enum'), + 'demo/src/Console/Commands/helpers.php' => " " phpClass('OtherCommand'), + 'demo/src/Console/Commands/Prefix.php' => phpClass('PrefixCommand'), + 'demo/database/seeders/helpers.php' => "build()['demo']; + + expect($module['commands'])->toBe([ + 'Modules\\Demo\\Console\\Commands\\AbstractCommand', + 'Modules\\Demo\\Console\\Commands\\AttributeCommand', + 'Modules\\Demo\\Console\\Commands\\CommandContract', + 'Modules\\Demo\\Console\\Commands\\CommandTrait', + 'Modules\\Demo\\Console\\Commands\\IndentedCommand', + 'Modules\\Demo\\Console\\Commands\\Status', + ])->and($module['seeders'])->toBe([]); +}); + +it('has a key that changes with the settings', function () { + $key = manifestBuilder('/app/modules')->key(); + + expect(manifestBuilder('/app/modules/')->key())->toBe($key) + ->and(manifestBuilder('/app/other')->key())->not->toBe($key) + ->and(manifestBuilder('/app/modules', ['api' => 'routes/api'])->key())->not->toBe($key) + ->and(manifestBuilder('/app/modules', commands: 'UI\\CLI\\Commands')->key())->not->toBe($key); +}); + +it('normalizes the modules path', function () { + expect(manifestBuilder('C:\\app\\modules\\')->modulesPath())->toBe('C:/app/modules'); +}); diff --git a/tests/Unit/Manifest/ManifestCacheTest.php b/tests/Unit/Manifest/ManifestCacheTest.php new file mode 100644 index 0000000..4624e89 --- /dev/null +++ b/tests/Unit/Manifest/ManifestCacheTest.php @@ -0,0 +1,93 @@ + 'app/blog', + 'namespace' => 'Modules\\Blog', + 'path' => $path, + 'src' => 'src', + 'config' => ['blog'], + 'lang' => true, + 'json' => false, + 'views' => true, + 'migrations' => false, + 'routes' => ['api' => ['' => ['routes/api/posts.php']]], + 'seeders' => ['' => ['Modules\\Blog\\Database\\Seeders\\BlogSeeder']], + 'commands' => [], + ]; +} + +it('writes the manifest as a PHP array with paths relative to the base path', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/bootstrap/cache/modules.php', $this->directory); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect($cache->exists())->toBeTrue() + ->and(require $this->directory.'/bootstrap/cache/modules.php')->toBe(['blog' => manifestEntry('modules/blog')]) + ->and(file_get_contents($this->directory.'/bootstrap/cache/modules.php'))->not->toContain($this->directory); +}); + +it('reads the manifest back with absolute paths', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect($cache->read())->toBe(['blog' => manifestEntry($this->directory.'/modules/blog')]); +}); + +it('works when the application is moved to another directory', function () { + (new ManifestCache(new Filesystem, $this->directory.'/build/modules.php', $this->directory.'/build')) + ->write(['blog' => manifestEntry($this->directory.'/build/modules/blog')]); + + rename($this->directory.'/build', $this->directory.'/release'); + + expect((new ManifestCache(new Filesystem, $this->directory.'/release/modules.php', $this->directory.'/release'))->read()) + ->toBe(['blog' => manifestEntry($this->directory.'/release/modules/blog')]); +}); + +it('keeps paths outside of the base path absolute', function (string $path) { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory.'/app'); + + $cache->write(['blog' => manifestEntry($path)]); + + expect((require $this->directory.'/modules.php')['blog']['path'])->toBe($path) + ->and($cache->read()['blog']['path'])->toBe($path); +})->with([ + 'unix' => ['/srv/shared/modules/blog'], + 'sibling with the same prefix' => [fn () => $this->directory.'/application/modules/blog'], + 'windows' => ['D:/shared/modules/blog'], +]); + +it('creates the cache directory', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/missing/cache/modules.php', $this->directory); + + $cache->write([]); + + expect(require $this->directory.'/missing/cache/modules.php')->toBe([]); +}); + +it('deletes the file', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory); + $cache->write([]); + + $cache->delete(); + $cache->delete(); + + expect($cache->exists())->toBeFalse() + ->and(file_exists($this->directory.'/modules.php'))->toBeFalse(); +}); + +it('normalizes a windows base path', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', str_replace('/', '\\', $this->directory).'\\'); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect((require $this->directory.'/modules.php')['blog']['path'])->toBe('modules/blog') + ->and($cache->path())->toBe($this->directory.'/modules.php'); +}); diff --git a/tests/Unit/Scaffold/ApplicationComposerTest.php b/tests/Unit/Scaffold/ApplicationComposerTest.php new file mode 100644 index 0000000..602dc96 --- /dev/null +++ b/tests/Unit/Scaffold/ApplicationComposerTest.php @@ -0,0 +1,76 @@ +files(['modules/blog/composer.json' => json_encode(['name' => 'app/blog', 'autoload-dev' => ['psr-4' => $autoloadDev]])]); + + expect((new ApplicationComposer($this->directory))->autoloadDev(blogModule($this->directory)))->toBe($expected); +})->with([ + 'one path' => [['Modules\\Blog\\Tests\\' => 'tests/'], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'dot slash' => [['Modules\\Blog\\Tests\\' => './tests/'], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'several paths' => [['Modules\\Blog\\Tests\\' => ['tests/', 'fixtures/']], ['Modules\\Blog\\Tests\\' => ['modules/blog/tests/', 'modules/blog/fixtures/']]], + 'one path in a list' => [['Modules\\Blog\\Tests\\' => ['tests/']], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'invalid paths' => [['Modules\\Blog\\Tests\\' => [1, null, 'tests/'], 'Empty\\' => [], 'Number\\' => 5], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'nothing' => [[], []], +]); + +it('reads no dev autoload from a module without it', function () { + $this->files(['modules/blog/composer.json' => '{"name": "app/blog"}']); + + expect((new ApplicationComposer($this->directory))->autoloadDev(blogModule($this->directory)))->toBe([]); +}); + +it('adds a module and tells whether composer.json changed', function () { + $this->files([ + 'composer.json' => '{"name": "demo/app"}', + 'modules/blog/composer.json' => '{"name": "app/blog", "autoload-dev": {"psr-4": {"Modules\\\\Blog\\\\Tests\\\\": "tests/"}}}', + ]); + $composer = new ApplicationComposer($this->directory.'/'); + $root = $composer->composerJson(); + + expect($composer->addModule($root, blogModule($this->directory), $this->directory.'/modules'))->toBeTrue() + ->and($composer->addModule($root, blogModule($this->directory), $this->directory.'/modules'))->toBeFalse() + ->and($root->toArray())->toMatchArray([ + 'require' => ['app/blog' => '*@dev'], + 'autoload-dev' => ['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ]) + ->and($root->get('repositories.0.url'))->toBe('modules/*') + ->and($composer->composerJsonPath())->toBe($this->directory.'/composer.json'); +}); + +it('makes paths inside the application relative', function (string $path, string $expected) { + $composer = new ApplicationComposer($this->directory); + + expect($composer->relative(str_replace('{base}', $this->directory, $path)))->toBe(str_replace('{base}', $this->directory, $expected)); +})->with([ + 'inside' => ['{base}/modules/blog', 'modules/blog'], + 'trailing slash' => ['{base}/modules/', 'modules'], + 'the application' => ['{base}', ''], + 'a sibling with the same prefix' => ['{base}-other/modules', '{base}-other/modules'], + 'outside' => ['/elsewhere/modules', '/elsewhere/modules'], +]); + +it('reads the installed packages of the vendor directory', function (?string $vendorDir, string $installed) { + $this->files([ + 'composer.json' => json_encode($vendorDir === null ? ['name' => 'demo/app'] : ['config' => ['vendor-dir' => str_replace('{base}', $this->directory, $vendorDir)]]), + $installed.'/composer/installed.json' => '{"packages": [{"name": "app/blog"}]}', + ]); + $composer = new ApplicationComposer($this->directory); + $packages = $composer->installed($composer->composerJson()); + + expect($packages->has('app/blog'))->toBeTrue() + ->and($packages->path('app/blog'))->toBe($this->directory.'/'.$installed.'/app/blog'); +})->with([ + 'default' => [null, 'vendor'], + 'relative' => ['libraries', 'libraries'], + 'absolute' => ['{base}/deps', 'deps'], +]); diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php new file mode 100644 index 0000000..5570a2f --- /dev/null +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -0,0 +1,429 @@ + 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]]; + +const PACKAGIST_EXCLUDING_APP = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']]; + +/** + * @param array|string $contents + * @return array + */ +function editComposerJson(string $directory, array|string $contents, Closure $edit): array +{ + $path = $directory.'/composer.json'; + file_put_contents($path, is_string($contents) ? $contents : json_encode($contents, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $composerJson = ComposerJson::read($path); + $edit($composerJson); + $composerJson->save(); + + return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); +} + +function addBlog(ComposerJson $composerJson): void +{ + $composerJson->addModule('app/blog', 'modules/*', ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']); +} + +it('adds a module: path repository, requirement, dev autoload and the Packagist filter', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app', 'require' => ['php' => '^8.3']], addBlog(...)); + + expect($json)->toBe([ + 'name' => 'demo/app', + 'require' => ['php' => '^8.3', 'app/blog' => '*@dev'], + 'repositories' => [PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP], + 'autoload-dev' => ['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ]); +}); + +it('is idempotent', function () { + $once = editComposerJson($this->directory, ['name' => 'demo/app'], addBlog(...)); + $twice = editComposerJson($this->directory, $once, addBlog(...)); + + expect($twice)->toBe($once); +}); + +it('adds more modules to the same repository and filter', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app'], function (ComposerJson $composerJson): void { + addBlog($composerJson); + $composerJson->addModule('app/shop', 'modules/*', []); + $composerJson->addModule('acme/billing', 'modules/*', []); + }); + + expect($json['require'])->toBe(['app/blog' => '*@dev', 'app/shop' => '*@dev', 'acme/billing' => '*@dev']) + ->and($json['repositories'])->toBe([ + PATH_REPOSITORY, + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*', 'acme/*']], + ]); +}); + +it('keeps an existing requirement of the module', function () { + $json = editComposerJson($this->directory, ['require' => ['app/blog' => '^1.0']], addBlog(...)); + + expect($json['require'])->toBe(['app/blog' => '^1.0']); +}); + +it('sorts the requirements when composer sorts packages', function () { + $json = editComposerJson($this->directory, [ + 'require' => ['php' => '^8.3', 'laravel/framework' => '^13.0', 'ext-json' => '*'], + 'config' => ['sort-packages' => true], + ], addBlog(...)); + + expect(array_keys($json['require']))->toBe(['php', 'ext-json', 'app/blog', 'laravel/framework']); +}); + +it('sorts the requirements like Composer', function () { + $json = editComposerJson($this->directory, [ + 'require' => array_fill_keys(['zeta/pkg', 'composer-runtime-api', 'lib-pcre', 'ext-mbstring', 'hhvm', 'php-64bit', 'ext-json', 'php', 'ext-10', 'app/wiki'], '*'), + 'config' => ['sort-packages' => true], + ], addBlog(...)); + + expect(array_keys($json['require']))->toBe([ + 'php', 'php-64bit', 'hhvm', 'ext-10', 'ext-json', 'ext-mbstring', 'lib-pcre', 'composer-runtime-api', 'app/blog', 'app/wiki', 'zeta/pkg', + ]); +}); + +it('adds a new requirement last when composer does not sort packages', function () { + $json = editComposerJson($this->directory, ['require' => ['php' => '^8.3', 'laravel/framework' => '^13.0']], addBlog(...)); + + expect(array_keys($json['require']))->toBe(['php', 'laravel/framework', 'app/blog']); +}); + +it('reuses an existing path repository', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'path', 'url' => 'modules/*']], + ], addBlog(...)); + + expect($json['repositories'])->toBe([['type' => 'path', 'url' => 'modules/*'], PACKAGIST_EXCLUDING_APP]); +}); + +it('adds the path repository when others point elsewhere', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'path', 'url' => 'packages/*'], ['type' => 'vcs', 'url' => 'modules/*'], ['packagist.org' => false]], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + ['type' => 'path', 'url' => 'packages/*'], + ['type' => 'vcs', 'url' => 'modules/*'], + ['packagist.org' => false], + PATH_REPOSITORY, + ]); +}); + +it('extends an existing packagist repository', function (string $url) { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'composer', 'url' => $url, 'exclude' => ['other/*']], ['packagist.org' => false]], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + ['type' => 'composer', 'url' => $url, 'exclude' => ['other/*', 'app/*']], + ['packagist.org' => false], + PATH_REPOSITORY, + ]); +})->with(['https://repo.packagist.org', 'https://repo.packagist.org/', 'https://packagist.org', 'http://repo.packagist.org/packages']); + +it('adds the filter to a packagist repository without one', function () { + $json = editComposerJson($this->directory, ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org']]], addBlog(...)); + + expect($json['repositories'])->toBe([PACKAGIST_EXCLUDING_APP, PATH_REPOSITORY]); +}); + +it('does not take a mirror for packagist', function () { + $mirror = ['type' => 'composer', 'url' => 'https://packagist.org.mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$mirror]], addBlog(...)); + + expect($json['repositories'])->toBe([$mirror, PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); +}); + +it('filters a mirror that replaces packagist by its name', function (string $name) { + $mirror = ['type' => 'composer', 'url' => 'https://mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$name => $mirror, 'private' => ['type' => 'vcs', 'url' => 'x']]], addBlog(...)); + + expect($json['repositories'])->toBe([ + $name => [...$mirror, 'exclude' => ['app/*']], + 'private' => ['type' => 'vcs', 'url' => 'x'], + 'modules' => PATH_REPOSITORY, + ]); +})->with(['packagist.org', 'packagist']); + +it('filters a mirror named packagist in the repository list and the default repository', function (string $name) { + // "composer config repo.packagist composer " writes this; Composer still uses the default repository. + $mirror = ['name' => $name, 'type' => 'composer', 'url' => 'https://mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$mirror]], addBlog(...)); + + expect($json['repositories'])->toBe([[...$mirror, 'exclude' => ['app/*']], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); +})->with(['packagist.org', 'packagist']); + +it('filters every repository that serves packagist', function () { + $mirror = ['type' => 'composer', 'url' => 'https://mirror.example.com']; + $packagist = ['type' => 'composer', 'url' => 'https://repo.packagist.org']; + + $json = editComposerJson($this->directory, ['repositories' => ['packagist.org' => $mirror, 'main' => $packagist]], addBlog(...)); + + expect($json['repositories'])->toBe([ + 'packagist.org' => [...$mirror, 'exclude' => ['app/*']], + 'main' => [...$packagist, 'exclude' => ['app/*']], + 'modules' => PATH_REPOSITORY, + ]); +}); + +it('leaves a packagist repository restricted with "only" untouched', function () { + $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; + + $json = editComposerJson($this->directory, ['repositories' => [$repository, ['packagist.org' => false]]], addBlog(...)); + + expect($json['repositories'])->toBe([$repository, ['packagist.org' => false], PATH_REPOSITORY]); +}); + +it('fails when packagist serves the module through "only"', function () { + $contents = ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*', 'app/*']]]]; + + expect(fn () => editComposerJson($this->directory, $contents, addBlog(...))) + ->toThrow(ComposerFailed::class, 'a Packagist repository serves [app/blog] through "only"'); +}); + +it('does not enable packagist when it is disabled', function (array $disabled) { + $json = editComposerJson($this->directory, ['repositories' => [$disabled]], addBlog(...)); + + expect($json['repositories'])->toBe([$disabled, PATH_REPOSITORY]); +})->with([ + [['packagist.org' => false]], + [['packagist' => false]], +]); + +it('supports repositories defined as an object', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => ['private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], 'modules' => ['type' => 'vcs', 'url' => 'x']], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + 'private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], + 'modules' => ['type' => 'vcs', 'url' => 'x'], + 'modules-2' => PATH_REPOSITORY, + 'packagist.org' => PACKAGIST_EXCLUDING_APP, + ]); +}); + +it('does not enable packagist when it is disabled in repositories defined as an object', function () { + $json = editComposerJson($this->directory, ['repositories' => ['packagist.org' => false]], addBlog(...)); + + expect($json['repositories'])->toBe(['packagist.org' => false, 'modules' => PATH_REPOSITORY]); +}); + +it('ignores repository entries that are not objects', function () { + $json = editComposerJson($this->directory, ['repositories' => ['modules/*', ['type' => 'path']]], addBlog(...)); + + expect($json['repositories'])->toBe(['modules/*', ['type' => 'path'], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); +}); + +it('keeps the formatting: empty objects, indentation, unicode and slashes', function () { + $original = "{\n \"name\": \"demo/app\",\n \"description\": \"Приложение / app\",\n \"require\": {},\n \"extra\": {},\n \"scripts\": {\n \"test\": [\"pest\"]\n }\n}\n"; + + editComposerJson($this->directory, $original, addBlog(...)); + + expect(file_get_contents($this->directory.'/composer.json'))->toBe(<<<'JSON' + { + "name": "demo/app", + "description": "Приложение / app", + "require": { + "app/blog": "*@dev" + }, + "extra": {}, + "scripts": { + "test": [ + "pest" + ] + }, + "repositories": [ + { + "type": "path", + "url": "modules/*", + "options": { + "symlink": true + } + }, + { + "type": "composer", + "url": "https://repo.packagist.org", + "exclude": [ + "app/*" + ] + } + ], + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "modules/blog/tests/" + } + } + } + + JSON); +}); + +it('keeps tab indentation', function () { + editComposerJson($this->directory, "{\n\t\"name\": \"demo/app\",\n\t\"extra\": {\n\t\t\"a\": 1\n\t}\n}", fn (ComposerJson $json) => $json->addProvider('X')); + + expect(file_get_contents($this->directory.'/composer.json')) + ->toBe("{\n\t\"name\": \"demo/app\",\n\t\"extra\": {\n\t\t\"a\": 1,\n\t\t\"laravel\": {\n\t\t\t\"providers\": [\n\t\t\t\t\"X\"\n\t\t\t]\n\t\t}\n\t}\n}\n"); +}); + +it('does not write the file when nothing changed', function () { + $original = '{"name":"demo/app","require":{"app/blog":"*"},"repositories":[{"type":"path","url":"modules/*"},{"packagist.org":false}]}'; + file_put_contents($this->directory.'/composer.json', $original); + + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + $composerJson->addModule('app/blog', 'modules/*', []); + + expect($composerJson->isDirty())->toBeFalse(); + + $composerJson->save(); + + expect(file_get_contents($this->directory.'/composer.json'))->toBe($original); +}); + +it('keeps the file mode', function () { + file_put_contents($path = $this->directory.'/composer.json', '{}'); + chmod($path, 0640); + + ComposerJson::read($path)->addProvider('A')->save(); + clearstatcache(); + + expect(fileperms($path) & 0777)->toBe(0640); +})->skipOnWindows(); + +it('replaces the file atomically', function () { + editComposerJson($this->directory, ['name' => 'demo/app'], addBlog(...)); + + expect(scandir($this->directory))->toBe(['.', '..', 'composer.json']); +}); + +it('saves again after more changes', function () { + file_put_contents($this->directory.'/composer.json', '{}'); + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + + $composerJson->addProvider('A')->save(); + expect($composerJson->isDirty())->toBeFalse(); + + $composerJson->addProvider('B')->save(); + + expect(json_decode((string) file_get_contents($this->directory.'/composer.json'), true)) + ->toBe(['extra' => ['laravel' => ['providers' => ['A', 'B']]]]); +}); + +it('removes a requirement and dev autoload namespaces', function () { + $json = editComposerJson($this->directory, [ + 'require' => ['php' => '^8.3', 'app/blog' => '*'], + 'autoload-dev' => ['psr-4' => ['Tests\\' => 'tests/', 'Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ], function (ComposerJson $composerJson): void { + $composerJson->removeRequire('app/blog')->removeRequire('app/missing'); + $composerJson->removeAutoloadDev(['Modules\\Blog\\Tests\\', 'Missing\\']); + }); + + expect($json)->toBe([ + 'require' => ['php' => '^8.3'], + 'autoload-dev' => ['psr-4' => ['Tests\\' => 'tests/']], + ]); +}); + +it('keeps a module required for development', function () { + $json = editComposerJson($this->directory, ['require-dev' => ['app/blog' => '*@dev']], addBlog(...)); + + expect($json)->not->toHaveKey('require') + ->and($json['require-dev'])->toBe(['app/blog' => '*@dev']); + + $json = editComposerJson($this->directory, $json, fn (ComposerJson $composerJson) => $composerJson->removeRequire('app/blog')); + + expect($json['require-dev'])->toBe([]); +}); + +it('removes nothing when there is nothing to remove', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app'], function (ComposerJson $composerJson): void { + $composerJson->removeRequire('app/blog')->removeAutoloadDev(['Modules\\Blog\\Tests\\']); + }); + + expect($json)->toBe(['name' => 'demo/app']); +}); + +it('adds a provider once', function () { + $json = editComposerJson($this->directory, [ + 'extra' => ['laravel' => ['providers' => ['Modules\\Blog\\Providers\\EventServiceProvider'], 'aliases' => []]], + ], function (ComposerJson $composerJson): void { + $composerJson->addProvider('Modules\\Blog\\Providers\\BlogServiceProvider'); + $composerJson->addProvider('Modules\\Blog\\Providers\\BlogServiceProvider'); + }); + + expect($json['extra']['laravel'])->toBe([ + 'providers' => ['Modules\\Blog\\Providers\\EventServiceProvider', 'Modules\\Blog\\Providers\\BlogServiceProvider'], + 'aliases' => [], + ]); +}); + +it('reads values', function () { + file_put_contents($this->directory.'/composer.json', '{"name":"demo/app","require":{"app/blog":"*","vendor/pkg.name":"^1"},"autoload-dev":{"psr-4":{"A\\\\":"a/"}}}'); + + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + + expect($composerJson->get('name'))->toBe('demo/app') + ->and($composerJson->get('autoload-dev.psr-4'))->toBe(['A\\' => 'a/']) + ->and($composerJson->get('missing'))->toBeNull() + ->and($composerJson->requires('app/blog'))->toBeTrue() + ->and($composerJson->requires('vendor/pkg.name'))->toBeTrue() + ->and($composerJson->requires('app/shop'))->toBeFalse() + ->and($composerJson->requires('php'))->toBeFalse() + ->and($composerJson->toArray()['require'])->toBe(['app/blog' => '*', 'vendor/pkg.name' => '^1']); +}); + +it('tells whether packagist can serve a package', function (array $repositories, bool $excluded) { + file_put_contents($this->directory.'/composer.json', json_encode(['repositories' => $repositories])); + + expect(ComposerJson::read($this->directory.'/composer.json')->isExcludedFromPackagist('app/blog'))->toBe($excluded); +})->with([ + 'default packagist' => [[], false], + 'other repositories only' => [[['type' => 'path', 'url' => 'modules/*']], false], + 'excluded vendor' => [[PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]], true], + 'excluded package' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/blog']], ['packagist.org' => false]], true], + 'excluded pattern with uppercase' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['APP/*']], ['packagist.org' => false]], true], + 'other vendor excluded' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['acme/*']], ['packagist.org' => false]], false], + 'similar vendor excluded' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['ap/*']], ['packagist.org' => false]], false], + 'only other vendors' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']], ['packagist.org' => false]], true], + 'only the vendor' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['app/*']], ['packagist.org' => false]], false], + 'disabled' => [[['packagist.org' => false]], true], + 'disabled by name' => [['packagist.org' => false], true], + 'disabled, then redefined' => [[['packagist.org' => false], ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], + 'mirror excluding the vendor' => [['packagist.org' => ['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], true], + 'mirror' => [['packagist' => ['type' => 'composer', 'url' => 'https://mirror.example.com']], false], + 'unnamed mirror' => [[['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], + 'replaced by another kind of repository' => [['packagist.org' => ['type' => 'vcs', 'url' => 'https://example.com/repo.git']], true], + 'disabled by name, then redefined' => [['packagist.org' => false, 'main' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], + 'after entries that are not objects' => [['modules/*', PACKAGIST_EXCLUDING_APP], true], + 'named mirror' => [[['name' => 'packagist', 'type' => 'composer', 'url' => 'https://mirror.example.com'], PACKAGIST_EXCLUDING_APP], false], + 'named mirror excluding the vendor' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']], PACKAGIST_EXCLUDING_APP], true], + 'named mirror next to the default repository' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], + 'named mirror restricted to other vendors' => [[['name' => 'packagist', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'only' => ['laravel/*']], ['packagist.org' => false]], true], + 'one of the packagist repositories not excluding the vendor' => [['packagist.org' => ['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']], 'main' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], + 'not a composer repository' => [[['type' => 'vcs', 'url' => 'https://packagist.org/app/blog', 'exclude' => ['app/*']]], false], +]); + +it('fails on files it can not edit', function (?string $contents, string $message) { + $path = $this->directory.'/composer.json'; + + if ($contents !== null) { + file_put_contents($path, $contents); + } + + expect(fn () => ComposerJson::read($path)->addModule('app/blog', 'modules/*', []))->toThrow(ComposerFailed::class, $message); +})->with([ + 'missing file' => [null, 'Unable to read'], + 'invalid JSON' => ['{', 'is not valid JSON'], + 'not an object' => ['[]', 'must contain a JSON object'], + 'require is a list' => ['{"require": ["app/blog"]}', '"require" must be an object'], + 'repositories is a string' => ['{"repositories": "modules/*"}', '"repositories" must be an array or an object'], +]); diff --git a/tests/Unit/Scaffold/InstalledPackagesTest.php b/tests/Unit/Scaffold/InstalledPackagesTest.php new file mode 100644 index 0000000..5191d6a --- /dev/null +++ b/tests/Unit/Scaffold/InstalledPackagesTest.php @@ -0,0 +1,60 @@ + 'app/blog', + 'version' => 'dev-main', + 'dist' => ['type' => 'path', 'url' => 'modules/blog'], + 'require' => ['php' => '^8.3'], + 'type' => 'library', + 'extra' => ['laravel' => ['providers' => ['Modules\\Blog\\BlogServiceProvider']]], + 'autoload' => ['psr-4' => ['Modules\\Blog\\' => 'src/']], +]; + +it('reads the packages of composer 2', function () { + $vendor = $this->files(['composer/installed.json' => json_encode(['packages' => [INSTALLED_BLOG], 'dev' => true])]); + + $installed = InstalledPackages::read($vendor); + + expect($installed->has('app/blog'))->toBeTrue() + ->and($installed->has('app/shop'))->toBeFalse() + ->and($installed->path('app/blog'))->toBe($vendor.'/app/blog'); +}); + +it('reads the packages of composer 1', function () { + $vendor = $this->files(['composer/installed.json' => json_encode([INSTALLED_BLOG])]); + + expect(InstalledPackages::read($vendor)->has('app/blog'))->toBeTrue(); +}); + +it('has no packages without installed.json', function (?string $contents) { + if ($contents !== null) { + $this->files(['composer/installed.json' => $contents]); + } + + expect(InstalledPackages::read($this->directory)->has('app/blog'))->toBeFalse(); +})->with([ + 'missing' => [null], + 'invalid' => ['{'], + 'unexpected' => ['{"packages": [42, {"version": "1.0"}]}'], +]); + +it('compares the installed metadata with composer.json', function (array $composerJson, bool $current) { + $installed = InstalledPackages::read($this->files(['composer/installed.json' => json_encode(['packages' => [INSTALLED_BLOG]])])); + + expect($installed->isCurrent('app/blog', $composerJson))->toBe($current); +})->with([ + 'same' => [['name' => 'app/blog', 'require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload'], 'description' => 'changed'], true], + // Composer lowercases the names of the installed requirements. + 'name in another case' => [['require' => ['PHP' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload']], true], + 'new provider' => [['require' => ['php' => '^8.3'], 'extra' => ['laravel' => ['providers' => ['A', 'B']]], 'autoload' => INSTALLED_BLOG['autoload']], false], + 'new namespace' => [['require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => ['psr-4' => ['Modules\\Blog\\' => 'src/', 'X\\' => 'x/']]], false], + 'new dependency' => [['require' => ['php' => '^8.3', 'acme/lib' => '^1'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload']], false], +]); + +it('is not current when the package is not installed', function () { + expect(InstalledPackages::read($this->directory)->isCurrent('app/blog', []))->toBeFalse(); +}); diff --git a/tests/Unit/Scaffold/NamesTest.php b/tests/Unit/Scaffold/NamesTest.php new file mode 100644 index 0000000..f1d4132 --- /dev/null +++ b/tests/Unit/Scaffold/NamesTest.php @@ -0,0 +1,125 @@ +toBe($module); +})->with([ + ['blog', 'blog'], + ['Blog', 'blog'], + ['shop-order', 'shop-order'], + ['ShopOrder', 'shop-order'], + ['shopOrder', 'shop-order'], + ['shop_order', 'shop-order'], + ['shop order', 'shop-order'], + [' blog ', 'blog'], + ['api-v2', 'api-v2'], + ['shop-order-2', 'shop-order-2'], + ['ShopOrder2', 'shop-order2'], + ['API', 'api'], + ['HTTPClient', 'http-client'], + ['Shop-Order', 'shop-order'], + ['blog2', 'blog2'], + ['list', 'list'], + 'leading dash' => ['-blog', 'blog'], + 'trailing nul byte' => ["blog\0", 'blog'], +]); + +it('rejects module names that are not safe', function (string $name) { + expect(fn () => Names::module($name))->toThrow(InvalidName::class, 'Invalid module name'); +})->with([ + '', + ' ', + '../etc', + '..', + 'blog/../../etc', + 'blog\\evil', + '2fa', + 'blog"', + "blog'", + 'bl$og', + 'блог', + "bl\0og", + 'a/b', +]); + +it('validates vendors', function (string $vendor, bool $valid) { + $validate = fn () => Names::vendor($vendor); + + $valid ? expect($validate())->toBe($vendor) : expect($validate)->toThrow(InvalidName::class, 'Invalid vendor'); +})->with([ + ['app', true], + ['acme-corp', true], + ['acme.corp', true], + ['acme_corp', true], + ['2acme', true], + ['App', false], + ['-app', false], + ['app-', false], + ['a/b', false], + ['', false], + ['app"', false], + ['app,"evil":"1', false], +]); + +it('builds package names', function () { + expect(Names::package('app', 'ShopOrder'))->toBe('app/shop-order'); +}); + +it('validates package names with the Composer format', function (string $package, bool $valid) { + expect(Names::isPackage($package))->toBe($valid); + + $valid + ? expect(Names::assertPackage($package))->toBe($package) + : expect(fn () => Names::assertPackage($package))->toThrow(InvalidName::class, 'Invalid package name'); +})->with([ + ['app/blog', true], + ['app/shop-order', true], + ['acme.corp/shop--order', true], + ['app/blog_2', true], + ['app', false], + ['App/blog', false], + ['app/-blog', false], + ['app/blog/extra', false], + ['--dev', false], + ['-app/blog', false], + ["app/blog\n", false], + ['app/blog ', false], +]); + +it('builds the namespace of a module', function (string $prefix, string $module, string $namespace) { + expect(Names::namespace($prefix, $module))->toBe($namespace); +})->with([ + ['Modules', 'blog', 'Modules\\Blog'], + ['Modules', 'shop-order', 'Modules\\ShopOrder'], + ['\\App\\Modules\\', 'shop-order', 'App\\Modules\\ShopOrder'], +]); + +it('rejects an invalid namespace prefix', function (string $prefix) { + expect(fn () => Names::namespace($prefix, 'blog'))->toThrow(InvalidName::class, 'Invalid namespace'); +})->with(['', 'App\\\\Modules', '1Modules', 'App-Modules', 'App\\Modules;']); + +it('joins namespace segments without the empty ones', function (array $segments, string $namespace) { + expect(Names::qualify(...$segments))->toBe($namespace); +})->with([ + [['Modules\\Blog', 'Http\\Requests'], 'Modules\\Blog\\Http\\Requests'], + [['Modules\\Blog\\', '\\UI\\Components\\', 'Alert'], 'Modules\\Blog\\UI\\Components\\Alert'], + [['Modules\\Blog', '', 'Post'], 'Modules\\Blog\\Post'], + [['Modules\\Blog', '\\'], 'Modules\\Blog'], + [['Modules\\Blog', '0'], 'Modules\\Blog\\0'], +]); + +it('validates namespaces', function (string $namespace, bool $valid) { + expect(Names::isNamespace($namespace))->toBe($valid); +})->with([ + ['Modules', true], + ['Modules\\Blog', true], + ['_Modules\\Blog_2', true], + ['Modules\\', false], + ['\\Modules', false], + ['Modules\\2Blog', false], + ['', false], +]); diff --git a/tests/Unit/Scaffold/TemplateRendererTest.php b/tests/Unit/Scaffold/TemplateRendererTest.php new file mode 100644 index 0000000..be0a511 --- /dev/null +++ b/tests/Unit/Scaffold/TemplateRendererTest.php @@ -0,0 +1,168 @@ + 'article-category', + 'namespace' => 'Modules\\ArticleCategory', + 'package' => 'app/article-category', + 'vendor' => 'app', + 'date' => '2026_09_23_120000', +]; + +it('renders stubs, copies other files and keeps empty directories', function () { + $template = $this->files([ + 'composer.json.stub' => '{"name": "{{ package }}", "psr-4": "{{ namespace|json }}\\\\"}', + 'src/Models/.gitkeep' => '', + 'resources/views/{{ name }}.blade.php' => '{{ $title }} and {{ name }} stay as they are', + 'README.md.stub' => "# {{name}}\n", + ]); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES))->toBe([ + 'README.md' => "# article-category\n", + 'composer.json' => '{"name": "app/article-category", "psr-4": "Modules\\\\ArticleCategory\\\\"}', + 'resources/views/article-category.blade.php' => '{{ $title }} and {{ name }} stay as they are', + 'src/Models/.gitkeep' => '', + ]); +}); + +it('applies filters from left to right', function (string $placeholder, string $result) { + $template = $this->files(['file.stub' => $placeholder]); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)['file'])->toBe($result); +})->with([ + ['{{ name }}', 'article-category'], + ['{{ name|studly }}', 'ArticleCategory'], + ['{{ name|camel }}', 'articleCategory'], + ['{{ name|snake }}', 'article_category'], + ['{{ name|kebab }}', 'article-category'], + ['{{ name|studly|kebab }}', 'article-category'], + ['{{ name|plural }}', 'article-categories'], + ['{{ name|plural|snake }}', 'article_categories'], + ['{{ name|plural|studly }}', 'ArticleCategories'], + ['{{ name|studly|singular }}', 'ArticleCategory'], + ['{{ name|upper }}', 'ARTICLE-CATEGORY'], + ['{{ name|studly|lower }}', 'articlecategory'], + ['{{ name|title }}', 'Article-Category'], + ['{{ namespace|json }}', 'Modules\\\\ArticleCategory'], + ['{{ package|json }}', 'app/article-category'], + ['{{ name | studly }}', 'ArticleCategory'], + ['{{name|studly}}', 'ArticleCategory'], + ['{{ date }}_create_{{ name|plural|snake }}_table', '2026_09_23_120000_create_article_categories_table'], + ['{{ vendor }}/{{ name }}', 'app/article-category'], + ['{{ missing', '{{ missing'], + ['{ name }', '{ name }'], +]); + +it('renders placeholders in directory and file names', function () { + $template = $this->files([ + 'src/{{ name|studly }}/{{ name|studly }}Service.php.stub' => 'namespace {{ namespace }}\\{{ name|studly }};', + 'database/migrations/{{ date }}_create_{{ name|plural|snake }}_table.php' => 'render($template, TEMPLATE_VARIABLES))->toBe([ + 'database/migrations/2026_09_23_120000_create_article_categories_table.php' => ' 'namespace Modules\\ArticleCategory\\ArticleCategory;', + ]); +})->skip(DIRECTORY_SEPARATOR === '\\', 'Windows does not allow "|" in file names.'); + +it('fails on unknown variables and filters before anything is written', function (string $file, string $contents, string $reason) { + if (DIRECTORY_SEPARATOR === '\\' && str_contains($file, '|')) { + $this->markTestSkipped('Windows does not allow "|" in file names.'); + } + + $template = $this->files([$file => $contents]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, "Invalid module template [{$file}]: {$reason}"); +})->with([ + 'unknown variable' => ['a.stub', '{{ model }}', 'unknown variable [model].'], + 'unknown filter' => ['a.stub', '{{ name|reverse }}', 'unknown filter [reverse].'], + 'unknown variable in a path' => ['{{ model }}.php', '', 'unknown variable [model].'], + 'unknown filter in a path' => ['{{ name|slug }}.php', '', 'unknown filter [slug].'], +]); + +it('does not render placeholders in the contents of files without .stub', function () { + $template = $this->files(['view.blade.php' => '{{ model }}']); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES))->toBe(['view.blade.php' => '{{ model }}']); +}); + +it('refuses paths that would leave the module directory', function (array $variables, string $file) { + $template = $this->files([$file => '']); + + expect(fn () => (new TemplateRenderer)->render($template, [...TEMPLATE_VARIABLES, ...$variables])) + ->toThrow(InvalidTemplate::class, 'the path renders to an unsafe name'); +})->with([ + 'parent directory' => [['name' => '..'], '{{ name }}/evil.php'], + 'current directory' => [['name' => '.'], '{{ name }}/file.php'], + 'empty segment' => [['name' => ''], '{{ name }}/file.php'], + 'slash' => [['name' => '../../etc'], '{{ name }}.php'], + 'backslash' => [[], '{{ namespace }}.php'], + 'drive letter' => [['name' => 'C:'], '{{ name }}/file.php'], + 'nul byte' => [['name' => "evil\0"], '{{ name }}.php'], +]); + +it('refuses two files rendered to the same path', function () { + $template = $this->files([ + 'composer.json' => '{}', + 'composer.json.stub' => '{}', + ]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'another file is also rendered to [composer.json].'); +}); + +it('refuses a path rendered both as a file and as a directory', function () { + $template = $this->files([ + 'composer.json' => '{}', + '{{ name }}.stub' => '', + 'article-category/src/file.php' => '', + ]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'Invalid module template [{{ name }}.stub]: the file is rendered to [article-category], which is also the directory of [article-category/src/file.php].'); +}); + +it('accepts a template path with a trailing slash', function () { + $template = $this->files(['README.md.stub' => '{{ name }}']); + + expect((new TemplateRenderer)->render($template.'/', TEMPLATE_VARIABLES))->toBe(['README.md' => 'article-category']); +}); + +it('fails when the template directory does not exist', function () { + expect(fn () => (new TemplateRenderer)->render($this->directory.'/missing', TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'the directory does not exist.'); +}); + +it('renders the built-in template', function () { + $files = (new TemplateRenderer)->render(__DIR__.'/../../../resources/stubs/module/default', TEMPLATE_VARIABLES); + + expect(array_keys($files))->toBe([ + 'composer.json', + 'database/factories/.gitkeep', + 'database/migrations/.gitkeep', + 'database/seeders/.gitkeep', + 'src/Models/.gitkeep', + 'tests/.gitkeep', + ])->and(json_decode($files['composer.json'], true, 512, JSON_THROW_ON_ERROR))->toBe([ + 'name' => 'app/article-category', + 'description' => 'The article-category module.', + 'type' => 'library', + 'autoload' => [ + 'psr-4' => [ + 'Modules\\ArticleCategory\\' => 'src/', + 'Modules\\ArticleCategory\\Database\\Factories\\' => 'database/factories/', + 'Modules\\ArticleCategory\\Database\\Seeders\\' => 'database/seeders/', + ], + ], + 'autoload-dev' => [ + 'psr-4' => [ + 'Modules\\ArticleCategory\\Tests\\' => 'tests/', + ], + ], + ]); +}); diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php new file mode 100644 index 0000000..172df1c --- /dev/null +++ b/tests/UnitTestCase.php @@ -0,0 +1,45 @@ +directory = TemporaryDirectory::create(); + // Relative paths (and mutants that make paths relative) never reach the repository. + $this->workingDirectory = (string) getcwd(); + chdir($this->directory); + } + + protected function tearDown(): void + { + chdir($this->workingDirectory); + TemporaryDirectory::delete($this->directory); + + parent::tearDown(); + } + + /** + * @param array $files Contents by path relative to the directory. + */ + protected function files(array $files): string + { + foreach ($files as $path => $contents) { + is_dir($directory = dirname($this->directory.'/'.$path)) || mkdir($directory, 0777, true); + file_put_contents($this->directory.'/'.$path, $contents); + } + + return $this->directory; + } +} diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml deleted file mode 100644 index 386aaae..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml +++ /dev/null @@ -1,33 +0,0 @@ -- composer.json -- database/factories/ArticleCommentFactory.php -- database/migrations/2024_01_01_000000_create_article_comments_table.php -- database/seeders/ArticleCommentPermissionsSeeder_1.php -- src/Actions/CreateArticleCommentAction.php -- src/Actions/DeleteArticleCommentAction.php -- src/Actions/ListArticleCommentsAction.php -- src/Actions/UpdateArticleCommentAction.php -- src/Actions/ViewArticleCommentAction.php -- src/DTO/CreateArticleCommentDTO.php -- src/DTO/UpdateArticleCommentDTO.php -- src/Models/ArticleComment.php -- src/Policies/ArticleCommentPolicy.php -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php -- src/UI/API/QueryWizards/ArticleCommentQueryWizard.php -- src/UI/API/QueryWizards/ArticleCommentsQueryWizard.php -- src/UI/API/Requests/CreateArticleCommentRequest.php -- src/UI/API/Requests/DeleteArticleCommentRequest.php -- src/UI/API/Requests/ListArticleCommentsRequest.php -- src/UI/API/Requests/UpdateArticleCommentRequest.php -- src/UI/API/Requests/ViewArticleCommentRequest.php -- src/UI/API/Resources/ArticleCommentResource.php -- src/UI/API/routes/v1/create_article_comment.php -- src/UI/API/routes/v1/delete_article_comment.php -- src/UI/API/routes/v1/list_article_comments.php -- src/UI/API/routes/v1/update_article_comment.php -- src/UI/API/routes/v1/view_article_comment.php -- tests/UI/API/CreateArticleCommentTest.php -- tests/UI/API/DeleteArticleCommentTest.php -- tests/UI/API/ListArticleCommentsTest.php -- tests/UI/API/UpdateArticleCommentTest.php -- tests/UI/API/ViewArticleCommentTest.php diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml deleted file mode 100644 index d03888b..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml +++ /dev/null @@ -1,8 +0,0 @@ -- composer.json -- database/factories/ArticleCommentFactory.php -- database/migrations/2024_01_01_000000_create_article_comments_table.php -- database/seeders/ArticleCommentPermissionsSeeder_1.php -- src/Models/ArticleComment.php -- src/Policies/ArticleCommentPolicy.php -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml deleted file mode 100644 index 173b667..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml +++ /dev/null @@ -1,3 +0,0 @@ -- composer.json -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php diff --git a/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml b/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml deleted file mode 100644 index 2d90c51..0000000 --- a/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml +++ /dev/null @@ -1,63 +0,0 @@ -- action/create.stub -- action/delete.stub -- action/list.stub -- action/plain.stub -- action/update.stub -- action/view.stub -- command.stub -- composer.json.stub -- controller/api.stub -- controller/web.stub -- dto.stub -- event.stub -- exception.stub -- factory.stub -- job/plain.stub -- job/queued.stub -- listener/plain.stub -- listener/queued.stub -- mail.stub -- middleware.stub -- migration/add.stub -- migration/create.stub -- migration/delete.stub -- migration/pivot.stub -- migration/plain.stub -- model/full.stub -- model/plain.stub -- notification/plain.stub -- notification/queued.stub -- observer.stub -- policy/full.stub -- policy/plain.stub -- provider/event.stub -- provider/module.stub -- provider/plain.stub -- provider/route.stub -- query-wizard/eloquent.stub -- query-wizard/model.stub -- request/create.stub -- request/delete.stub -- request/list.stub -- request/plain.stub -- request/update.stub -- request/view.stub -- resource/collection.stub -- resource/single.stub -- route.stub -- rule.stub -- seeder/permissions.stub -- seeder/plain.stub -- test/api/create.stub -- test/api/delete.stub -- test/api/list.stub -- test/api/plain.stub -- test/api/update.stub -- test/api/view.stub -- test/cli/plain.stub -- test/feature/plain.stub -- test/unit/plain.stub -- test/web/create.stub -- test/web/delete.stub -- test/web/plain.stub -- test/web/update.stub diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php deleted file mode 100644 index ed01161..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController(CreateAuthorRequest $request): JsonResponse - { - $author = $this->handle($request->toDTO()); - - return (new AuthorResource($author))->created(); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php deleted file mode 100644 index 0f8b701..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController(DeleteAuthorRequest $request, Author $author): Response - { - $this->handle($author); - - return $this->noContent(); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php deleted file mode 100644 index 1a51d18..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController(ListAuthorsRequest $request): AnonymousResourceCollection - { - return AuthorResource::collection($this->handle($request)); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php deleted file mode 100644 index 148c7ab..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php +++ /dev/null @@ -1,15 +0,0 @@ -all(); - - if ($data) { - $author->update($data); - } - - return $author; - } - - public function asController(UpdateAuthorRequest $request, Author $author): AuthorResource - { - $author = $this->handle($author, $request->toDTO()); - - return new AuthorResource($author); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php deleted file mode 100644 index 3443c4b..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController(ViewAuthorRequest $request, Author $author): AuthorResource - { - return new AuthorResource($this->handle($request, $author)); - } -} diff --git a/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php b/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php deleted file mode 100644 index 0f87a39..0000000 --- a/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php +++ /dev/null @@ -1,30 +0,0 @@ -|Author create($attributes = [], ?Author $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method Author createOne($attributes = []) - * @method \Illuminate\Support\Collection|Author make($attributes = [], ?Author $parent = null) - * @method Author makeOne($attributes = []) - */ -class SomeAuthorFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = Author::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php b/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php deleted file mode 100644 index eb911c8..0000000 --- a/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function attachments(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php b/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php deleted file mode 100644 index d2f58c3..0000000 --- a/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -string('title'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->dropColumn('title'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php deleted file mode 100644 index a1fea11..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->string('title'); - $table->text('excerpt'); - $table->text('content'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('articles'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php deleted file mode 100644 index 02dbdeb..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php +++ /dev/null @@ -1,30 +0,0 @@ -dropColumn('title'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->string('title'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php deleted file mode 100644 index 34f727a..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ -string('title'); - $table->text('excerpt'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->dropColumn('title'); - $table->dropColumn('excerpt'); - $table->dropColumn('user_id'); - $table->dropForeign(['user_id']); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php deleted file mode 100644 index 11a9d71..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->string('title'); - $table->text('excerpt'); - $table->text('content'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('articles'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php deleted file mode 100644 index 72d9761..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ -dropColumn('title'); - $table->dropColumn('excerpt'); - $table->dropColumn('user_id'); - $table->dropForeign(['user_id']); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->string('title'); - $table->text('excerpt'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php deleted file mode 100644 index 11a9d71..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php +++ /dev/null @@ -1,28 +0,0 @@ -unsignedBigInteger('article_id')->index(); - $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade'); - $table->unsignedBigInteger('author_id')->index(); - $table->foreign('author_id')->references('id')->on('authors')->onDelete('cascade'); - $table->primary(['article_id', 'author_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_author'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php deleted file mode 100644 index 88fa2f3..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php +++ /dev/null @@ -1,24 +0,0 @@ -all(); - - if ($data) { - $articleComment->update($data); - } - - return $articleComment; - } - - public function asController(UpdateArticleCommentRequest $request, ArticleComment $articleComment): ArticleCommentResource - { - $articleComment = $this->handle($articleComment, $request->toDTO()); - - return new ArticleCommentResource($articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php deleted file mode 100644 index 74f5661..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController(ViewArticleCommentRequest $request, ArticleComment $articleComment): ArticleCommentResource - { - return new ArticleCommentResource($this->handle($request, $articleComment)); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php deleted file mode 100644 index 3bbcd57..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php +++ /dev/null @@ -1,13 +0,0 @@ -can('view-article-comment'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, ArticleComment $articleComment): bool - { - return $user->can('view-article-comment'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-article-comment'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, ArticleComment $articleComment): bool - { - return $user->can('update-article-comment'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, ArticleComment $articleComment): bool - { - return $user->can('force-delete-article-comment'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php deleted file mode 100644 index 89a3dec..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php deleted file mode 100644 index 804bc9e..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php deleted file mode 100644 index 0c198a3..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php deleted file mode 100644 index 2710975..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('delete', $articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php deleted file mode 100644 index 16b668a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('update', $articleComment); - } - - public function toDTO(): UpdateArticleCommentDTO - { - return UpdateArticleCommentDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php deleted file mode 100644 index 814263f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('view', $articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php deleted file mode 100644 index 368a4fd..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php +++ /dev/null @@ -1,17 +0,0 @@ -name('api.article_comments.create'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php deleted file mode 100644 index 3098b2d..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.delete'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php deleted file mode 100644 index 2fc2cd8..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.list'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php deleted file mode 100644 index 872915f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.update'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php deleted file mode 100644 index b6557d5..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.view'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php deleted file mode 100644 index d7c37e4..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php +++ /dev/null @@ -1,79 +0,0 @@ - 'create-article-comment', - 'roles' => '', - ]; - - public function test_create_article_comment(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas(ArticleComment::class, $data); - } - - public function test_create_article_comment_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('api.article_comments.create'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_article_comment_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertUnauthorized(); - } - - public function test_create_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php deleted file mode 100644 index 0bf472e..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-article-comment', - 'roles' => '', - ]; - - public function test_delete_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertNoContent(); - - $this->assertNull(ArticleComment::find($articleComment->getKey())); - } - - public function test_delete_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php deleted file mode 100644 index 467f0e4..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php +++ /dev/null @@ -1,57 +0,0 @@ - 'view-article-comment', - 'roles' => '', - ]; - - public function test_list_article_comments(): void - { - $this->actingAsTestUser(); - - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_article_comments_unauthenticated(): void - { - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertUnauthorized(); - } - - public function test_list_article_comments_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php deleted file mode 100644 index cabac0f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php +++ /dev/null @@ -1,99 +0,0 @@ - 'update-article-comment', - 'roles' => '', - ]; - - public function test_update_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $articleComment->getKey(), - ]); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas(ArticleComment::class, $expectedData); - } - - public function test_update_article_comment_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php deleted file mode 100644 index 839d499..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php +++ /dev/null @@ -1,67 +0,0 @@ - 'view-article-comment', - 'roles' => '', - ]; - - public function test_view_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - $expectedData = $articleComment->toArray(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php deleted file mode 100644 index 28b67d9..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php +++ /dev/null @@ -1,33 +0,0 @@ -|ArticleComment create($attributes = [], ?ArticleComment $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method ArticleComment createOne($attributes = []) - * @method \Illuminate\Support\Collection|ArticleComment make($attributes = [], ?ArticleComment $parent = null) - * @method ArticleComment makeOne($attributes = []) - */ -class ArticleCommentFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = ArticleComment::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php deleted file mode 100644 index 6f9bd1b..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_comments'); - } -}; diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php deleted file mode 100644 index 7e1e80a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php +++ /dev/null @@ -1,45 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-article-comment', - display_name: 'View any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-article-comment', - display_name: 'Create "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-article-comment', - display_name: 'Update any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-article-comment', - display_name: 'Delete any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-article-comment', - display_name: 'Force delete any "article-comments"', - group: 'article-comments' - )); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php deleted file mode 100644 index 27192d2..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController(CreateArticleCommentRequest $request): JsonResponse - { - $articleComment = $this->handle($request->toDTO()); - - return (new ArticleCommentResource($articleComment))->created(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php deleted file mode 100644 index 710f7e1..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController(DeleteArticleCommentRequest $request, ArticleComment $articleComment): Response - { - $this->handle($articleComment); - - return $this->noContent(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php deleted file mode 100644 index 865dd73..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController(ListArticleCommentsRequest $request): AnonymousResourceCollection - { - return ArticleCommentResource::collection($this->handle($request)); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json deleted file mode 100644 index 6af9462..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "demo/article-comment": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "demo/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php deleted file mode 100644 index 28b67d9..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php +++ /dev/null @@ -1,33 +0,0 @@ -|ArticleComment create($attributes = [], ?ArticleComment $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method ArticleComment createOne($attributes = []) - * @method \Illuminate\Support\Collection|ArticleComment make($attributes = [], ?ArticleComment $parent = null) - * @method ArticleComment makeOne($attributes = []) - */ -class ArticleCommentFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = ArticleComment::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php deleted file mode 100644 index 6f9bd1b..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_comments'); - } -}; diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php deleted file mode 100644 index 7e1e80a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php +++ /dev/null @@ -1,45 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-article-comment', - display_name: 'View any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-article-comment', - display_name: 'Create "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-article-comment', - display_name: 'Update any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-article-comment', - display_name: 'Delete any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-article-comment', - display_name: 'Force delete any "article-comments"', - group: 'article-comments' - )); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php deleted file mode 100644 index 1b97872..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php +++ /dev/null @@ -1,33 +0,0 @@ -can('view-article-comment'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, ArticleComment $articleComment): bool - { - return $user->can('view-article-comment'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-article-comment'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, ArticleComment $articleComment): bool - { - return $user->can('update-article-comment'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, ArticleComment $articleComment): bool - { - return $user->can('force-delete-article-comment'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json deleted file mode 100644 index 6af9462..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "demo/article-comment": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "demo/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json deleted file mode 100644 index ebe9ac3..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade", - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json deleted file mode 100644 index f9631aa..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json deleted file mode 100644 index 4491948..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider", - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json deleted file mode 100644 index af000ba..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json b/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json b/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json deleted file mode 100644 index 8505b7d..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json b/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json b/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json deleted file mode 100644 index ab6c0e8..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json deleted file mode 100644 index 8505b7d..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json deleted file mode 100644 index ab6c0e8..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json b/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json deleted file mode 100644 index 1977705..0000000 --- a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json b/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json deleted file mode 100644 index 4c694c0..0000000 --- a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laraneat/article": "*", - "laraneat/article-category": "*", - "laraneat/author": "*", - "laraneat/empty": "*", - "laraneat/location": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "laraneat/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php b/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php deleted file mode 100644 index 1b65cc0..0000000 --- a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail', 'database']; - } - - /** - * Get the array representation of the notification. - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php b/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php deleted file mode 100644 index 14891e9..0000000 --- a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php b/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php deleted file mode 100644 index 5fc6ec5..0000000 --- a/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php +++ /dev/null @@ -1,48 +0,0 @@ -can('view-author'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, Author $author): bool - { - return $user->can('view-author'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-author'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Author $author): bool - { - return $user->can('update-author'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Author $author): bool - { - return $user->can('delete-author'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, Author $author): bool - { - return $user->can('delete-author'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, Author $author): bool - { - return $user->can('force-delete-author'); - } -} diff --git a/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php b/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php deleted file mode 100644 index 00de928..0000000 --- a/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php +++ /dev/null @@ -1,8 +0,0 @@ -> - */ - protected $listen = []; - - /** - * Register any events for your application. - */ - public function boot(): void - { - // - } -} diff --git a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php b/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php deleted file mode 100644 index 184b779..0000000 --- a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/author.php'; - $configsPath = $this->app->configPath('author.php'); - - $this->mergeConfigFrom($sourcePath, 'author'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'author-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'author-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Author\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/author'); - - $this->loadTranslationsFrom($sourcePath, 'author'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'author-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/author'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('author'), [$sourcePath]), - 'author' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'author-views'); - } -} diff --git a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php b/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php deleted file mode 100644 index f5a2650..0000000 --- a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php +++ /dev/null @@ -1,24 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Author\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Author\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php b/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php deleted file mode 100644 index 63553d5..0000000 --- a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php b/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php deleted file mode 100644 index 3a876bd..0000000 --- a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php deleted file mode 100644 index c17fe79..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php deleted file mode 100644 index 80d4521..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php deleted file mode 100644 index d382341..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('delete', $author); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php deleted file mode 100644 index 057de9f..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('delete', $author); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php deleted file mode 100644 index db74862..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('update', $author); - } - - public function toDTO(): UpdateAuthorDTO - { - return UpdateAuthorDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php deleted file mode 100644 index 4e6f3b3..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -route('author'); - return $author && Gate::check('update', $author); - } - - public function toDTO(): UpdateAuthorDTO - { - return UpdateAuthorDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php deleted file mode 100644 index 3131436..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('view', $author); - } -} diff --git a/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php b/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php deleted file mode 100644 index 66593f2..0000000 --- a/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php +++ /dev/null @@ -1,17 +0,0 @@ -name('api.authors.delete'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php deleted file mode 100644 index 951f87e..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.delete'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php deleted file mode 100644 index e38cc25..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.list'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php deleted file mode 100644 index e38cc25..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.list'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php deleted file mode 100644 index 5369614..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.options'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php deleted file mode 100644 index 5dd3e59..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.options'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php deleted file mode 100644 index 640858f..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php deleted file mode 100644 index fda74ad..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php deleted file mode 100644 index 57b2069..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.create'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php deleted file mode 100644 index e031af6..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.create'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php deleted file mode 100644 index dcd1387..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php deleted file mode 100644 index 34f88ba..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.update'); diff --git a/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php b/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php deleted file mode 100644 index 069e26b..0000000 --- a/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php +++ /dev/null @@ -1,19 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-author', - display_name: 'View any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-author', - display_name: 'Create "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-author', - display_name: 'Update any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-author', - display_name: 'Delete any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-author', - display_name: 'Force delete any "authors"', - group: 'authors' - )); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates__unit__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates__unit__test_for_the_module__1.php deleted file mode 100644 index 5f51708..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates__unit__test_for_the_module__1.php +++ /dev/null @@ -1,16 +0,0 @@ - 'create-author', - 'roles' => '', - ]; - - public function test_create_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas(Author::class, $data); - } - - public function test_create_author_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('api.authors.create'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_author_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertUnauthorized(); - } - - public function test_create_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php deleted file mode 100644 index 5b03452..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php +++ /dev/null @@ -1,49 +0,0 @@ - 'create-author', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_create_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->post(route('web.authors.create'), $data) - ->assertCreated(); - - $this->assertDatabaseHas(Author::class, $data); - } - - public function test_create_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->post(route('web.authors.create'), $data) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php deleted file mode 100644 index 1b8975a..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-author', - 'roles' => '', - ]; - - public function test_delete_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertNoContent(); - - $this->assertNull(Author::find($author->getKey())); - } - - public function test_delete_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('api.authors.delete', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php deleted file mode 100644 index 102d004..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php +++ /dev/null @@ -1,50 +0,0 @@ - 'delete-author', - 'roles' => '', - ]; - - public function test_delete_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->delete(route('web.authors.delete', ['author' => $author->getKey()])) - ->assertNoContent(); - - $this->assertNull(Author::find($author->getKey())); - } - - public function test_delete_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->delete(route('web.authors.delete', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->delete(route('web.authors.delete', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php deleted file mode 100644 index d15bf85..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php +++ /dev/null @@ -1,57 +0,0 @@ - 'view-author', - 'roles' => '', - ]; - - public function test_list_authors(): void - { - $this->actingAsTestUser(); - - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_authors_unauthenticated(): void - { - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertUnauthorized(); - } - - public function test_list_authors_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php deleted file mode 100644 index 63a8cdb..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php +++ /dev/null @@ -1,27 +0,0 @@ - '', - 'roles' => '', - ]; - - public function test(): void - { - // - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php deleted file mode 100644 index 777d3fb..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php +++ /dev/null @@ -1,16 +0,0 @@ - 'update-author', - 'roles' => '', - ]; - - public function test_update_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $author->getKey(), - ]); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas(Author::class, $expectedData); - } - - public function test_update_author_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php deleted file mode 100644 index 309002a..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php +++ /dev/null @@ -1,66 +0,0 @@ - 'update-author', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_update_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $author->getKey(), - ]); - - $this->patch(route('web.authors.update', ['author' => $author->getKey()]), $data) - ->assertOk(); - - $this->assertDatabaseHas(Author::class, $expectedData); - } - - public function test_update_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patch(route('web.authors.update', ['author' => $author->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patch(route('web.authors.update', ['author' => 7777]), $data) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php deleted file mode 100644 index ec20f2d..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php +++ /dev/null @@ -1,67 +0,0 @@ - 'view-author', - 'roles' => '', - ]; - - public function test_view_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - $expectedData = $author->toArray(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('api.authors.view', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/fixtures/laravel/.gitignore b/tests/fixtures/laravel/.gitignore deleted file mode 100644 index 8d29ab4..0000000 --- a/tests/fixtures/laravel/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -modules -vendor diff --git a/tests/fixtures/laravel/bootstrap/app.php b/tests/fixtures/laravel/bootstrap/app.php deleted file mode 100644 index 151dd2e..0000000 --- a/tests/fixtures/laravel/bootstrap/app.php +++ /dev/null @@ -1,22 +0,0 @@ - ['APP_KEY="'.$APP_KEY.'"', 'DB_CONNECTION="'.$DB_CONNECTION.'"'], 'providers' => []]; - -$app = (new Commander($config, getcwd()))->laravel(); - -unset($APP_KEY, $DB_CONNECTION, $config); - -$router = $app->make('router'); - -collect(glob(__DIR__.'/../routes/testbench-*.php')) - ->each(function ($routeFile) use ($app, $router) { - require $routeFile; - }); - - -return $app; diff --git a/tests/fixtures/laravel/bootstrap/cache/.gitignore b/tests/fixtures/laravel/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/composer.json b/tests/fixtures/laravel/composer.json deleted file mode 100644 index 1977705..0000000 --- a/tests/fixtures/laravel/composer.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/tests/fixtures/laravel/config/app.php b/tests/fixtures/laravel/config/app.php deleted file mode 100644 index 1bd9f73..0000000 --- a/tests/fixtures/laravel/config/app.php +++ /dev/null @@ -1,234 +0,0 @@ - env('APP_NAME', 'Laravel'), - - /* - |-------------------------------------------------------------------------- - | Application Environment - |-------------------------------------------------------------------------- - | - | This value determines the "environment" your application is currently - | running in. This may determine how you prefer to configure various - | services the application utilizes. Set this in your ".env" file. - | - */ - - 'env' => env('APP_ENV', 'testing'), - - /* - |-------------------------------------------------------------------------- - | Application Debug Mode - |-------------------------------------------------------------------------- - | - | When your application is in debug mode, detailed error messages with - | stack traces will be shown on every error that occurs within your - | application. If disabled, a simple generic error page is shown. - | - */ - - 'debug' => (bool) env('APP_DEBUG', false), - - /* - |-------------------------------------------------------------------------- - | Application URL - |-------------------------------------------------------------------------- - | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | your application so that it is used when running Artisan tasks. - | - */ - - 'url' => env('APP_URL', 'http://localhost'), - - 'asset_url' => env('ASSET_URL', null), - - /* - |-------------------------------------------------------------------------- - | Application Timezone - |-------------------------------------------------------------------------- - | - | Here you may specify the default timezone for your application, which - | will be used by the PHP date and date-time functions. We have gone - | ahead and set this to a sensible default for you out of the box. - | - */ - - 'timezone' => 'UTC', - - /* - |-------------------------------------------------------------------------- - | Application Locale Configuration - |-------------------------------------------------------------------------- - | - | The application locale determines the default locale that will be used - | by the translation service provider. You are free to set this value - | to any of the locales which will be supported by the application. - | - */ - - 'locale' => 'en', - - /* - |-------------------------------------------------------------------------- - | Application Fallback Locale - |-------------------------------------------------------------------------- - | - | The fallback locale determines the locale to use when the current one - | is not available. You may change the value to correspond to any of - | the language folders that are provided through your application. - | - */ - - 'fallback_locale' => 'en', - - /* - |-------------------------------------------------------------------------- - | Faker Locale - |-------------------------------------------------------------------------- - | - | This locale will be used by the Faker PHP library when generating fake - | data for your database seeds. For example, this will be used to get - | localized telephone numbers, street address information and more. - | - */ - - 'faker_locale' => 'en_US', - - /* - |-------------------------------------------------------------------------- - | Encryption Key - |-------------------------------------------------------------------------- - | - | This key is used by the Illuminate encrypter service and should be set - | to a random, 32 character string, otherwise these encrypted strings - | will not be safe. Please do this before deploying an application! - | - */ - - 'key' => env('APP_KEY'), - - 'cipher' => 'AES-256-CBC', - - /* - |-------------------------------------------------------------------------- - | Autoloaded Service Providers - |-------------------------------------------------------------------------- - | - | The service providers listed here will be automatically loaded on the - | request to your application. Feel free to add your own services to - | this array to grant expanded functionality to your applications. - | - */ - - 'providers' => [ - - /* - * Laravel Framework Service Providers... - */ - Illuminate\Auth\AuthServiceProvider::class, - Illuminate\Broadcasting\BroadcastServiceProvider::class, - Illuminate\Bus\BusServiceProvider::class, - Illuminate\Cache\CacheServiceProvider::class, - Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, - Illuminate\Cookie\CookieServiceProvider::class, - Illuminate\Database\DatabaseServiceProvider::class, - Illuminate\Encryption\EncryptionServiceProvider::class, - Illuminate\Filesystem\FilesystemServiceProvider::class, - Illuminate\Foundation\Providers\FoundationServiceProvider::class, - Illuminate\Hashing\HashServiceProvider::class, - Illuminate\Mail\MailServiceProvider::class, - Illuminate\Notifications\NotificationServiceProvider::class, - Illuminate\Pagination\PaginationServiceProvider::class, - Illuminate\Pipeline\PipelineServiceProvider::class, - Illuminate\Queue\QueueServiceProvider::class, - Illuminate\Redis\RedisServiceProvider::class, - Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, - Illuminate\Session\SessionServiceProvider::class, - Illuminate\Translation\TranslationServiceProvider::class, - Illuminate\Validation\ValidationServiceProvider::class, - Illuminate\View\ViewServiceProvider::class, - - /* - * Package Service Providers... - */ - - /* - * Application Service Providers... - */ - // App\Providers\AppServiceProvider::class, - // App\Providers\AuthServiceProvider::class, - // // App\Providers\BroadcastServiceProvider::class, - // App\Providers\EventServiceProvider::class, - // App\Providers\RouteServiceProvider::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Class Aliases - |-------------------------------------------------------------------------- - | - | This array of class aliases will be registered when this application - | is started. However, feel free to register as many as you wish as - | the aliases are "lazy" loaded so they don't hinder performance. - | - */ - - 'aliases' => [ - - 'App' => Illuminate\Support\Facades\App::class, - 'Arr' => Illuminate\Support\Arr::class, - 'Artisan' => Illuminate\Support\Facades\Artisan::class, - 'Auth' => Illuminate\Support\Facades\Auth::class, - 'Blade' => Illuminate\Support\Facades\Blade::class, - 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, - 'Bus' => Illuminate\Support\Facades\Bus::class, - 'Cache' => Illuminate\Support\Facades\Cache::class, - 'Config' => Illuminate\Support\Facades\Config::class, - 'Cookie' => Illuminate\Support\Facades\Cookie::class, - 'Crypt' => Illuminate\Support\Facades\Crypt::class, - 'Date' => Illuminate\Support\Facades\Date::class, - 'DB' => Illuminate\Support\Facades\DB::class, - 'Eloquent' => Illuminate\Database\Eloquent\Model::class, - 'Event' => Illuminate\Support\Facades\Event::class, - 'File' => Illuminate\Support\Facades\File::class, - 'Gate' => Illuminate\Support\Facades\Gate::class, - 'Hash' => Illuminate\Support\Facades\Hash::class, - 'Http' => Illuminate\Support\Facades\Http::class, - 'Lang' => Illuminate\Support\Facades\Lang::class, - 'Log' => Illuminate\Support\Facades\Log::class, - 'Mail' => Illuminate\Support\Facades\Mail::class, - 'Notification' => Illuminate\Support\Facades\Notification::class, - 'Password' => Illuminate\Support\Facades\Password::class, - 'Queue' => Illuminate\Support\Facades\Queue::class, - 'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class, - 'Redirect' => Illuminate\Support\Facades\Redirect::class, - // 'Redis' => Illuminate\Support\Facades\Redis::class, - 'Request' => Illuminate\Support\Facades\Request::class, - 'Response' => Illuminate\Support\Facades\Response::class, - 'Route' => Illuminate\Support\Facades\Route::class, - 'Schema' => Illuminate\Support\Facades\Schema::class, - 'Session' => Illuminate\Support\Facades\Session::class, - 'Storage' => Illuminate\Support\Facades\Storage::class, - 'Str' => Illuminate\Support\Str::class, - 'URL' => Illuminate\Support\Facades\URL::class, - 'Validator' => Illuminate\Support\Facades\Validator::class, - 'View' => Illuminate\Support\Facades\View::class, - - ], - -]; diff --git a/tests/fixtures/laravel/config/auth.php b/tests/fixtures/laravel/config/auth.php deleted file mode 100644 index 0285fdf..0000000 --- a/tests/fixtures/laravel/config/auth.php +++ /dev/null @@ -1,111 +0,0 @@ - [ - 'guard' => 'web', - 'passwords' => 'users', - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | here which uses session storage and the Eloquent user provider. - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | If you have multiple user tables or models you may configure multiple - | sources which represent each model / table. These sources may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => Illuminate\Foundation\Auth\User::class, - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | You may specify multiple password reset configurations if you have more - | than one user table or model in the application and you want to have - | separate password reset settings based on the specific user types. - | - | The expire time is the number of minutes that the reset token should be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => 'password_resets', - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the amount of seconds before a password confirmation - | times out and the user is prompted to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => 10800, - -]; diff --git a/tests/fixtures/laravel/config/broadcasting.php b/tests/fixtures/laravel/config/broadcasting.php deleted file mode 100644 index 2d52982..0000000 --- a/tests/fixtures/laravel/config/broadcasting.php +++ /dev/null @@ -1,64 +0,0 @@ - env('BROADCAST_DRIVER', 'null'), - - /* - |-------------------------------------------------------------------------- - | Broadcast Connections - |-------------------------------------------------------------------------- - | - | Here you may define all of the broadcast connections that will be used - | to broadcast events to other systems or over websockets. Samples of - | each available type of connection are provided inside this array. - | - */ - - 'connections' => [ - - 'pusher' => [ - 'driver' => 'pusher', - 'key' => env('PUSHER_APP_KEY'), - 'secret' => env('PUSHER_APP_SECRET'), - 'app_id' => env('PUSHER_APP_ID'), - 'options' => [ - 'cluster' => env('PUSHER_APP_CLUSTER'), - 'useTLS' => true, - ], - ], - - 'ably' => [ - 'driver' => 'ably', - 'key' => env('ABLY_KEY'), - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - ], - - 'log' => [ - 'driver' => 'log', - ], - - 'null' => [ - 'driver' => 'null', - ], - - ], - -]; diff --git a/tests/fixtures/laravel/config/cache.php b/tests/fixtures/laravel/config/cache.php deleted file mode 100644 index 35b9068..0000000 --- a/tests/fixtures/laravel/config/cache.php +++ /dev/null @@ -1,110 +0,0 @@ - env('CACHE_DRIVER', 'array'), - - /* - |-------------------------------------------------------------------------- - | Cache Stores - |-------------------------------------------------------------------------- - | - | Here you may define all of the cache "stores" for your application as - | well as their drivers. You may even define multiple stores for the - | same cache driver to group types of items stored in your caches. - | - | Supported drivers: "apc", "array", "database", "file", - | "memcached", "redis", "dynamodb", "octane", "null" - | - */ - - 'stores' => [ - - 'apc' => [ - 'driver' => 'apc', - ], - - 'array' => [ - 'driver' => 'array', - 'serialize' => false, - ], - - 'database' => [ - 'driver' => 'database', - 'table' => 'cache', - 'connection' => null, - 'lock_connection' => null, - ], - - 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), - ], - - 'memcached' => [ - 'driver' => 'memcached', - 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ - env('MEMCACHED_USERNAME'), - env('MEMCACHED_PASSWORD'), - ], - 'options' => [ - // Memcached::OPT_CONNECT_TIMEOUT => 2000, - ], - 'servers' => [ - [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), - 'weight' => 100, - ], - ], - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'cache', - 'lock_connection' => 'default', - ], - - 'dynamodb' => [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), - 'endpoint' => env('DYNAMODB_ENDPOINT'), - ], - - 'octane' => [ - 'driver' => 'octane', - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Cache Key Prefix - |-------------------------------------------------------------------------- - | - | When utilizing a RAM based store such as APC or Memcached, there might - | be other applications utilizing the same cache. So, we'll specify a - | value to get prefixed to all our keys so we can avoid collisions. - | - */ - - 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'), - -]; diff --git a/tests/fixtures/laravel/config/cors.php b/tests/fixtures/laravel/config/cors.php deleted file mode 100644 index 8a39e6d..0000000 --- a/tests/fixtures/laravel/config/cors.php +++ /dev/null @@ -1,34 +0,0 @@ - ['api/*', 'sanctum/csrf-cookie'], - - 'allowed_methods' => ['*'], - - 'allowed_origins' => ['*'], - - 'allowed_origins_patterns' => [], - - 'allowed_headers' => ['*'], - - 'exposed_headers' => [], - - 'max_age' => 0, - - 'supports_credentials' => false, - -]; diff --git a/tests/fixtures/laravel/config/database.php b/tests/fixtures/laravel/config/database.php deleted file mode 100644 index 426fc9a..0000000 --- a/tests/fixtures/laravel/config/database.php +++ /dev/null @@ -1,153 +0,0 @@ - env('DB_CONNECTION', 'mysql'), - - /* - |-------------------------------------------------------------------------- - | Database Connections - |-------------------------------------------------------------------------- - | - | Here are each of the database connections setup for your application. - | Of course, examples of configuring each database platform that is - | supported by Laravel is shown below to make development simple. - | - | - | All database work in Laravel is done through the PHP PDO facilities - | so make sure you have the driver for your particular database of - | choice installed on your machine before you begin development. - | - */ - - 'connections' => [ - - 'testing' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', false), - ], - - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - 'schema' => 'public', - 'sslmode' => 'prefer', - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run in the database. - | - */ - - 'migrations' => 'migrations', - - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as APC or Memcached. Laravel makes it easy to dig right in. - | - */ - - 'redis' => [ - - 'client' => env('REDIS_CLIENT', 'phpredis'), - - 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), - ], - - 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), - ], - - 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_CACHE_DB', '1'), - ], - - ], - -]; diff --git a/tests/fixtures/laravel/config/filesystems.php b/tests/fixtures/laravel/config/filesystems.php deleted file mode 100644 index 760ef97..0000000 --- a/tests/fixtures/laravel/config/filesystems.php +++ /dev/null @@ -1,73 +0,0 @@ - env('FILESYSTEM_DRIVER', 'local'), - - /* - |-------------------------------------------------------------------------- - | Filesystem Disks - |-------------------------------------------------------------------------- - | - | Here you may configure as many filesystem "disks" as you wish, and you - | may even configure multiple disks of the same driver. Defaults have - | been setup for each driver as an example of the required options. - | - | Supported Drivers: "local", "ftp", "sftp", "s3" - | - */ - - 'disks' => [ - - 'local' => [ - 'driver' => 'local', - 'root' => storage_path('app'), - ], - - 'public' => [ - 'driver' => 'local', - 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', - 'visibility' => 'public', - ], - - 's3' => [ - 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Symbolic Links - |-------------------------------------------------------------------------- - | - | Here you may configure the symbolic links that will be created when the - | `storage:link` Artisan command is executed. The array keys should be - | the locations of the links and the values should be their targets. - | - */ - - 'links' => [ - public_path('storage') => storage_path('app/public'), - ], - -]; diff --git a/tests/fixtures/laravel/config/hashing.php b/tests/fixtures/laravel/config/hashing.php deleted file mode 100644 index 8425770..0000000 --- a/tests/fixtures/laravel/config/hashing.php +++ /dev/null @@ -1,52 +0,0 @@ - 'bcrypt', - - /* - |-------------------------------------------------------------------------- - | Bcrypt Options - |-------------------------------------------------------------------------- - | - | Here you may specify the configuration options that should be used when - | passwords are hashed using the Bcrypt algorithm. This will allow you - | to control the amount of time it takes to hash the given password. - | - */ - - 'bcrypt' => [ - 'rounds' => env('BCRYPT_ROUNDS', 10), - ], - - /* - |-------------------------------------------------------------------------- - | Argon Options - |-------------------------------------------------------------------------- - | - | Here you may specify the configuration options that should be used when - | passwords are hashed using the Argon algorithm. These will allow you - | to control the amount of time it takes to hash the given password. - | - */ - - 'argon' => [ - 'memory' => 1024, - 'threads' => 2, - 'time' => 2, - ], - -]; diff --git a/tests/fixtures/laravel/config/logging.php b/tests/fixtures/laravel/config/logging.php deleted file mode 100644 index 1aa06aa..0000000 --- a/tests/fixtures/laravel/config/logging.php +++ /dev/null @@ -1,105 +0,0 @@ - env('LOG_CHANNEL', 'stack'), - - /* - |-------------------------------------------------------------------------- - | Log Channels - |-------------------------------------------------------------------------- - | - | Here you may configure the log channels for your application. Out of - | the box, Laravel uses the Monolog PHP logging library. This gives - | you a variety of powerful log handlers / formatters to utilize. - | - | Available Drivers: "single", "daily", "slack", "syslog", - | "errorlog", "monolog", - | "custom", "stack" - | - */ - - 'channels' => [ - 'stack' => [ - 'driver' => 'stack', - 'channels' => ['single'], - 'ignore_exceptions' => false, - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'daily' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'days' => 14, - ], - - 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => 'Laravel Log', - 'emoji' => ':boom:', - 'level' => env('LOG_LEVEL', 'critical'), - ], - - 'papertrail' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => SyslogUdpHandler::class, - 'handler_with' => [ - 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), - ], - ], - - 'stderr' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, - 'formatter' => env('LOG_STDERR_FORMATTER'), - 'with' => [ - 'stream' => 'php://stderr', - ], - ], - - 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'errorlog' => [ - 'driver' => 'errorlog', - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'null' => [ - 'driver' => 'monolog', - 'handler' => NullHandler::class, - ], - - 'emergency' => [ - 'path' => storage_path('logs/laravel.log'), - ], - ], - -]; diff --git a/tests/fixtures/laravel/config/mail.php b/tests/fixtures/laravel/config/mail.php deleted file mode 100644 index db96336..0000000 --- a/tests/fixtures/laravel/config/mail.php +++ /dev/null @@ -1,118 +0,0 @@ - env('MAIL_MAILER', 'smtp'), - - /* - |-------------------------------------------------------------------------- - | Mailer Configurations - |-------------------------------------------------------------------------- - | - | Here you may configure all of the mailers used by your application plus - | their respective settings. Several examples have been configured for - | you and you are free to add your own as your application requires. - | - | Laravel supports a variety of mail "transport" drivers to be used while - | sending an e-mail. You will specify which one you are using for your - | mailers below. You are free to add additional mailers as required. - | - | Supported: "smtp", "sendmail", "mailgun", "ses", - | "postmark", "log", "array" - | - */ - - 'mailers' => [ - 'smtp' => [ - 'transport' => 'smtp', - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - 'port' => env('MAIL_PORT', 587), - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'auth_mode' => null, - ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'mailgun' => [ - 'transport' => 'mailgun', - ], - - 'postmark' => [ - 'transport' => 'postmark', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => '/usr/sbin/sendmail -bs', - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Global "From" Address - |-------------------------------------------------------------------------- - | - | You may wish for all e-mails sent by your application to be sent from - | the same address. Here, you may specify a name and address that is - | used globally for all e-mails that are sent by your application. - | - */ - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), - ], - - /* - |-------------------------------------------------------------------------- - | Markdown Mail Settings - |-------------------------------------------------------------------------- - | - | If you are using Markdown based email rendering, you may configure your - | theme and component paths here, allowing you to customize the design - | of the emails. Or, you may simply stick with the Laravel defaults! - | - */ - - 'markdown' => [ - 'theme' => 'default', - - 'paths' => [ - resource_path('views/vendor/mail'), - ], - ], - -]; diff --git a/tests/fixtures/laravel/config/queue.php b/tests/fixtures/laravel/config/queue.php deleted file mode 100644 index 25ea5a8..0000000 --- a/tests/fixtures/laravel/config/queue.php +++ /dev/null @@ -1,93 +0,0 @@ - env('QUEUE_CONNECTION', 'sync'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection information for each server that - | is used by your application. A default configuration has been added - | for each back-end shipped with Laravel. You are free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, - 'after_commit' => false, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => 'localhost', - 'queue' => 'default', - 'retry_after' => 90, - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => 90, - 'block_for' => null, - 'after_commit' => false, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control which database and table are used to store the jobs that - | have failed. You may change them to any database / table you wish. - | - */ - - 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'failed_jobs', - ], - -]; diff --git a/tests/fixtures/laravel/config/sanctum.php b/tests/fixtures/laravel/config/sanctum.php deleted file mode 100644 index 442726a..0000000 --- a/tests/fixtures/laravel/config/sanctum.php +++ /dev/null @@ -1,51 +0,0 @@ - explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', - env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '' - ))), - - /* - |-------------------------------------------------------------------------- - | Expiration Minutes - |-------------------------------------------------------------------------- - | - | This value controls the number of minutes until an issued token will be - | considered expired. If this value is null, personal access tokens do - | not expire. This won't tweak the lifetime of first-party sessions. - | - */ - - 'expiration' => null, - - /* - |-------------------------------------------------------------------------- - | Sanctum Middleware - |-------------------------------------------------------------------------- - | - | When authenticating your first-party SPA with Sanctum you may need to - | customize some of the middleware Sanctum uses while processing the - | request. You may change the middleware listed below as required. - | - */ - - 'middleware' => [ - 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, - 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, - ], - -]; diff --git a/tests/fixtures/laravel/config/services.php b/tests/fixtures/laravel/config/services.php deleted file mode 100644 index 2a1d616..0000000 --- a/tests/fixtures/laravel/config/services.php +++ /dev/null @@ -1,33 +0,0 @@ - [ - 'domain' => env('MAILGUN_DOMAIN'), - 'secret' => env('MAILGUN_SECRET'), - 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), - ], - - 'postmark' => [ - 'token' => env('POSTMARK_TOKEN'), - ], - - 'ses' => [ - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - ], - -]; diff --git a/tests/fixtures/laravel/config/session.php b/tests/fixtures/laravel/config/session.php deleted file mode 100644 index ec77e0c..0000000 --- a/tests/fixtures/laravel/config/session.php +++ /dev/null @@ -1,201 +0,0 @@ - env('SESSION_DRIVER', 'array'), - - /* - |-------------------------------------------------------------------------- - | Session Lifetime - |-------------------------------------------------------------------------- - | - | Here you may specify the number of minutes that you wish the session - | to be allowed to remain idle before it expires. If you want them - | to immediately expire on the browser closing, set that option. - | - */ - - 'lifetime' => env('SESSION_LIFETIME', 120), - - 'expire_on_close' => false, - - /* - |-------------------------------------------------------------------------- - | Session Encryption - |-------------------------------------------------------------------------- - | - | This option allows you to easily specify that all of your session data - | should be encrypted before it is stored. All encryption will be run - | automatically by Laravel and you can use the Session like normal. - | - */ - - 'encrypt' => false, - - /* - |-------------------------------------------------------------------------- - | Session File Location - |-------------------------------------------------------------------------- - | - | When using the native session driver, we need a location where session - | files may be stored. A default has been set for you but a different - | location may be specified. This is only needed for file sessions. - | - */ - - 'files' => storage_path('framework/sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Database Connection - |-------------------------------------------------------------------------- - | - | When using the "database" or "redis" session drivers, you may specify a - | connection that should be used to manage these sessions. This should - | correspond to a connection in your database configuration options. - | - */ - - 'connection' => env('SESSION_CONNECTION', null), - - /* - |-------------------------------------------------------------------------- - | Session Database Table - |-------------------------------------------------------------------------- - | - | When using the "database" session driver, you may specify the table we - | should use to manage the sessions. Of course, a sensible default is - | provided for you; however, you are free to change this as needed. - | - */ - - 'table' => 'sessions', - - /* - |-------------------------------------------------------------------------- - | Session Cache Store - |-------------------------------------------------------------------------- - | - | While using one of the framework's cache driven session backends you may - | list a cache store that should be used for these sessions. This value - | must match with one of the application's configured cache "stores". - | - | Affects: "apc", "dynamodb", "memcached", "redis" - | - */ - - 'store' => env('SESSION_STORE', null), - - /* - |-------------------------------------------------------------------------- - | Session Sweeping Lottery - |-------------------------------------------------------------------------- - | - | Some session drivers must manually sweep their storage location to get - | rid of old sessions from storage. Here are the chances that it will - | happen on a given request. By default, the odds are 2 out of 100. - | - */ - - 'lottery' => [2, 100], - - /* - |-------------------------------------------------------------------------- - | Session Cookie Name - |-------------------------------------------------------------------------- - | - | Here you may change the name of the cookie used to identify a session - | instance by ID. The name specified here will get used every time a - | new session cookie is created by the framework for every driver. - | - */ - - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug(env('APP_NAME', 'laravel'), '_').'_session' - ), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Path - |-------------------------------------------------------------------------- - | - | The session cookie path determines the path for which the cookie will - | be regarded as available. Typically, this will be the root path of - | your application but you are free to change this when necessary. - | - */ - - 'path' => '/', - - /* - |-------------------------------------------------------------------------- - | Session Cookie Domain - |-------------------------------------------------------------------------- - | - | Here you may change the domain of the cookie used to identify a session - | in your application. This will determine which domains the cookie is - | available to in your application. A sensible default has been set. - | - */ - - 'domain' => env('SESSION_DOMAIN', null), - - /* - |-------------------------------------------------------------------------- - | HTTPS Only Cookies - |-------------------------------------------------------------------------- - | - | By setting this option to true, session cookies will only be sent back - | to the server if the browser has a HTTPS connection. This will keep - | the cookie from being sent to you when it can't be done securely. - | - */ - - 'secure' => env('SESSION_SECURE_COOKIE'), - - /* - |-------------------------------------------------------------------------- - | HTTP Access Only - |-------------------------------------------------------------------------- - | - | Setting this value to true will prevent JavaScript from accessing the - | value of the cookie and the cookie will only be accessible through - | the HTTP protocol. You are free to modify this option if needed. - | - */ - - 'http_only' => true, - - /* - |-------------------------------------------------------------------------- - | Same-Site Cookies - |-------------------------------------------------------------------------- - | - | This option determines how your cookies behave when cross-site requests - | take place, and can be used to mitigate CSRF attacks. By default, we - | will set this value to "lax" since this is a secure default value. - | - | Supported: "lax", "strict", "none", null - | - */ - - 'same_site' => 'lax', - -]; diff --git a/tests/fixtures/laravel/config/view.php b/tests/fixtures/laravel/config/view.php deleted file mode 100644 index 22b8a18..0000000 --- a/tests/fixtures/laravel/config/view.php +++ /dev/null @@ -1,36 +0,0 @@ - [ - resource_path('views'), - ], - - /* - |-------------------------------------------------------------------------- - | Compiled View Path - |-------------------------------------------------------------------------- - | - | This option determines where all the compiled Blade templates will be - | stored for your application. Typically, this is within the storage - | directory. However, as usual, you are free to change this value. - | - */ - - 'compiled' => env( - 'VIEW_COMPILED_PATH', - realpath(storage_path('framework/views')) - ), - -]; diff --git a/tests/fixtures/laravel/database/.gitignore b/tests/fixtures/laravel/database/.gitignore deleted file mode 100644 index 9b19b93..0000000 --- a/tests/fixtures/laravel/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php b/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php deleted file mode 100644 index 288afdb..0000000 --- a/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('users'); - } -} diff --git a/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php b/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php deleted file mode 100644 index 4c54106..0000000 --- a/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('password_resets'); - } -} diff --git a/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php b/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php deleted file mode 100644 index ea47866..0000000 --- a/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('failed_jobs'); - } -} diff --git a/tests/fixtures/laravel/public/.gitignore b/tests/fixtures/laravel/public/.gitignore deleted file mode 100644 index d723e54..0000000 --- a/tests/fixtures/laravel/public/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!index.php -!.gitignore diff --git a/tests/fixtures/laravel/public/index.php b/tests/fixtures/laravel/public/index.php deleted file mode 100644 index 66ea93c..0000000 --- a/tests/fixtures/laravel/public/index.php +++ /dev/null @@ -1,55 +0,0 @@ -make(Kernel::class); - -$response = tap($kernel->handle( - $request = Request::capture() -))->send(); - -$kernel->terminate($request, $response); diff --git a/tests/fixtures/laravel/resources/lang/en/auth.php b/tests/fixtures/laravel/resources/lang/en/auth.php deleted file mode 100644 index 6598e2c..0000000 --- a/tests/fixtures/laravel/resources/lang/en/auth.php +++ /dev/null @@ -1,20 +0,0 @@ - 'These credentials do not match our records.', - 'password' => 'The provided password is incorrect.', - 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/pagination.php b/tests/fixtures/laravel/resources/lang/en/pagination.php deleted file mode 100644 index d481411..0000000 --- a/tests/fixtures/laravel/resources/lang/en/pagination.php +++ /dev/null @@ -1,19 +0,0 @@ - '« Previous', - 'next' => 'Next »', - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/passwords.php b/tests/fixtures/laravel/resources/lang/en/passwords.php deleted file mode 100644 index 2345a56..0000000 --- a/tests/fixtures/laravel/resources/lang/en/passwords.php +++ /dev/null @@ -1,22 +0,0 @@ - 'Your password has been reset!', - 'sent' => 'We have emailed your password reset link!', - 'throttled' => 'Please wait before retrying.', - 'token' => 'This password reset token is invalid.', - 'user' => "We can't find a user with that email address.", - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/validation.php b/tests/fixtures/laravel/resources/lang/en/validation.php deleted file mode 100644 index 2179113..0000000 --- a/tests/fixtures/laravel/resources/lang/en/validation.php +++ /dev/null @@ -1,158 +0,0 @@ - 'The :attribute must be accepted.', - 'accepted_if' => 'The :attribute must be accepted when :other is :value.', - 'active_url' => 'The :attribute is not a valid URL.', - 'after' => 'The :attribute must be a date after :date.', - 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', - 'alpha' => 'The :attribute must only contain letters.', - 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', - 'alpha_num' => 'The :attribute must only contain letters and numbers.', - 'array' => 'The :attribute must be an array.', - 'before' => 'The :attribute must be a date before :date.', - 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', - 'between' => [ - 'numeric' => 'The :attribute must be between :min and :max.', - 'file' => 'The :attribute must be between :min and :max kilobytes.', - 'string' => 'The :attribute must be between :min and :max characters.', - 'array' => 'The :attribute must have between :min and :max items.', - ], - 'boolean' => 'The :attribute field must be true or false.', - 'confirmed' => 'The :attribute confirmation does not match.', - 'current_password' => 'The password is incorrect.', - 'date' => 'The :attribute is not a valid date.', - 'date_equals' => 'The :attribute must be a date equal to :date.', - 'date_format' => 'The :attribute does not match the format :format.', - 'different' => 'The :attribute and :other must be different.', - 'digits' => 'The :attribute must be :digits digits.', - 'digits_between' => 'The :attribute must be between :min and :max digits.', - 'dimensions' => 'The :attribute has invalid image dimensions.', - 'distinct' => 'The :attribute field has a duplicate value.', - 'email' => 'The :attribute must be a valid email address.', - 'ends_with' => 'The :attribute must end with one of the following: :values.', - 'exists' => 'The selected :attribute is invalid.', - 'file' => 'The :attribute must be a file.', - 'filled' => 'The :attribute field must have a value.', - 'gt' => [ - 'numeric' => 'The :attribute must be greater than :value.', - 'file' => 'The :attribute must be greater than :value kilobytes.', - 'string' => 'The :attribute must be greater than :value characters.', - 'array' => 'The :attribute must have more than :value items.', - ], - 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', - 'array' => 'The :attribute must have :value items or more.', - ], - 'image' => 'The :attribute must be an image.', - 'in' => 'The selected :attribute is invalid.', - 'in_array' => 'The :attribute field does not exist in :other.', - 'integer' => 'The :attribute must be an integer.', - 'ip' => 'The :attribute must be a valid IP address.', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'json' => 'The :attribute must be a valid JSON string.', - 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', - 'file' => 'The :attribute must be less than :value kilobytes.', - 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', - ], - 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', - 'array' => 'The :attribute must not have more than :value items.', - ], - 'max' => [ - 'numeric' => 'The :attribute must not be greater than :max.', - 'file' => 'The :attribute must not be greater than :max kilobytes.', - 'string' => 'The :attribute must not be greater than :max characters.', - 'array' => 'The :attribute must not have more than :max items.', - ], - 'mimes' => 'The :attribute must be a file of type: :values.', - 'mimetypes' => 'The :attribute must be a file of type: :values.', - 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', - 'file' => 'The :attribute must be at least :min kilobytes.', - 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', - ], - 'multiple_of' => 'The :attribute must be a multiple of :value.', - 'not_in' => 'The selected :attribute is invalid.', - 'not_regex' => 'The :attribute format is invalid.', - 'numeric' => 'The :attribute must be a number.', - 'password' => 'The password is incorrect.', - 'present' => 'The :attribute field must be present.', - 'regex' => 'The :attribute format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_unless' => 'The :attribute field is required unless :other is in :values.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values are present.', - 'required_without' => 'The :attribute field is required when :values is not present.', - 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'prohibited' => 'The :attribute field is prohibited.', - 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', - 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', - 'prohibits' => 'The :attribute field prohibits :other from being present.', - 'same' => 'The :attribute and :other must match.', - 'size' => [ - 'numeric' => 'The :attribute must be :size.', - 'file' => 'The :attribute must be :size kilobytes.', - 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', - ], - 'starts_with' => 'The :attribute must start with one of the following: :values.', - 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid timezone.', - 'unique' => 'The :attribute has already been taken.', - 'uploaded' => 'The :attribute failed to upload.', - 'url' => 'The :attribute must be a valid URL.', - 'uuid' => 'The :attribute must be a valid UUID.', - - /* - |-------------------------------------------------------------------------- - | Custom Validation Language Lines - |-------------------------------------------------------------------------- - | - | Here you may specify custom validation messages for attributes using the - | convention "attribute.rule" to name the lines. This makes it quick to - | specify a specific custom language line for a given attribute rule. - | - */ - - 'custom' => [ - 'attribute-name' => [ - 'rule-name' => 'custom-message', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Custom Validation Attributes - |-------------------------------------------------------------------------- - | - | The following language lines are used to swap our attribute placeholder - | with something more reader friendly such as "E-Mail Address" instead - | of "email". This simply helps us make our message more expressive. - | - */ - - 'attributes' => [], - -]; diff --git a/tests/fixtures/laravel/resources/views/errors/503.blade.php b/tests/fixtures/laravel/resources/views/errors/503.blade.php deleted file mode 100644 index 336936b..0000000 --- a/tests/fixtures/laravel/resources/views/errors/503.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - Be right back. - - - - - - -
-
-
Be right back.
-
-
- - diff --git a/tests/fixtures/laravel/resources/views/welcome.blade.php b/tests/fixtures/laravel/resources/views/welcome.blade.php deleted file mode 100644 index 284db8e..0000000 --- a/tests/fixtures/laravel/resources/views/welcome.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - -
-
-
Laravel 5
-
-
- - diff --git a/tests/fixtures/laravel/routes/.gitignore b/tests/fixtures/laravel/routes/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/routes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/server.php b/tests/fixtures/laravel/server.php deleted file mode 100644 index 5fb6379..0000000 --- a/tests/fixtures/laravel/server.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - -$uri = urldecode( - parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) -); - -// This file allows us to emulate Apache's "mod_rewrite" functionality from the -// built-in PHP web server. This provides a convenient way to test a Laravel -// application without having installed a "real" web server software here. -if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { - return false; -} - -require_once __DIR__.'/public/index.php'; diff --git a/tests/fixtures/laravel/storage/app/modules/modules.used b/tests/fixtures/laravel/storage/app/modules/modules.used deleted file mode 100644 index 63ebfb4..0000000 --- a/tests/fixtures/laravel/storage/app/modules/modules.used +++ /dev/null @@ -1 +0,0 @@ -Article \ No newline at end of file diff --git a/tests/fixtures/laravel/storage/framework/.gitignore b/tests/fixtures/laravel/storage/framework/.gitignore deleted file mode 100644 index cd61cda..0000000 --- a/tests/fixtures/laravel/storage/framework/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -down -schedule-* diff --git a/tests/fixtures/laravel/storage/framework/cache/.gitignore b/tests/fixtures/laravel/storage/framework/cache/.gitignore deleted file mode 100644 index 01e4a6c..0000000 --- a/tests/fixtures/laravel/storage/framework/cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!data/ -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/data/.gitignore b/tests/fixtures/laravel/storage/framework/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/sessions/.gitignore b/tests/fixtures/laravel/storage/framework/sessions/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/sessions/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/testing/.gitignore b/tests/fixtures/laravel/storage/framework/testing/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/testing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/views/.gitignore b/tests/fixtures/laravel/storage/framework/views/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/views/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/logs/.gitignore b/tests/fixtures/laravel/storage/logs/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/logs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/stubs/model.stub b/tests/fixtures/stubs/model.stub deleted file mode 100644 index 1002d79..0000000 --- a/tests/fixtures/stubs/model.stub +++ /dev/null @@ -1 +0,0 @@ -stub-override \ No newline at end of file diff --git a/tests/fixtures/stubs/modules/invalid/without-name/composer.json b/tests/fixtures/stubs/modules/invalid/without-name/composer.json deleted file mode 100644 index 0906c69..0000000 --- a/tests/fixtures/stubs/modules/invalid/without-name/composer.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "", - "description": "Invalid module without name", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\WithoutName\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\WithoutName\\Tests\\": "tests/" - } - } -} diff --git a/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json b/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json deleted file mode 100644 index 9b65e62..0000000 --- a/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "laraneat/without-namespace", - "description": "Invalid module without namespace", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ] -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/composer.json b/tests/fixtures/stubs/modules/valid/article-category/composer.json deleted file mode 100644 index 792a48f..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article-category", - "description": "ArticleCategory module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleCategory\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleCategory\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider", - "Modules\\ArticleCategory\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php deleted file mode 100644 index a845107..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-category-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-category-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-category-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleCategory\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php deleted file mode 100644 index 8cfa02b..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleCategory\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleCategory\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/composer.json b/tests/fixtures/stubs/modules/valid/article-copy/composer.json deleted file mode 100644 index 02d9b7b..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article", - "description": "Article module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Article\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Article\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Article\\Providers\\ArticleServiceProvider", - "Modules\\Article\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php deleted file mode 100644 index 48ccbe3..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Article\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php deleted file mode 100644 index e20139c..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Article\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Article\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/composer.json b/tests/fixtures/stubs/modules/valid/article/composer.json deleted file mode 100644 index 02d9b7b..0000000 --- a/tests/fixtures/stubs/modules/valid/article/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article", - "description": "Article module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Article\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Article\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Article\\Providers\\ArticleServiceProvider", - "Modules\\Article\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php b/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php deleted file mode 100644 index 48ccbe3..0000000 --- a/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Article\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php deleted file mode 100644 index e20139c..0000000 --- a/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Article\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Article\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/composer.json b/tests/fixtures/stubs/modules/valid/author/composer.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/fixtures/stubs/modules/valid/author/composer.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php b/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php deleted file mode 100644 index bee9723..0000000 --- a/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php +++ /dev/null @@ -1,7 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'author-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'author-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'author-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Author\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php deleted file mode 100644 index a9eff6d..0000000 --- a/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,59 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Author\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/Routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Author\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/Routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/empty-module/composer.json b/tests/fixtures/stubs/modules/valid/empty-module/composer.json deleted file mode 100644 index be56de9..0000000 --- a/tests/fixtures/stubs/modules/valid/empty-module/composer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "laraneat/empty", - "description": "Empty module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Empty\\": "src/" - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/empty/composer.json b/tests/fixtures/stubs/modules/valid/empty/composer.json deleted file mode 100644 index f8d6492..0000000 --- a/tests/fixtures/stubs/modules/valid/empty/composer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "empty/empty", - "description": "Empty module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Empty\\Empty\\": "src/" - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/composer.json b/tests/fixtures/stubs/modules/valid/navigation/composer.json deleted file mode 100644 index f27c35b..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/location", - "description": "GeoLocation module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\GeoLocation\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\GeoLocation\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider", - "Modules\\GeoLocation\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php b/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php deleted file mode 100644 index 979cd4c..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'geo-location-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'geo-location-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'geo-location-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\GeoLocation\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php deleted file mode 100644 index 7bbbe04..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\GeoLocation\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\GeoLocation\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -}