diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 496599f..c92f132 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -1,8 +1,7 @@ name: Checks -# lacommerce has no test suite yet, so there is nothing for PHPUnit to run. This gates what can -# be checked without one: a valid composer.json, and every PHP file parsing. When tests land, -# add a Testbench job here and it becomes a required check through the org's ruleset script. +# Fast checks that need no dependencies: a valid composer.json, and every PHP file parsing. +# The Testbench suite runs in tests.yml. on: pull_request: branches: [master] @@ -30,7 +29,7 @@ jobs: tools: composer - name: Validate composer.json - run: composer validate --no-check-publish --no-check-lock + run: composer validate --strict --no-check-publish --no-check-lock - name: Check PHP syntax - run: find src config database -name '*.php' -print0 | xargs -0 -n1 php -l + run: find src config tests -name '*.php' -print0 | xargs -0 -n1 php -l diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d767189 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + pull_request: + branches: [master] + # No path filter: this workflow is meant to gate merges as a required status check, and a + # required check that never reports (a docs-only pull request) blocks the merge for good. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + phpunit: + name: PHP ${{ matrix.php }} · ${{ matrix.stability }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + stability: [prefer-lowest, prefer-stable] + steps: + - uses: actions/checkout@v7 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, sqlite3, mbstring + coverage: none + tools: composer + + # No lock file is tracked, so this resolves fresh against composer.json every run. + - name: Install dependencies + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit --colors=never diff --git a/.gitignore b/.gitignore index e43b0f9..4036744 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ .DS_Store +/vendor/ +composer.lock +/build/ +/.phpunit.cache/ +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index fe57bc0..ed3cc4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,42 @@ # Changelog -All notable changes to `lacommerce` will be documented in this file +All notable changes to `simtabi/lacommerce` are documented in this file. -## 0.1.0 - 2022-02-03 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- Initial release +## [Unreleased] + +Preparing the first tagged release. An entry dated 2022-02-03 used to sit here as `0.1.0`, but no tag was +ever cut for it and the package was never published. + +### Added + +- A Testbench suite covering the `HasSku`, `HasOrderNumber` and `HasTicketNumber` traits and the service + provider, run in CI on PHP 8.4 and 8.5 against lowest and stable dependencies. +- Vendor-scoped config key `simtabi.lacommerce`, published to `config/simtabi/lacommerce.php` with the tag + `simtabi::lacommerce-config`. +- `composer.json` now requires the `illuminate/*` components the source uses. + +### Changed + +- Requires PHP `^8.4.1 || ^8.5` and Laravel 12 or 13 (was PHP `^8.0`, no Laravel constraint). +- Package metadata, badges and documentation links point at the `simtabi` org. + +### Deprecated + +- The bare config key `lacommerce` and publish tag `lacommerce:config`. Both still work: a published + `config/lacommerce.php` is still read (the scoped file wins when both exist), `config('lacommerce.*')` + still returns the merged configuration, and the bare tag still publishes `config/lacommerce.php`. + Earliest removal: 0.2.0. + +### Fixed + +- The traits failed on every model under current Laravel: they called `static::observe()` while the model + was booting, which throws. They now register the observer's handlers directly. +- The provider loaded the package's `database/migrations` into every host application. That directory only + held a test fixture creating a `dummy_models` table; the fixture now lives under `tests/` and nothing is + loaded. +- The config was merged twice; it is merged once. +- View, translation and asset loaders and publish tags pointed at directories the package does not ship; + they are now registered only when the directory exists. diff --git a/README.md b/README.md index 32a4982..57f78f6 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,77 @@ # simtabi/lacommerce -[![Latest version on Packagist](https://img.shields.io/packagist/v/simtabi/lacommerce.svg)](https://packagist.org/packages/simtabi/lacommerce) -[![Tests](https://github.com/laranail/lacommerce/actions/workflows/laravel.yml/badge.svg)](https://github.com/laranail/lacommerce/actions/workflows/laravel.yml) +[![Tests](https://github.com/simtabi/lacommerce/actions/workflows/tests.yml/badge.svg)](https://github.com/simtabi/lacommerce/actions/workflows/tests.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +Two badges, not four: the package is not on Packagist yet, so there is no registry version to show, and +there is no static-analysis workflow. + > Handy helper generators for Laravel e-commerce projects — auto-generate unique SKUs, order numbers, and ticket numbers on your Eloquent models via the `HasSku`, `HasOrderNumber`, and `HasTicketNumber` traits, each configurable globally or per model and fully replaceable. -Compatible with PHP `^8.0`. +Compatible with PHP `^8.4.1 || ^8.5` and Laravel 12 or 13. ## Install ```bash composer require simtabi/lacommerce -php artisan vendor:publish --tag=lacommerce:config ``` -## Documentation +## Quick start guide and usage + +### Getting started + +The service provider is auto-discovered and the defaults work without a config file. Give each model the +destination column its trait writes to (`sku`, `order_number` or `ticket_number`) in your own migration. +To change the defaults, publish the config to `config/simtabi/lacommerce.php`: + +```bash +php artisan vendor:publish --tag=simtabi::lacommerce-config +``` + +### Usage + +```php +use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Traits\HasSku; + +class Product extends Model +{ + use HasSku; +} + +$product = Product::create(['name' => 'Laravel is Awesome']); + +echo $product->sku; // "LAR-8056449213" +``` + +```php +use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Traits\HasOrderNumber; + +class Order extends Model +{ + use HasOrderNumber; +} + +echo Order::create(['name' => 'Web order'])->order_number; // "ORD-3920571846" +``` + +The full walkthrough is in [docs/getting-started.md](docs/getting-started.md); everything else is in the +[Documentation](#documentation) index. + +## Documentation -Full documentation is at **[opensource.simtabi.com/documentation/laranail/lacommerce](https://opensource.simtabi.com/documentation/laranail/lacommerce/)** — installation, getting started, each generator trait, and configuration. +Full documentation is at **[opensource.simtabi.com/documentation/simtabi/lacommerce](https://opensource.simtabi.com/documentation/simtabi/lacommerce/)** — installation, getting started, each generator trait, and configuration. ## Credits The SKU-generation approach builds on [Cyrill Kalita / binary-cats](https://github.com/binary-cats)' work, -plus [all contributors](https://github.com/laranail/lacommerce/graphs/contributors). +plus [all contributors](https://github.com/simtabi/lacommerce/graphs/contributors). ## Contributing & security Issues and PRs are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities per -[SECURITY.md](SECURITY.md) (opensource@simtabi.com); participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). +[SECURITY.md](SECURITY.md) (security@simtabi.com); participation follows the [Code of Conduct](CODE_OF_CONDUCT.md). ## License diff --git a/composer.json b/composer.json index b41d15b..fdd7e39 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,11 @@ "laravel", "sku" ], - "homepage": "https://opensource.simtabi.com/products/laranail/lacommerce", + "homepage": "https://opensource.simtabi.com/products/simtabi/lacommerce", "support": { - "docs": "https://opensource.simtabi.com/documentation/laranail/lacommerce/" + "issues": "https://github.com/simtabi/lacommerce/issues", + "source": "https://github.com/simtabi/lacommerce", + "docs": "https://opensource.simtabi.com/documentation/simtabi/lacommerce/" }, "license": "MIT", "authors": [ @@ -21,11 +23,15 @@ } ], "require": { - "php": "^8.0" + "php": "^8.4.1 || ^8.5", + "illuminate/contracts": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0" }, "require-dev": { - "orchestra/testbench": "^6.24.1", - "phpunit/phpunit": "^9.5.19" + "orchestra/testbench": "^10.6 || ^11.0", + "phpunit/phpunit": "^11.5 || ^12.0" }, "autoload": { "psr-4": { @@ -46,5 +52,10 @@ "Simtabi\\Lacommerce\\Providers\\LacommerceServiceProvider" ] } - } + }, + "scripts": { + "test": "phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/docs/architecture.md b/docs/architecture.md index 4c40f99..d999e4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,8 @@ How a generated value is produced on save. See the [Documentation index](../READ ## The moving parts -- **Traits** (`Traits/HasSku`, `HasOrderNumber`, `HasTicketNumber`) — added to a model; each boots a model - observer for its destination column. +- **Traits** (`Traits/HasSku`, `HasOrderNumber`, `HasTicketNumber`) — added to a model; each registers its + observer's `creating` and `updating` handlers for its destination column. - **Observer** (`Generators/Services/Observer`) — hooks the model's create/update events and asks the generator for a value. - **Generator** (`Generators/Services/Generator` + per-type `Concerns/*Generator`) — builds the value from diff --git a/docs/configuration.md b/docs/configuration.md index 493833f..a2d3a60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,7 +1,7 @@ # Configuration -Every key in `config/lacommerce.php`, published with `vendor:publish --tag=lacommerce:config`. See the -[Documentation index](../README.md#documentation). +Every key in `config/simtabi/lacommerce.php`, published with `vendor:publish --tag=simtabi::lacommerce-config` +and read from the `simtabi.lacommerce` config key. See the [Documentation index](../README.md#documentation). ## Structure @@ -19,21 +19,21 @@ return [ ], 'sku' => [ - 'generator' => SkuGenerator::class, // must implement GeneratorInterface - 'source' => 'name', // source column(s) - 'destination' => 'sku', // destination column + 'generator' => SkuGenerator::class, // must implement GeneratorInterface + 'source_column' => 'name', // source column(s) + 'destination_column' => 'sku', // destination column ], 'ticket_number' => [ - 'generator' => TicketNumberGenerator::class, - 'source' => 'name', - 'destination' => 'ticket_number', + 'generator' => TicketNumberGenerator::class, + 'source_column' => 'name', + 'destination_column' => 'ticket_number', ], 'order_number' => [ - 'generator' => OrderNumberGenerator::class, - 'source' => 'name', - 'destination' => 'order_number', + 'generator' => OrderNumberGenerator::class, + 'source_column' => 'name', + 'destination_column' => 'order_number', ], ], ]; @@ -48,11 +48,28 @@ return [ | `generator.default.generate_on_create` | Generate when the model is created. | | `generator.default.refresh_on_update` | Regenerate when the model is updated. | | `generator..generator` | The generator class (must implement its `GeneratorInterface`). | -| `generator..source` | Source column(s) the value is derived from. | -| `generator..destination` | Column the generated value is written to. | +| `generator..source_column` | Source column(s) the value is derived from. | +| `generator..destination_column` | Column the generated value is written to. | Override any of these per model via the trait's config method — see [Generators](tools/generators.md). +The published file replaces the defaults block by block, not key by key: a top-level key you keep +(`generator`) must carry every nested key the package reads, so publish the whole file and edit it. + +## The deprecated `lacommerce` key + +Before 0.1.0 the config lived under the bare key `lacommerce`, published to `config/lacommerce.php` with +`--tag=lacommerce:config`. Both still work and are deprecated; the earliest release that could remove them +is 0.2.0. + +- A published `config/lacommerce.php` is still read. When `config/simtabi/lacommerce.php` also exists, it + wins. +- `config('lacommerce.*')` still returns the merged configuration. +- `--tag=lacommerce:config` still publishes `config/lacommerce.php`. + +To migrate, move the file to `config/simtabi/lacommerce.php` and replace any `config('lacommerce.…')` reads +with `config('simtabi.lacommerce.…')`. + --- [← Docs index](../README.md#documentation) diff --git a/docs/getting-started.md b/docs/getting-started.md index a4f1551..be0231a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,7 +6,7 @@ Generate your first SKU in three steps. See the [Documentation index](../README. ```bash composer require simtabi/lacommerce -php artisan vendor:publish --tag=lacommerce:config +php artisan vendor:publish --tag=simtabi::lacommerce-config ``` See [Installation](installation.md). @@ -32,7 +32,7 @@ $product = new Product(); $product->name = 'Laravel is Awesome'; $product->save(); -echo $product->sku; // "LAR-80564492" +echo $product->sku; // "LAR-8056449213" ``` Order numbers and ticket numbers work the same way via `HasOrderNumber` / `HasTicketNumber`. @@ -40,7 +40,7 @@ Order numbers and ticket numbers work the same way via `HasOrderNumber` / `HasTi ## Next steps - [Generators](tools/generators.md) — all three generators, per-model config, custom generators. -- [Configuration](configuration.md) — every `config/lacommerce.php` key. +- [Configuration](configuration.md) — every `config/simtabi/lacommerce.php` key. --- diff --git a/docs/installation.md b/docs/installation.md index 71636b7..f68a45b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,8 +4,8 @@ Install `simtabi/lacommerce` and publish its config. See the [Documentation inde ## Requirements -- PHP `^8.0` -- Laravel (the `LacommerceServiceProvider` is auto-discovered) +- PHP `^8.4.1 || ^8.5` +- Laravel 12 or 13 (the `LacommerceServiceProvider` is auto-discovered) ## Install @@ -13,15 +13,16 @@ Install `simtabi/lacommerce` and publish its config. See the [Documentation inde composer require simtabi/lacommerce ``` -The service provider registers itself. Publish the config (and optionally assets/views): +The service provider registers itself. Publish the config if you want to change the defaults: ```bash -php artisan vendor:publish --tag=lacommerce:config -php artisan vendor:publish --tag=lacommerce:assets -php artisan vendor:publish --tag=lacommerce:views +php artisan vendor:publish --tag=simtabi::lacommerce-config ``` -This publishes `config/lacommerce.php` — see [Configuration](configuration.md). +This publishes `config/simtabi/lacommerce.php` — see [Configuration](configuration.md). The package ships +no views, translations or public assets, so there is nothing else to publish. + +The package runs no migrations of its own: add the destination column to your own table. > Your model needs the destination column (e.g. `sku`) on its table. If you overwrite generated values > manually, add that column to the model's `$fillable`. diff --git a/docs/tools/generators.md b/docs/tools/generators.md index 802a10b..71493d3 100644 --- a/docs/tools/generators.md +++ b/docs/tools/generators.md @@ -27,10 +27,10 @@ $product = new Product(); $product->name = 'Laravel is Awesome'; $product->save(); -echo $product->sku; // "LAR-80564492" +echo $product->sku; // "LAR-8056449213" ``` -The trait registers an observer for the destination field, generated every time you save the model. If you +The trait registers model-event listeners for the destination field, generated every time you save the model. If you plan to overwrite values manually, add the destination column to the model's `$fillable`. `Illuminate\Support\Str::sku()` is also registered, so you can generate a value directly. @@ -74,14 +74,14 @@ class CustomSkuGenerator extends SkuGenerator { protected function getSourceString(): string { - $source = $this->configs->source; + $source = $this->modelConfig->sourceColumn; $fields = array_filter($this->model->only($source)); if (empty($fields)) { return 'some-random-value-logic'; } - return implode($this->configs->separator, $fields); + return implode($this->modelConfig->separator, $fields); } } ``` @@ -92,7 +92,9 @@ Then point the config at it: 'generator' => \App\Components\SkuGenerator\CustomSkuGenerator::class, ``` -A custom generator must implement `Simtabi\Lacommerce\SKU\Contracts\SkuGenerator`. +A custom generator must implement `Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface` (or the +`OrderNumberGeneratorInterface` / `TicketNumberGeneratorInterface` beside it); extending the shipped generator +does that for you. ## About SKUs diff --git a/phpunit.xml b/phpunit.xml index aa3d4b0..26f0ade 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,29 +1,22 @@ - + failOnRisky="true" + failOnWarning="true"> - + tests - - - src/ - - - - - - - - - + + + src + + + + + diff --git a/src/Providers/LacommerceServiceProvider.php b/src/Providers/LacommerceServiceProvider.php index 483ec63..8c26901 100644 --- a/src/Providers/LacommerceServiceProvider.php +++ b/src/Providers/LacommerceServiceProvider.php @@ -2,6 +2,7 @@ namespace Simtabi\Lacommerce\Providers; +use Illuminate\Contracts\Foundation\CachesConfiguration; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Simtabi\Lacommerce\Generators\Concerns\OrderNumber\OrderNumberConfigs; @@ -15,7 +16,37 @@ class LacommerceServiceProvider extends ServiceProvider { - private string $packageName = 'lacommerce'; + /** + * Vendor-scoped config key. Published to config/simtabi/lacommerce.php. + */ + public const CONFIG_KEY = 'simtabi.lacommerce'; + + /** + * Vendor-scoped publish tag for the config file. + */ + public const CONFIG_TAG = 'simtabi::lacommerce-config'; + + /** + * Vendor-scoped view and translation namespace. + */ + public const VIEW_NAMESPACE = 'simtabi/lacommerce'; + + /** + * Bare config key used before 0.1.0. + * + * @deprecated Since 0.1.0; use CONFIG_KEY (`simtabi.lacommerce`). Still populated and a + * published config/lacommerce.php is still honoured. Earliest removal: 0.2.0. + */ + public const LEGACY_CONFIG_KEY = 'lacommerce'; + + /** + * Bare publish tag used before 0.1.0. + * + * @deprecated Since 0.1.0; use CONFIG_TAG (`simtabi::lacommerce-config`). Still registered and + * still publishes config/lacommerce.php. Earliest removal: 0.2.0. + */ + public const LEGACY_CONFIG_TAG = 'lacommerce:config'; + private const PACKAGE_PATH = __DIR__ . '/../../'; /** @@ -25,11 +56,41 @@ class LacommerceServiceProvider extends ServiceProvider */ public function register() { - $this->mergeConfigFrom(self::PACKAGE_PATH . 'config/config.php', $this->packageName); - $this->loadTranslationsFrom(self::PACKAGE_PATH . "resources/lang/", $this->packageName); - $this->loadMigrationsFrom(self::PACKAGE_PATH.'database/migrations'); - $this->loadViewsFrom(self::PACKAGE_PATH . "resources/views", $this->packageName); - $this->mergeConfigFrom(self::PACKAGE_PATH . "config/config.php", $this->packageName); + $this->registerConfig(); + + if (is_dir($path = self::PACKAGE_PATH . 'resources/lang')) { + $this->loadTranslationsFrom($path, self::VIEW_NAMESPACE); + } + + if (is_dir($path = self::PACKAGE_PATH . 'resources/views')) { + $this->loadViewsFrom($path, self::VIEW_NAMESPACE); + } + } + + /** + * Merge the package defaults into the vendor-scoped key, honouring a config file published + * under the deprecated bare key, and mirror the result to that bare key so existing + * `config('lacommerce.*')` reads keep working. + * + * Precedence, lowest to highest: package defaults, config/lacommerce.php (deprecated), + * config/simtabi/lacommerce.php. The merge is shallow, as mergeConfigFrom() is. + */ + private function registerConfig(): void + { + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; + } + + $config = $this->app->make('config'); + + $merged = array_merge( + require self::PACKAGE_PATH . 'config/config.php', + (array) $config->get(self::LEGACY_CONFIG_KEY, []), + (array) $config->get(self::CONFIG_KEY, []), + ); + + $config->set(self::CONFIG_KEY, $merged); + $config->set(self::LEGACY_CONFIG_KEY, $merged); } /** @@ -55,21 +116,31 @@ private function registerConsoles(): static { if ($this->app->runningInConsole()) { + // Laravel keys a provider's publishable paths by source path, so two tags naming the + // same source string would share one target and the later one would win. The two + // spellings below name the same file but are distinct keys. $this->publishes([ - self::PACKAGE_PATH . "config/config.php" => config_path("{$this->packageName}.php"), - ], "{$this->packageName}:config"); - - $this->publishes([ - self::PACKAGE_PATH . "public" => public_path("vendor/{$this->packageName}"), - ], "{$this->packageName}:assets"); - - $this->publishes([ - self::PACKAGE_PATH . "resources/views" => resource_path("views/vendor/{$this->packageName}"), - ], "{$this->packageName}:views"); + dirname(__DIR__, 2) . '/config/config.php' => config_path('simtabi/lacommerce.php'), + ], self::CONFIG_TAG); + // Deprecated bare tag, kept so existing install instructions still work. $this->publishes([ - self::PACKAGE_PATH . "resources/lang" => $this->app->langPath("vendor/{$this->packageName}"), - ], "{$this->packageName}:translations"); + self::PACKAGE_PATH . 'config/config.php' => config_path(self::LEGACY_CONFIG_KEY . '.php'), + ], self::LEGACY_CONFIG_TAG); + + // The package ships no public assets, views or translations today. The tags are + // registered only when the directory exists, so they never point at nothing. + $optional = [ + 'public' => [public_path('vendor/simtabi/lacommerce'), 'simtabi::lacommerce-assets'], + 'resources/views' => [resource_path('views/vendor/simtabi/lacommerce'), 'simtabi::lacommerce-views'], + 'resources/lang' => [$this->app->langPath('vendor/simtabi/lacommerce'), 'simtabi::lacommerce-translations'], + ]; + + foreach ($optional as $source => [$target, $tag]) { + if (is_dir(self::PACKAGE_PATH . $source)) { + $this->publishes([self::PACKAGE_PATH . $source => $target], $tag); + } + } } return $this; @@ -108,7 +179,7 @@ private function getConfig(): array { $config = $this->app->make('config'); - return $config->get('lacommerce.generator', []); + return $config->get(self::CONFIG_KEY . '.generator', []); } /** @@ -138,7 +209,7 @@ protected function bindConfigs() private function registerStrMacros() { - $defSeparator = config('lacommerce.generator.default.separator', '-'); + $defSeparator = config(self::CONFIG_KEY . '.generator.default.separator', '-'); Str::macro('sku', function (string $source, ?string $separator = null, ?string $prefix = null) use ($defSeparator){ $separator = $separator ?: $defSeparator; diff --git a/src/Traits/HasOrderNumber.php b/src/Traits/HasOrderNumber.php index 363fdde..dbad0e0 100644 --- a/src/Traits/HasOrderNumber.php +++ b/src/Traits/HasOrderNumber.php @@ -2,6 +2,7 @@ namespace Simtabi\Lacommerce\Traits; +use Illuminate\Database\Eloquent\Model; use Simtabi\Lacommerce\Generators\Concerns\OrderNumber\OrderNumberConfigs; use Simtabi\Lacommerce\Generators\Concerns\OrderNumber\OrderNumberObserver; @@ -9,13 +10,17 @@ trait HasOrderNumber { /** - * Boot the trait by adding observers. + * Boot the trait by registering the observer's model events. + * + * The listeners are registered directly rather than through static::observe(), which + * instantiates the model and therefore throws while the model is still booting. * * @return void */ public static function bootHasOrderNumber() { - static::observe(OrderNumberObserver::class); + static::creating(static fn (Model $model) => resolve(OrderNumberObserver::class)->creating($model)); + static::updating(static fn (Model $model) => resolve(OrderNumberObserver::class)->updating($model)); } /** diff --git a/src/Traits/HasSku.php b/src/Traits/HasSku.php index d9f6fa2..7718305 100644 --- a/src/Traits/HasSku.php +++ b/src/Traits/HasSku.php @@ -2,6 +2,7 @@ namespace Simtabi\Lacommerce\Traits; +use Illuminate\Database\Eloquent\Model; use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuConfigs; use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuObserver; @@ -9,13 +10,17 @@ trait HasSku { /** - * Boot the trait by adding observers. + * Boot the trait by registering the observer's model events. + * + * The listeners are registered directly rather than through static::observe(), which + * instantiates the model and therefore throws while the model is still booting. * * @return void */ public static function bootHasSku() { - static::observe(SkuObserver::class); + static::creating(static fn (Model $model) => resolve(SkuObserver::class)->creating($model)); + static::updating(static fn (Model $model) => resolve(SkuObserver::class)->updating($model)); } /** diff --git a/src/Traits/HasTicketNumber.php b/src/Traits/HasTicketNumber.php index c1f81ea..1b8adf4 100644 --- a/src/Traits/HasTicketNumber.php +++ b/src/Traits/HasTicketNumber.php @@ -2,6 +2,7 @@ namespace Simtabi\Lacommerce\Traits; +use Illuminate\Database\Eloquent\Model; use Simtabi\Lacommerce\Generators\Concerns\TicketNumber\TicketNumberConfigs; use Simtabi\Lacommerce\Generators\Concerns\TicketNumber\TicketNumberObserver; @@ -9,13 +10,17 @@ trait HasTicketNumber { /** - * Boot the trait by adding observers. + * Boot the trait by registering the observer's model events. + * + * The listeners are registered directly rather than through static::observe(), which + * instantiates the model and therefore throws while the model is still booting. * * @return void */ public static function bootHasTicketNumber() { - static::observe(TicketNumberObserver::class); + static::creating(static fn (Model $model) => resolve(TicketNumberObserver::class)->creating($model)); + static::updating(static fn (Model $model) => resolve(TicketNumberObserver::class)->updating($model)); } /** diff --git a/tests/Feature/GenerateOnCreateDisabledTest.php b/tests/Feature/GenerateOnCreateDisabledTest.php new file mode 100644 index 0000000..b3fa59f --- /dev/null +++ b/tests/Feature/GenerateOnCreateDisabledTest.php @@ -0,0 +1,33 @@ + $config]; + } + + #[Test] + public function it_does_not_generate_when_disabled(): void + { + $product = DummyProduct::create(['name' => 'Blue shirt']); + + $this->assertNull($product->fresh()->getRawOriginal('sku')); + + $product->update(['name' => 'Red hat']); + + $this->assertNull($product->fresh()->getRawOriginal('sku')); + } +} diff --git a/tests/Feature/HasOrderNumberTest.php b/tests/Feature/HasOrderNumberTest.php new file mode 100644 index 0000000..a7f2c96 --- /dev/null +++ b/tests/Feature/HasOrderNumberTest.php @@ -0,0 +1,42 @@ + 'Blue shirt']); + + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', $order->order_number); + $this->assertNull($order->sku); + $this->assertNull($order->ticket_number); + } + + #[Test] + public function it_keeps_an_order_number_that_was_set_by_hand_on_update(): void + { + $order = DummyOrder::create(['name' => 'Blue shirt']); + + $order->update(['name' => 'Red hat', 'order_number' => 'ORD-MANUAL']); + + $this->assertSame('ORD-MANUAL', $order->fresh()->order_number); + } + + #[Test] + public function it_exposes_its_configs(): void + { + $order = new DummyOrder(); + + $this->assertInstanceOf(OrderNumberConfigs::class, $order->orderNumberConfigs()); + $this->assertSame('order_number', $order->orderNumberConfig('destinationColumn')); + } +} diff --git a/tests/Feature/HasSkuTest.php b/tests/Feature/HasSkuTest.php new file mode 100644 index 0000000..ab3ec29 --- /dev/null +++ b/tests/Feature/HasSkuTest.php @@ -0,0 +1,63 @@ + 'Blue shirt']); + + $this->assertMatchesRegularExpression('/^BLU-\d{10}$/', $product->sku); + $this->assertSame($product->sku, $product->fresh()->sku); + } + + #[Test] + public function it_refreshes_the_sku_when_the_source_column_changes(): void + { + $product = DummyProduct::create(['name' => 'Blue shirt']); + + $product->update(['name' => 'Red hat']); + + $this->assertMatchesRegularExpression('/^RED-\d{10}$/', $product->sku); + } + + #[Test] + public function it_keeps_a_sku_that_was_set_by_hand_on_update(): void + { + $product = DummyProduct::create(['name' => 'Blue shirt']); + + $product->update(['name' => 'Red hat', 'sku' => 'MANUAL-1']); + + $this->assertSame('MANUAL-1', $product->fresh()->sku); + } + + #[Test] + public function it_leaves_the_sku_alone_when_an_unrelated_column_changes(): void + { + $product = DummyProduct::create(['name' => 'Blue shirt']); + $sku = $product->sku; + + $product->touch(); + + $this->assertSame($sku, $product->fresh()->sku); + } + + #[Test] + public function it_exposes_its_configs(): void + { + $product = new DummyProduct(); + + $this->assertInstanceOf(SkuConfigs::class, $product->skuConfigs()); + $this->assertSame('sku', $product->skuConfig('destinationColumn')); + $this->assertSame(['name'], $product->skuConfig('sourceColumn')); + } +} diff --git a/tests/Feature/HasTicketNumberTest.php b/tests/Feature/HasTicketNumberTest.php new file mode 100644 index 0000000..e7524ad --- /dev/null +++ b/tests/Feature/HasTicketNumberTest.php @@ -0,0 +1,41 @@ + 'Printer jam']); + + $this->assertMatchesRegularExpression('/^PRI-\d{10}$/', $ticket->ticket_number); + $this->assertNull($ticket->sku); + } + + #[Test] + public function it_refreshes_the_ticket_number_when_the_source_column_changes(): void + { + $ticket = DummyTicket::create(['name' => 'Printer jam']); + + $ticket->update(['name' => 'Network down']); + + $this->assertMatchesRegularExpression('/^NET-\d{10}$/', $ticket->ticket_number); + } + + #[Test] + public function it_exposes_its_configs(): void + { + $ticket = new DummyTicket(); + + $this->assertInstanceOf(TicketNumberConfigs::class, $ticket->ticketNumberConfigs()); + $this->assertSame('ticket_number', $ticket->ticketNumberConfig('destinationColumn')); + } +} diff --git a/tests/Feature/LegacyConfigTest.php b/tests/Feature/LegacyConfigTest.php new file mode 100644 index 0000000..48b88ad --- /dev/null +++ b/tests/Feature/LegacyConfigTest.php @@ -0,0 +1,34 @@ + $legacy]; + } + + #[Test] + public function it_honours_a_config_published_under_the_bare_key(): void + { + $this->assertSame('_', config('simtabi.lacommerce.generator.default.separator')); + $this->assertSame('_', config('lacommerce.generator.default.separator')); + + $product = DummyProduct::create(['name' => 'Blue shirt']); + + $this->assertMatchesRegularExpression('/^BLU_\d{10}$/', $product->sku); + } +} diff --git a/tests/Feature/ScopedConfigPrecedenceTest.php b/tests/Feature/ScopedConfigPrecedenceTest.php new file mode 100644 index 0000000..93cf07e --- /dev/null +++ b/tests/Feature/ScopedConfigPrecedenceTest.php @@ -0,0 +1,32 @@ + $legacy, 'simtabi.lacommerce' => $scoped]; + } + + #[Test] + public function the_vendor_scoped_config_takes_precedence_over_the_bare_one(): void + { + $this->assertSame('/', config('simtabi.lacommerce.generator.default.separator')); + $this->assertSame('/', config('lacommerce.generator.default.separator')); + } +} diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php new file mode 100644 index 0000000..d873636 --- /dev/null +++ b/tests/Feature/ServiceProviderTest.php @@ -0,0 +1,83 @@ +assertSame('-', config('simtabi.lacommerce.generator.default.separator')); + $this->assertSame('sku', config('simtabi.lacommerce.generator.sku.destination_column')); + $this->assertSame('order_number', config('simtabi.lacommerce.generator.order_number.destination_column')); + $this->assertSame('ticket_number', config('simtabi.lacommerce.generator.ticket_number.destination_column')); + } + + #[Test] + public function it_mirrors_the_config_to_the_deprecated_bare_key(): void + { + $this->assertSame(config('simtabi.lacommerce'), config('lacommerce')); + } + + #[Test] + public function it_registers_the_vendor_scoped_publish_tag(): void + { + $paths = ServiceProvider::pathsToPublish(LacommerceServiceProvider::class, 'simtabi::lacommerce-config'); + + $this->assertCount(1, $paths); + $this->assertSame(config_path('simtabi/lacommerce.php'), array_values($paths)[0]); + } + + #[Test] + public function it_keeps_the_deprecated_bare_publish_tag(): void + { + $paths = ServiceProvider::pathsToPublish(LacommerceServiceProvider::class, 'lacommerce:config'); + + $this->assertCount(1, $paths); + $this->assertSame(config_path('lacommerce.php'), array_values($paths)[0]); + } + + #[Test] + public function it_registers_no_tag_for_directories_the_package_does_not_ship(): void + { + $groups = ServiceProvider::publishableGroups(); + + foreach (['assets', 'views', 'translations'] as $suffix) { + $this->assertNotContains("lacommerce:{$suffix}", $groups); + $this->assertNotContains("simtabi::lacommerce-{$suffix}", $groups); + } + } + + #[Test] + public function it_does_not_load_migrations_into_the_host_application(): void + { + $this->assertSame([], $this->app['migrator']->paths()); + } + + #[Test] + public function it_binds_the_configs_for_each_generator(): void + { + $this->assertSame('sku', $this->app->make(SkuConfigs::class)->getDestinationColumn()); + $this->assertSame('order_number', $this->app->make(OrderNumberConfigs::class)->getDestinationColumn()); + $this->assertSame('ticket_number', $this->app->make(TicketNumberConfigs::class)->getDestinationColumn()); + } + + #[Test] + public function it_registers_the_str_macros(): void + { + $this->assertTrue(Str::hasMacro('sku')); + $this->assertTrue(Str::hasMacro('orderNumber')); + $this->assertTrue(Str::hasMacro('ticketNumber')); + } +} diff --git a/tests/Fixtures/Models/DummyOrder.php b/tests/Fixtures/Models/DummyOrder.php new file mode 100644 index 0000000..1eadf66 --- /dev/null +++ b/tests/Fixtures/Models/DummyOrder.php @@ -0,0 +1,17 @@ +table(), function (Blueprint $table) { $table->increments('id'); $table->string('sku')->index()->nullable(); + $table->string('order_number')->index()->nullable(); + $table->string('ticket_number')->index()->nullable(); $table->string('name'); $table->timestamps(); }); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..582f2b7 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,66 @@ +set('database.default', 'testing'); + } + + /** + * Config an application has published, applied where Laravel applies config files: after + * they load and before any provider registers. defineEnvironment() runs too late for that. + * + * @return array + */ + protected function publishedConfig(): array + { + return []; + } + + protected function resolveApplicationConfiguration($app) + { + parent::resolveApplicationConfiguration($app); + + foreach ($this->publishedConfig() as $key => $value) { + $app['config']->set($key, $value); + } + } + + /** + * The package's default config file, for building a published copy. + * + * @return array + */ + protected static function defaultConfig(): array + { + return require __DIR__ . '/../config/config.php'; + } + + protected function defineDatabaseMigrations(): void + { + require_once __DIR__ . '/Fixtures/database/migrations/CreateDummyModelsTable.php'; + + $migration = new \CreateDummyModelsTable(); + $migration->up(); + + $this->beforeApplicationDestroyed(static function () use ($migration): void { + if (Schema::hasTable('dummy_models')) { + $migration->down(); + } + }); + } +}