diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 69ba19e..480de9c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,3 +37,7 @@ jobs: - name: Execute tests run: vendor/bin/pest + + - name: Static analysis + run: vendor/bin/phpstan analyse --no-progress + if: matrix.php == '8.3' diff --git a/.gitignore b/.gitignore index ae326e9..bac18d6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .phpunit.result.cache READMETODO.md build -docs/superpowers/ \ No newline at end of file +docs/superpowers/ +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..091a47b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +## [3.0.0] - TBD + +### Added + +- **Storage driver system** (`CartDriver` interface, `CartManager`, `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver`) +- **`Cart::fake()`** - swaps the active driver for a `NullDriver` and returns a `CartFactory` for fluent test state seeding +- **Assertion methods** on `Cart`: `assertContains()`, `assertCount()`, `assertTotalQuantity()`, `assertSubTotal()`, `assertTotal()`, `assertConditionApplied()`, `assertEmpty()`, `assertNotEmpty()` +- **`CartFactory`** - fluent builder for seeding test cart state (`withItems()`, `withCondition()`, `withCoupon()`) +- **`Coupon` abstract class** - extend to create first-class coupon objects that convert to `CartCondition` +- **`TaxRule` abstract class** - extend to create first-class tax rules that convert to `CartCondition` +- **`ShippingRate` abstract class** - extend to create first-class shipping rates that convert to `CartCondition` +- **`Cart::coupon()`**, **`Cart::tax()`**, **`Cart::shipping()`** - accept first-class objects or raw `CartCondition` +- **`HasCart` trait** - Livewire-aware trait with reactive `$cartItems`, `$cartTotal`, `$cartSubTotal`, `$cartCount` and proxy methods +- **`ShareCartWithInertia`** - static class for sharing cart state via `HandleInertiaRequests::share()` +- **`cart:install`** Artisan command - publishes config and scaffolds `app/Cart/` directory structure +- **`cart:make:coupon`** Artisan command - generates a `Coupon` subclass stub +- **`cart:make:tax`** Artisan command - generates a `TaxRule` subclass stub +- **`cart:make:shipping`** Artisan command - generates a `ShippingRate` subclass stub +- **`cart:make:driver`** Artisan command - generates a `CartDriver` implementation stub +- **`cart:debug`** Artisan command - dumps current cart state (non-production only) +- Laravel 13 support + +### Changed + +- `Cart` constructor now accepts a `CartDriver` instance instead of raw session/database storage +- Config publish tag renamed from `config` to `cart-config` + +### Removed + +- `Wearepixel\Cart\CartSession` - replaced by the driver system + +## [2.1.1] - 2026-05-27 + +### Fixed + +- PHP 8.4 deprecation warnings from Mockery implicit-nullable parameters in test bootstrap +- Upgraded CI runners from deprecated `ubuntu-20.04` to `ubuntu-latest` + +### Changed + +- `pestphp/pest` upgraded from `^3.0` to `^4.7` + +## [2.1.0] - 2024-01-01 + +### Added + +- Laravel 12 support + +## [2.0.0] - 2023-01-01 + +### Added + +- Initial v2 release diff --git a/README.md b/README.md index 41a9245..3d5bd76 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ For Laravel 9.0 and below, please use version [1.0](https://github.com/wearepixel/laravel-cart/releases/tag/1.0.12) +> **Upgrading from v2?** See the [Upgrade Guide](UPGRADE.md) for breaking changes and recommended v3 patterns. + ## Table of Contents - [Getting Started](#-getting-started) @@ -22,7 +24,11 @@ ## Table of Contents - [Basic Usage](#basic-usage) - [Conditions](#conditions) - [Cart Items](#cart-items) - - [Database Support](#database-support) + - [Storage Drivers](#storage-drivers) + - [Artisan Generators](#artisan-generators) + - [First-class Objects](#first-class-objects) + - [Testing](#testing) + - [Livewire / Inertia](#livewire--inertia) - [Events](#events) - [Credits](#-credits) - [License](#-license) @@ -73,7 +79,7 @@ ### ⚙️ Configuration You can publish the configuration file to customize various options. ```php -php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="config" +php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="cart-config" ``` #### Formatting Numbers @@ -601,6 +607,42 @@ #### Conditions on items Cart::add($item); ``` +##### Limiting a condition to a specific quantity of items + +By default, an item condition applies to every unit of the item. Use `applies_to` to limit it to the first N units: + +```php +// 5% off the first item only - for 2x $50 items: (50*0.95) + 50 = $97.50 +$firstItemDiscount = new CartCondition([ + 'name' => '5% Off First Item', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 1, +]); + +Cart::add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $firstItemDiscount, +]); + +Cart::getSubTotal(); // 97.50 +``` + +When combining conditions, those without `applies_to` apply to every unit while those with `applies_to` only apply to the first N: + +```php +$limitedDiscount = new CartCondition(['name' => '10% Off First', 'type' => 'discount', 'value' => '-10%', 'applies_to' => 1]); +$globalDiscount = new CartCondition(['name' => '5% Off All', 'type' => 'discount', 'value' => '-5%']); + +// Item 1: 100 * 0.90 * 0.95 = 85.50 +// Item 2: 100 * 0.95 = 95.00 +// Subtotal: 180.50 +``` + > NOTE: All cart per-item conditions should be added before calling **Cart::getSubTotal()** Then Finally you can call **Cart::getSubTotal()** to get the Cart sub total with the applied conditions on each of the items. @@ -693,55 +735,282 @@ ### Cart Items $item->getPriceSumWithConditions(); ``` -### Storage Options +### Storage Drivers -By default the cart is stored in the session, but there are times you may want to store the cart in the database. +The cart supports multiple storage backends via a driver system. -For instance, you may want to store the cart in the database so that the cart can be retrieved even after the user logs out or closes the browser, or you may want to add cart timeouts and support for multiple computers. +#### Session (default) -#### Session +Cart data is stored in the Laravel session. No configuration needed. -The cart is stored in the session by default, using Laravel's in-built SessionManager. +#### Database -#### Database Support +Store the cart in a database table for persistence across sessions. -To get started, you'll need to create a new model for your Cart, the package requires only 3 columns, but you're free to extend this as you wish and add more columns. - -You can utilise the [events](#events) provided by the package to store additional information alongside the cart. +**1. Create a migration:** ```php -$table->string('session_id'); // this handles the session id of the cart -$table->text('items'); // this will store the cart items -$table->text('conditions'); // this will store the cart level conditions +$table->string('session_id'); +$table->text('items'); +$table->text('conditions'); ``` -Then add some json casts to your model and fillable columns: +**2. Add casts to your model:** ```php protected $guarded = []; protected $casts = [ - 'items' => 'array', + 'items' => 'array', 'conditions' => 'array', ]; ``` -Then update the configuration file to use the database driver: +**3. Update `config/cart.php`:** ```php 'driver' => 'database', -'storage' => [ - 'session', +'drivers' => [ 'database' => [ - 'model' => \App\Models\Cart::class, // your model here - 'id' => 'session_id', - 'items' => 'items', + 'model' => \App\Models\Cart::class, + 'id' => 'session_id', + 'items' => 'items', 'conditions' => 'conditions', ], ], ``` +#### Redis + +Store the cart in Redis with an optional TTL. + +```php +'driver' => 'redis', + +'drivers' => [ + 'redis' => [ + 'connection' => 'default', + 'ttl' => 604800, // 7 days in seconds + ], +], +``` + +#### Multi-driver + +Write to multiple drivers simultaneously. Reads come from the first driver listed. + +```php +'driver' => 'multi', + +'drivers' => [ + 'multi' => ['session', 'database'], +], +``` + +#### Custom driver + +Generate a custom driver stub with `php artisan cart:make:driver MyDriver` then register it in `CartManager`. + +### Artisan Generators + +#### Install: **cart:install** + +Publishes the config file and scaffolds `app/Cart/` with subdirectories for your coupons, tax rules, shipping rates, and custom drivers. + +```bash +php artisan cart:install +``` + +#### Generate a Coupon: **cart:make:coupon** + +```bash +php artisan cart:make:coupon TenPercentOff +``` + +Creates `app/Cart/Coupons/TenPercentOff.php` extending `Wearepixel\Cart\Coupons\Coupon`. + +#### Generate a Tax Rule: **cart:make:tax** + +```bash +php artisan cart:make:tax GstTaxRule +``` + +Creates `app/Cart/Tax/GstTaxRule.php` extending `Wearepixel\Cart\Tax\TaxRule`. + +#### Generate a Shipping Rate: **cart:make:shipping** + +```bash +php artisan cart:make:shipping FlatRateShipping +``` + +Creates `app/Cart/Shipping/FlatRateShipping.php` extending `Wearepixel\Cart\Shipping\ShippingRate`. + +#### Generate a Custom Driver: **cart:make:driver** + +```bash +php artisan cart:make:driver ElasticsearchDriver +``` + +Creates `app/Cart/Drivers/ElasticsearchDriver.php` implementing `CartDriver`. + +#### Debug: **cart:debug** + +Dumps the current cart state. Not available in production. + +```bash +php artisan cart:debug +``` + +### First-class Objects + +Instead of building raw `CartCondition` arrays, you can extend the provided base classes. + +#### Coupon + +```php +namespace App\Cart\Coupons; + +use Wearepixel\Cart\Coupons\Coupon; + +class TenPercentOff extends Coupon +{ + protected string $code = 'SAVE10'; + protected string $value = '-10%'; + protected string $target = 'subtotal'; + + public function isValid(): bool + { + return true; // add your own validation logic + } +} + +// Apply to the cart +Cart::coupon(new TenPercentOff); +``` + +#### TaxRule + +```php +namespace App\Cart\Tax; + +use Wearepixel\Cart\Tax\TaxRule; + +class GstTaxRule extends TaxRule +{ + protected string $name = 'GST'; + protected string $value = '10%'; + protected string $target = 'subtotal'; + + public function isApplicable(): bool + { + return true; + } +} + +Cart::tax(new GstTaxRule); +``` + +#### ShippingRate + +```php +namespace App\Cart\Shipping; + +use Wearepixel\Cart\Shipping\ShippingRate; + +class FlatRateShipping extends ShippingRate +{ + protected string $name = 'Standard Shipping'; + protected string $value = '+10'; + protected string $target = 'total'; + + public function isApplicable(): bool + { + return true; + } +} + +Cart::shipping(new FlatRateShipping); +``` + +### Testing + +#### Cart::fake() + +`Cart::fake()` replaces the active driver with an in-memory `NullDriver` and returns a `CartFactory` for seeding test state. + +```php +use Wearepixel\Cart\Cart; + +// In your test +$factory = app('cart')->fake(); + +$factory->withItems(3); +$factory->withCondition(new CartCondition(['name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal'])); + +// Or chain it +app('cart')->fake()->withItems(2)->withCondition($condition); +``` + +#### Assertion Methods + +After `fake()`, the `Cart` instance exposes assertion methods: + +```php +$cart = app('cart'); +$cart->fake(); +$cart->add(1, 'Widget', 50.00, 2); + +$cart->assertContains(1); // item exists +$cart->assertCount(1); // 1 distinct item +$cart->assertTotalQuantity(2); // 2 units +$cart->assertSubTotal(100.00); // subtotal +$cart->assertTotal(100.00); // total +$cart->assertNotEmpty(); // cart has items +$cart->assertEmpty(); // cart is empty +$cart->assertConditionApplied('GST'); // condition present +``` + +### Livewire / Inertia + +#### HasCart trait (Livewire) + +Add `HasCart` to any Livewire component to get reactive cart state and proxy methods. + +```php +use Wearepixel\Cart\Concerns\HasCart; + +class CartComponent extends Component +{ + use HasCart; + + // $cartItems, $cartCount, $cartSubTotal, $cartTotal are auto-populated + + public function addToCart(int $id, string $name, float $price): void + { + $this->cartAdd($id, $name, $price, 1); + // $cartCount, $cartSubTotal etc. update automatically + } +} +``` + +#### ShareCartWithInertia (Inertia) + +Share cart state on every Inertia response from your `HandleInertiaRequests` middleware: + +```php +use Wearepixel\Cart\Concerns\ShareCartWithInertia; + +public function share(Request $request): array +{ + return array_merge(parent::share($request), [ + 'cart' => fn() => ShareCartWithInertia::data(), + ]); +} +``` + +This shares `cart.items`, `cart.subtotal`, `cart.total`, and `cart.count` with every page. + ### Events The package provides a few events that you can listen to in order to manipulate the cart or take actions based on cart events. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..edd4c49 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,165 @@ +# Upgrade Guide + +## v2.x to v3.0 + +### Breaking changes + +#### Constructor signature + +`Cart` no longer accepts a raw session/database storage object as its first constructor argument. It now requires a `CartDriver` instance. If you were resolving `Cart` directly (not via the service container), update your instantiation: + +**Before:** + +```php +new Cart($sessionStorage, $events, 'cart', $sessionKey, $config); +``` + +**After:** + +```php +use Wearepixel\Cart\Drivers\SessionDriver; + +new Cart(new SessionDriver($session, $sessionKey), $events, 'cart', $config); +``` + +Applications that use the service container (the normal case) are unaffected - `CartServiceProvider` handles this automatically. + +#### Publish tag renamed + +The config publish tag changed from `config` to `cart-config` to avoid conflicts with other packages: + +**Before:** + +```bash +php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=config +``` + +**After:** + +```bash +php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=cart-config +# or simply: +php artisan cart:install +``` + +#### CartSession removed + +`Wearepixel\Cart\CartSession` has been removed. It is replaced by the driver system. If you extended or referenced `CartSession` directly, switch to the appropriate driver (`SessionDriver`, `DatabaseDriver`, etc.). + +### Non-breaking additions + +The following are additive and require no changes to existing code: + +- **Storage drivers:** `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver` - configure via `config/cart.php` +- **CartManager:** resolves drivers by name, accessible via `app('cart.manager')` +- **First-class objects:** `Coupon`, `TaxRule`, `ShippingRate` abstract base classes - optional, existing raw `CartCondition` usage continues to work +- **Cart::fake():** returns a `CartFactory` backed by an in-memory `NullDriver` for testing +- **Assertion methods:** `Cart::assertContains()`, `assertCount()`, `assertTotal()`, etc. +- **HasCart trait:** Livewire reactive cart state +- **ShareCartWithInertia:** static class for Inertia middleware integration +- **Artisan generators:** `cart:install`, `cart:make:coupon`, `cart:make:tax`, `cart:make:shipping`, `cart:make:driver`, `cart:debug` + +--- + +### Recommended v3 patterns + +v3 is fully backwards compatible - your existing code will continue to work unchanged. The following patterns are recommended for new code and worth migrating to when you touch existing cart logic. + +#### 1. Publish the updated config + +Run the installer to get the new `driver` key and storage configuration in your `config/cart.php`: + +```bash +php artisan cart:install +``` + +The default driver is `session`, so behaviour is identical to v2 unless you change it. + +#### 2. Replace raw CartCondition arrays with first-class objects + +Generate typed classes with Artisan instead of constructing arrays inline: + +```bash +php artisan cart:make:coupon TenPercentOff +php artisan cart:make:tax GstTaxRule +php artisan cart:make:shipping FlatRateShipping +``` + +**Before:** + +```php +$condition = new CartCondition([ + 'name' => 'SAVE10', + 'type' => 'coupon', + 'target' => 'subtotal', + 'value' => '-10%', +]); + +Cart::condition($condition); +``` + +**After:** + +```php +// app/Cart/Coupons/TenPercentOff.php +class TenPercentOff extends Coupon +{ + protected string $code = 'SAVE10'; + protected string $value = '-10%'; + protected string $target = 'subtotal'; + + public function isValid(): bool + { + return true; + } +} + +Cart::coupon(new TenPercentOff); +Cart::tax(new GstTaxRule); +Cart::shipping(new FlatRateShipping); +``` + +The same pattern applies to `TaxRule` (implement `isApplicable()`) and `ShippingRate` (implement `isApplicable()`). + +#### 3. Use Cart::fake() in tests instead of mocking + +**Before:** + +```php +// Mocking the session or binding a fake driver manually +$this->mock(CartDriver::class, function ($mock) { + $mock->shouldReceive(...); +}); +``` + +**After:** + +```php +$factory = app('cart')->fake(); + +$factory->withItems(3)->withCondition(new GstTaxRule); + +$cart = app('cart'); +$cart->assertCount(3); +$cart->assertConditionApplied('GST'); +$cart->assertTotal(33.00); +``` + +`Cart::fake()` swaps the driver to an in-memory `NullDriver` for the duration of the test - no session, database, or Redis needed. + +#### 4. Switch to a persistent driver (optional) + +If you want cart state to survive beyond a session, update `config/cart.php`: + +```php +'driver' => 'database', // or 'redis' +``` + +For the database driver, publish and run the migration: + +```bash +php artisan cart:install +php artisan migrate +``` + +See the [Storage Drivers](README.md#storage-drivers) section of the README for full configuration options. diff --git a/composer.json b/composer.json index 44cccf1..6396812 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "wearepixel/laravel-cart", - "version": "2.1.4", + "version": "3.0.0", "description": "A cart implementation for Laravel", "keywords": [ "laravel", @@ -30,7 +30,8 @@ "mockery/mockery": "^2.0", "laravel/pint": "^1.21", "pestphp/pest": "^4.7", - "larapack/dd": "dev-master" + "larapack/dd": "dev-master", + "phpstan/phpstan": "2.2.x-dev" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index c12f801..477801f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "36458db92bb67aee0a12a15f588349b4", + "content-hash": "56b40d3f17cc40f1b62c3f71a3601af7", "packages": [ { "name": "brick/math", @@ -6518,6 +6518,71 @@ }, "time": "2026-05-25T15:58:15+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.2.x-dev", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/5c0bbadd35255710f7d2402cd6213a0bc34cfa34", + "reference": "5c0bbadd35255710f7d2402cd6213a0bc34cfa34", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "default-branch": true, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-06-05T16:36:10+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.5.x-dev", @@ -8189,7 +8254,8 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "larapack/dd": 20 + "larapack/dd": 20, + "phpstan/phpstan": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/config/config.php b/config/config.php index cebf9f3..906a9f3 100644 --- a/config/config.php +++ b/config/config.php @@ -16,16 +16,30 @@ * --------------------------------------------------------------- * Storage * --------------------------------------------------------------- + * Supported: "session", "database", "redis", "null", "multi" */ 'driver' => 'session', 'storage' => [ - 'session', 'database' => [ - 'model' => '', - 'id' => '', - 'items' => '', + 'model' => '', + 'id' => '', + 'items' => '', 'conditions' => '', ], ], + + /* + * --------------------------------------------------------------- + * Driver-specific options + * --------------------------------------------------------------- + */ + 'drivers' => [ + 'redis' => [ + 'connection' => 'default', + 'ttl' => 604800, // 7 days in seconds + ], + + 'multi' => ['session', 'database'], + ], ]; diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..48daf68 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,42 @@ +validateConfig($config); - + $this->driver = $driver; $this->events = $events; - $this->session = new CartSession($sessionStorage, $sessionKey, $config); $this->instanceName = $instanceName; $this->config = $config; $this->currentItemId = null; @@ -35,71 +42,122 @@ public function __construct($sessionStorage, $events, $instanceName, $sessionKey $this->fireEvent('Created'); } - private function validateConfig($config) + public static function extend(string $name, Closure $resolver): void { - if ($config['driver'] == 'database') { - if (! isset($config['storage']['database']['model'])) { - throw new \Exception('You need to set a model for the database driver in the cart configuration.'); - } + app('cart.manager')->extend($name, fn($app) => $resolver($app['session']->getId(), config('cart'))); + } - if (! isset($config['storage']['database']['id'])) { - throw new \Exception('You need to set an id (for the session) for the database driver in the cart configuration.'); - } + public function getDriver(): CartDriver + { + return $this->driver; + } - if (! isset($config['storage']['database']['items'])) { - throw new \Exception('You need to set an items key for the database driver in the cart configuration.'); - } + /** + * @internal For testing purposes only. Not intended for production use. + */ + public function fake(): CartFactory + { + $this->driver = new NullDriver($this->driver->getSessionKey()); - if (! isset($config['storage']['database']['conditions'])) { - throw new \Exception('You need to set a conditions key for the database driver in the cart configuration.'); - } - } + return new CartFactory($this); + } + + public function assertContains(int|string $id): void + { + Assert::assertTrue( + $this->has($id), + "Failed asserting that cart contains item [{$id}]." + ); + } + + public function assertCount(int $count): void + { + Assert::assertCount( + $count, + $this->getContent(), + "Failed asserting that cart has [{$count}] items." + ); + } + + public function assertTotalQuantity(int|float $quantity): void + { + Assert::assertEqualsWithDelta( + $quantity, + (float) $this->getTotalQuantity(), + 0.0001, + "Failed asserting that cart total quantity is [{$quantity}]." + ); + } + + public function assertSubTotal(float $amount): void + { + Assert::assertEqualsWithDelta( + $amount, + (float) $this->getSubTotal(false), + 0.0001, + "Failed asserting that cart subtotal is [{$amount}]." + ); + } + + public function assertTotal(float $amount): void + { + Assert::assertEqualsWithDelta( + $amount, + (float) $this->getTotal(), + 0.0001, + "Failed asserting that cart total is [{$amount}]." + ); + } + + public function assertConditionApplied(string $conditionName): void + { + Assert::assertNotNull( + $this->getCondition($conditionName), + "Failed asserting that condition [{$conditionName}] is applied to the cart." + ); + } + + public function assertEmpty(): void + { + Assert::assertTrue( + $this->isEmpty(), + 'Failed asserting that the cart is empty.' + ); + } + + public function assertNotEmpty(): void + { + Assert::assertFalse( + $this->isEmpty(), + 'Failed asserting that the cart is not empty.' + ); } public function getSessionKey(): string { - return $this->session->getSessionKey(); + return $this->driver->getSessionKey(); } public function setSessionKey(string $sessionKey): void { - $this->session->setSessionKey($sessionKey); + $this->driver->setSessionKey($sessionKey); } - /** - * get instance name of the cart - * - * @return string - */ - public function getInstanceName() + public function getInstanceName(): string { return $this->instanceName; } - /** - * get an item on a cart by item ID - */ public function get($itemId): ?ItemCollection { return $this->getContent()->get($itemId); } - /** - * check if we have this item in the cart by item ID - */ public function has($itemId): bool { return $this->getContent()->has($itemId); } - /** - * add item to the cart, it can be an array or multi dimensional array - * - * @param string|array $id - * @return $this - * - * @throws InvalidItemException - */ public function add( string|int|array $id, ?string $name = null, @@ -109,15 +167,13 @@ public function add( array|CartCondition $conditions = [], ?string $associatedModel = null ): self { - // check if we are adding an array of items if (is_array($id)) { - // check if we are adding a multi dimensional array if (Helpers::isMultiArray($id)) { foreach ($id as $item) { $this->add( $item['id'], $item['name'], - isset($item['price']) ? $item['price'] : null, + $item['price'] ?? null, $item['quantity'], Helpers::issetAndHasValueOrAssignDefault($item['attributes'], []), Helpers::issetAndHasValueOrAssignDefault($item['conditions'], []), @@ -128,7 +184,7 @@ public function add( $this->add( $id['id'], $id['name'], - isset($id['price']) ? $id['price'] : null, + $id['price'] ?? null, $id['quantity'], Helpers::issetAndHasValueOrAssignDefault($id['attributes'], []), Helpers::issetAndHasValueOrAssignDefault($id['conditions'], []), @@ -139,24 +195,21 @@ public function add( return $this; } - // if we are here, we are adding a single item $data = [ - 'id' => $id, - 'name' => $name, - 'price' => Helpers::normalizePrice($price), - 'quantity' => $quantity, + 'id' => $id, + 'name' => $name, + 'price' => Helpers::normalizePrice($price), + 'quantity' => $quantity, 'attributes' => new ItemAttributeCollection($attributes), 'conditions' => $conditions, ]; - if (isset($associatedModel) && $associatedModel != '') { + if (isset($associatedModel) && $associatedModel !== '') { $data['associatedModel'] = $associatedModel; } - // validate data $item = $this->validate($data); - // if the item is already in the cart we will just update it if ($this->get($id)) { $this->update($id, $item); } else { @@ -164,25 +217,15 @@ public function add( } $this->currentItemId = $id; - - // clean up any incomplete items $this->removeInvalidItems(); return $this; } - /** - * Remove any incomplete items - */ public function removeInvalidItems(): bool { - $carts = $this->getContent(); - - foreach ($carts as $index => $cart) { - - if (! (isset($cart['id'], $cart['name'], $cart['quantity']))) { - - // remove item + foreach ($this->getContent() as $index => $cart) { + if (! isset($cart['id'], $cart['name'], $cart['quantity'])) { $this->remove($index); } } @@ -190,9 +233,6 @@ public function removeInvalidItems(): bool return true; } - /** - * Update a cart item with whatever data is passed in - */ public function update(int|string $id, array $data): bool { if ($this->fireEvent('Updating', 'item', $data) === false) { @@ -200,67 +240,44 @@ public function update(int|string $id, array $data): bool } $cart = $this->getContent(); - $item = $cart->get($id); foreach ($data as $key => $value) { - if ($key == 'quantity') { + if ($key === 'quantity') { if (is_array($value)) { - if (isset($value['relative'])) { - if (isset($value['relative']) && $value['relative'] === true) { - $item = $this->updateQuantityRelative($item, $key, $value['value']); - } else { - $item = $this->updateQuantityNotRelative($item, $key, $value['value']); - } + if (isset($value['relative']) && $value['relative'] === true) { + $item = $this->updateQuantityRelative($item, $key, $value['value']); + } else { + $item = $this->updateQuantityNotRelative($item, $key, $value['value']); } } else { $item = $this->updateQuantityRelative($item, $key, $value); } - } elseif ($key == 'attributes') { + } elseif ($key === 'attributes') { $item[$key] = new ItemAttributeCollection($value); } else { $item[$key] = $value; } } - // update the item in the cart $cart->put($id, $item); - $this->save($cart); - $this->fireEvent('Updated', 'item', $item); return true; } - /** - * Set the config - * - * @param array $config - * @return void - */ - public function setConfig($config) + public function setConfig(array $config): void { $this->config = $config; } - /** - * add condition on an existing item on the cart - * - * @param int|string $productId - * @param CartCondition $itemCondition - * @return $this - */ - public function addItemCondition($productId, $itemCondition) + public function addItemCondition($productId, $itemCondition): self { if ($product = $this->get($productId)) { - $conditionInstance = '\\Wearepixel\\Cart\\CartCondition'; + $conditionInstance = CartCondition::class; if ($itemCondition instanceof $conditionInstance) { - // we need to copy first to a temporary variable to hold the conditions - // to avoid hitting this error "Indirect modification of overloaded element of Wearepixel\Cart\ItemCollection has no effect" - // this is due to laravel Collection instance that implements Array Access - // // see link for more info: http://stackoverflow.com/questions/20053269/indirect-modification-of-overloaded-element-of-splfixedarray-has-no-effect $itemConditionTempHolder = $product['conditions']; if (is_array($itemConditionTempHolder)) { @@ -269,21 +286,14 @@ public function addItemCondition($productId, $itemCondition) $itemConditionTempHolder = $itemCondition; } - $this->update($productId, [ - 'conditions' => $itemConditionTempHolder, // the newly updated conditions - ]); + $this->update($productId, ['conditions' => $itemConditionTempHolder]); } } return $this; } - /** - * removes an item on cart by item ID - * - * @return bool - */ - public function remove($id) + public function remove($id): bool { $cart = $this->getContent(); @@ -292,48 +302,36 @@ public function remove($id) } $cart->forget($id); - $this->save($cart); - $this->fireEvent('Removed', 'item', $id); return true; } - public function clear() + public function clear(): bool { if ($this->fireEvent('Clearing') === false) { return false; } - $this->session->clear(); - + $this->driver->flush(); $this->fireEvent('Cleared'); return true; } - public function clearItems() + public function clearItems(): bool { if ($this->fireEvent('Clearing') === false) { return false; } - $this->session->clearItems(); - + $this->driver->clearItems(); $this->fireEvent('Cleared'); return true; } - /** - * Adds a condition to the cart - */ - /** - * Adds a condition to the cart - * - * @throws InvalidConditionException - */ public function condition(array|CartCondition $condition): self { if (is_array($condition)) { @@ -344,315 +342,201 @@ public function condition(array|CartCondition $condition): self return $this; } - if (! $condition instanceof CartCondition) { - throw new InvalidConditionException('Argument 1 must be an instance of \'Wearepixel\Cart\CartCondition\''); - } - $conditions = $this->getConditions(); - // Check if order has been applied - if ($condition->getOrder() == 0) { + if ($condition->getOrder() === 0) { $last = $conditions->last(); $condition->setOrder(! is_null($last) ? $last->getOrder() + 1 : 1); } $conditions->put($condition->getName(), $condition); - $conditions = $conditions->reject(function ($condition) { - return empty($condition); - })->sortBy(function ($condition, $key) { - return $condition->getOrder(); - }); + $conditions = $conditions->reject(fn($c) => empty($c)) + ->sortBy(fn($c) => $c->getOrder()); $this->saveConditions($conditions); return $this; } - /** - * get conditions applied on the cart - * - * @param bool $active - If true, only conditions actually applied on the cart are returned - */ - public function getConditions(bool $array = false, bool $active = false): CartConditionCollection|array + public function coupon(Coupon $coupon): self { - $conditions = new CartConditionCollection($this->session->getConditions()); - - $conditions = $conditions->reject(function ($condition) { - return empty($condition); - }) - ->transform(function ($condition) { - return $condition instanceof CartCondition ? $condition : new CartCondition($condition); - }); - - if ($active) { - return $conditions->filter(function (CartCondition $condition) { - $amount = $condition->getTarget() == 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); - - return $condition->getMinimum() === null || $condition->getMinimum() <= $amount; - })->filter(function (CartCondition $condition) { - $amount = $condition->getTarget() == 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); - - return $condition->getMaximum() === null || $condition->getMaximum() >= $amount; - }); - } - - return $array ? $conditions->map(function (CartCondition $condition) { - return $condition->toArray(); - })->toArray() : $conditions; + return $this->condition($coupon->toCondition()); } - /** - * get condition applied on the cart by its name - * - * @return CartCondition - */ - public function getCondition($conditionName) + public function tax(TaxRule $taxRule): self + { + return $this->condition($taxRule->toCondition()); + } + + public function shipping(ShippingRate $shippingRate): self + { + return $this->condition($shippingRate->toCondition()); + } + + public function getConditions(bool $array = false, bool $active = false): CartConditionCollection|array + { + $conditions = new CartConditionCollection($this->driver->getConditions()); + + $conditions = $conditions->reject(fn($c) => empty($c)) + ->transform(fn($c) => $c instanceof CartCondition ? $c : new CartCondition($c)); + + if ($active) { + return $conditions + ->filter(function (CartCondition $condition) { + $amount = $condition->getTarget() === 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); + + return $condition->getMinimum() === null || $condition->getMinimum() <= $amount; + }) + ->filter(function (CartCondition $condition) { + $amount = $condition->getTarget() === 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); + + return $condition->getMaximum() === null || $condition->getMaximum() >= $amount; + }); + } + + return $array + ? $conditions->map(fn(CartCondition $c) => $c->toArray())->toArray() + : $conditions; + } + + public function getCondition($conditionName): ?CartCondition { return $this->getConditions()->get($conditionName); } - /** - * Get all the condition filtered by Type - * Please Note that this will only return condition added on cart bases, not those conditions added - * specifically on an per item bases - * - * @return CartConditionCollection - */ - public function getConditionsByType($type) + public function getConditionsByType(string $type): CartConditionCollection { - return $this->getConditions()->filter(function (CartCondition $condition) use ($type) { - return $condition->getType() == $type; - }); + return $this->getConditions()->filter(fn(CartCondition $c) => $c->getType() === $type); } - /** - * Remove all the condition with the $type specified - */ - public function removeConditionsByType(string $type) + public function removeConditionsByType(string $type): void { - $this->getConditionsByType($type)->each(function ($condition) { - $this->removeCartCondition($condition->getName()); - }); + $this->getConditionsByType($type)->each(fn($c) => $this->removeCartCondition($c->getName())); } - /** - * Remove a condition from the cart - */ - public function removeCartCondition($conditionName) + public function removeCartCondition($conditionName): void { $conditions = $this->getConditions(); - $conditions->pull($conditionName); - $this->saveConditions($conditions); } - /** - * remove a condition that has been applied on an item that is already on the cart - * - * @return bool - */ - public function removeItemCondition($itemId, $conditionName) + public function removeItemCondition($itemId, $conditionName): bool { if (! $item = $this->getContent()->get($itemId)) { return false; } if ($this->itemHasConditions($item)) { - // NOTE: - // we do it this way, we get first conditions and store - // it in a temp variable $originalConditions, then we will modify the array there - // and after modification we will store it again on $item['conditions'] - // This is because of ArrayAccess implementation - // see link for more info: http://stackoverflow.com/questions/20053269/indirect-modification-of-overloaded-element-of-splfixedarray-has-no-effect - $tempConditionsHolder = $item['conditions']; - // if the item's conditions is in array format - // we will iterate through all of it and check if the name matches - // to the given name the user wants to remove, if so, remove it if (is_array($tempConditionsHolder)) { foreach ($tempConditionsHolder as $k => $condition) { - if ($condition->getName() == $conditionName) { + if ($condition->getName() === $conditionName) { unset($tempConditionsHolder[$k]); } } - $item['conditions'] = $tempConditionsHolder; - } - - // if the item condition is not an array, we will check if it is - // an instance of a Condition, if so, we will check if the name matches - // on the given condition name the user wants to remove, if so, - // lets just make $item['conditions'] an empty array as there's just 1 condition on it anyway - else { - $conditionInstance = 'Wearepixel\\Cart\\CartCondition'; - - if ($item['conditions'] instanceof $conditionInstance) { - if ($tempConditionsHolder->getName() == $conditionName) { + } else { + if ($item['conditions'] instanceof CartCondition) { + if ($tempConditionsHolder->getName() === $conditionName) { $item['conditions'] = []; } } } } - $this->update($itemId, [ - 'conditions' => $item['conditions'], - ]); + $this->update($itemId, ['conditions' => $item['conditions']]); return true; } - /** - * remove all conditions that has been applied on an item that is already on the cart - * - * @return bool - */ - public function clearItemConditions($itemId) + public function clearItemConditions($itemId): bool { - if (! $item = $this->getContent()->get($itemId)) { + if (! $this->getContent()->get($itemId)) { return false; } - $this->update($itemId, [ - 'conditions' => [], - ]); + $this->update($itemId, ['conditions' => []]); return true; } - /** - * Clear cart conditions (this does not clear item conditions) - */ public function clearCartConditions(): void { - $this->session->putConditions([]); + $this->driver->putConditions([]); } - /** - * Clear all conditions from all items and the cart - */ public function clearAllConditions(): void { - $cart = $this->getContent(); - - $cart->each(function ($item) { - $this->clearItemConditions($item->id); - }); - + $this->getContent()->each(fn($item) => $this->clearItemConditions($item->id)); $this->clearCartConditions(); } - /** - * get cart sub total without conditions - * - * @param bool $formatted - * @return float - */ - public function getSubTotalWithoutConditions($formatted = true) + public function getSubTotalWithoutConditions(bool $formatted = true): int|float|string { - $cart = $this->getContent(); - - $sum = $cart->sum(function ($item) { - return $item->getPriceSum(); - }); + $sum = $this->getContent()->sum(fn($item) => $item->getPriceSum()); return Helpers::formatValue(floatval($sum), $formatted, $this->config); } - public function getSubTotal($formatted = true): int|float|string + public function getSubTotal(bool $formatted = true): int|float|string { $subTotal = 0.00; - // add all the items together with conditions applied - $subTotalSum = $this->getContent()->sum(function (ItemCollection $item) { - return $item->getPriceSumWithConditions(false); - }); + $subTotalSum = $this->getContent()->sum(fn(ItemCollection $item) => $item->getPriceSumWithConditions(false)); - // get the conditions that are meant to be applied - // on the subtotal and apply it here before returning the subtotal $conditionsForSubtotal = $this->getConditions() - ->filter(function (CartCondition $condition) { - return $condition->getTarget() === 'subtotal'; - }) - ->filter(function (CartCondition $condition) use ($subTotalSum) { - return $condition->getMinimum() === null || $condition->getMinimum() <= $subTotalSum; - }) - ->filter(function (CartCondition $condition) use ($subTotalSum) { - return $condition->getMaximum() === null || $condition->getMaximum() >= $subTotalSum; - }) - ->sortBy(function (CartCondition $condition) { - return $condition->getOrder(); - }); + ->filter(fn(CartCondition $c) => $c->getTarget() === 'subtotal') + ->filter(fn(CartCondition $c) => $c->getMinimum() === null || $c->getMinimum() <= $subTotalSum) + ->filter(fn(CartCondition $c) => $c->getMaximum() === null || $c->getMaximum() >= $subTotalSum) + ->sortBy(fn(CartCondition $c) => $c->getOrder()); if ($conditionsForSubtotal->isEmpty()) { return Helpers::formatValue($subTotalSum, $formatted, $this->config); } $index = 0; - $conditionsForSubtotal->each(function (CartCondition $condition) use ($subTotalSum, &$subTotal, &$index) { $toBeCalculated = $index > 0 ? $subTotal : $subTotalSum; - $subTotal = $condition->applyCondition($toBeCalculated); - $index++; }); return Helpers::formatValue($subTotal, $formatted, $this->config); } - /** - * Get the total of the cart with conditions applied - */ public function getTotal(): int|float { $total = 0.00; $subTotal = $this->getSubTotal(false); $conditionsForTotal = $this->getConditions() - ->filter(function (CartCondition $condition) { - return $condition->getTarget() === 'total'; - }) - ->filter(function (CartCondition $condition) use ($subTotal) { - return $condition->getMinimum() === null || $condition->getMinimum() <= $subTotal; - }) - ->filter(function (CartCondition $condition) use ($subTotal) { - return $condition->getMaximum() === null || $condition->getMaximum() >= $subTotal; - }) - ->sortBy(function (CartCondition $condition) { - return $condition->getOrder(); - }); + ->filter(fn(CartCondition $c) => $c->getTarget() === 'total') + ->filter(fn(CartCondition $c) => $c->getMinimum() === null || $c->getMinimum() <= $subTotal) + ->filter(fn(CartCondition $c) => $c->getMaximum() === null || $c->getMaximum() >= $subTotal) + ->sortBy(fn(CartCondition $c) => $c->getOrder()); - // if no conditions were added, just return the sub total if (! $conditionsForTotal->count()) { return Helpers::formatValue($subTotal, $this->config['format_numbers'], $this->config); } $index = 0; - $conditionsForTotal->each(function (CartCondition $condition) use ($subTotal, &$total, &$index) { - $toBeCalculated = ($index > 0) ? $total : $subTotal; - + $toBeCalculated = $index > 0 ? $total : $subTotal; $total = $condition->applyCondition($toBeCalculated); - $index++; }); return Helpers::formatValue($total, $this->config['format_numbers'], $this->config); } - /** - * Get the calculated value for a condition by it's name - */ public function getCalculatedValueForCondition(string $conditionName): int|float { - $conditions = $this->getConditions(); - $subTotal = $this->getSubTotalWithoutConditions(false); - foreach ($conditions as $condition) { + foreach ($this->getConditions() as $condition) { $conditionValue = $condition->getCalculatedValue($subTotal); if ($condition->getName() === $conditionName) { @@ -665,12 +549,7 @@ public function getCalculatedValueForCondition(string $conditionName): int|float return 0; } - /** - * get total quantity of items in the cart - * - * @return int - */ - public function getTotalQuantity() + public function getTotalQuantity(): int|float { $items = $this->getContent(); @@ -678,39 +557,58 @@ public function getTotalQuantity() return 0; } - $count = $items->sum(function ($item) { - return $item['quantity']; - }); - - return $count; + return $items->sum(fn($item) => $item['quantity']); } public function getContent(): CartCollection { - return (new CartCollection($this->session->getItems()))->transform(function ($item) { - return $item instanceof ItemCollection ? $item : new ItemCollection($item, $this->config); - })->reject(function ($item) { - return ! ($item instanceof ItemCollection); - }); + return (new CartCollection($this->driver->getItems())) + ->transform(function ($item) { + if ($item instanceof ItemCollection) { + return $item; + } + + if (is_array($item) && isset($item['attributes']) && is_array($item['attributes'])) { + $item['attributes'] = new ItemAttributeCollection($item['attributes']); + } + + if (is_array($item) && isset($item['conditions']) && is_array($item['conditions'])) { + $item['conditions'] = array_map( + fn($c) => is_array($c) ? new CartCondition($c) : $c, + $item['conditions'] + ); + } + + return new ItemCollection($item, $this->config); + }) + ->reject(fn($item) => ! ($item instanceof ItemCollection)); } - public function isEmpty($test = null) + public function isEmpty(): bool { return $this->getContent()->isEmpty(); } - /** - * validate Item data - * - * @return array $item; - * - * @throws InvalidItemException - */ - protected function validate($item) + public function associate(mixed $model): self + { + if (is_string($model) && ! class_exists($model)) { + throw new UnknownModelException("The supplied model {$model} does not exist."); + } + + $cart = $this->getContent(); + $item = $cart->pull($this->currentItemId); + $item['associatedModel'] = $model; + $cart->put($this->currentItemId, new ItemCollection($item, $this->config)); + $this->save($cart); + + return $this; + } + + protected function validate(array $item): array { $rules = [ - 'id' => 'required', - 'name' => 'required', + 'id' => 'required', + 'name' => 'required', 'quantity' => 'required|numeric|min:0.1', ]; @@ -720,11 +618,9 @@ protected function validate($item) if ($validator->errors()->first('id') === 'validation.required') { throw new InvalidItemException('The id field is required.'); } - if ($validator->errors()->first('name') === 'validation.required') { throw new InvalidItemException('The name field is required.'); } - if ($validator->errors()->first('quantity') === 'validation.min.numeric') { throw new InvalidItemException('The quantity must be at least 0.1.'); } @@ -733,52 +629,61 @@ protected function validate($item) return $item; } - /** - * add row to cart collection - * - * @return bool - */ - protected function addItem($id, $item) + protected function addItem(int|string $id, array $item): bool { if ($this->fireEvent('Adding', 'item', $item) === false) { return false; } $cart = $this->getContent(); - $cart->put($id, new ItemCollection($item, $this->config)); - $this->save($cart); - $this->fireEvent('Added', 'item', $item); return true; } - /** - * save the cart - * - * @param $cart CartCollection - */ - protected function save($cart) + protected function save(CartCollection $cart): void { - $this->session->putItems($cart); + $this->driver->putItems($this->serializeItems($cart)); } - /** - * save the cart conditions - */ - protected function saveConditions($conditions) + protected function saveConditions(CartConditionCollection $conditions): void { - $this->session->putConditions($conditions); + $this->driver->putConditions($this->serializeConditions($conditions)); } - /** - * check if an item has condition - * - * @return bool - */ - protected function itemHasConditions($item) + private function serializeItems(CartCollection $cart): array + { + return $cart->map(function (ItemCollection $item) { + $data = $item->toArray(); + + $conditions = $item['conditions'] ?? []; + + if ($conditions instanceof CartCondition) { + $data['conditions'] = [$conditions->toArray()]; + } elseif (is_array($conditions) || $conditions instanceof \Traversable) { + $data['conditions'] = collect($conditions) + ->map(fn($c) => $c instanceof CartCondition ? $c->toArray() : $c) + ->toArray(); + } else { + $data['conditions'] = []; + } + + if ($item['attributes'] instanceof ItemAttributeCollection) { + $data['attributes'] = $item['attributes']->toArray(); + } + + return $data; + })->toArray(); + } + + private function serializeConditions(CartConditionCollection $conditions): array + { + return $conditions->map(fn(CartCondition $c) => $c->toArray())->all(); + } + + protected function itemHasConditions(ItemCollection $item): bool { if (! isset($item['conditions'])) { return false; @@ -788,32 +693,18 @@ protected function itemHasConditions($item) return count($item['conditions']) > 0; } - $conditionInstance = 'Wearepixel\\Cart\\CartCondition'; - - if ($item['conditions'] instanceof $conditionInstance) { - return true; - } - - return false; + return $item['conditions'] instanceof CartCondition; } - /** - * update a cart item quantity relative to its current quantity - * - * @return mixed - */ - protected function updateQuantityRelative($item, $key, $value) + protected function updateQuantityRelative(ItemCollection $item, string $key, mixed $value): ItemCollection { - if (preg_match('/\-/', $value) == 1) { - $value = (float) str_replace('-', '', $value); - - // we will not allowed to reduced quantity to 0, so if the given value - // would result to item quantity of 0, we will not do it. + if (preg_match('/\-/', (string) $value) === 1) { + $value = (float) str_replace('-', '', (string) $value); if (($item[$key] - $value) > 0) { $item[$key] -= $value; } - } elseif (preg_match('/\+/', $value) == 1) { - $item[$key] += (float) str_replace('+', '', $value); + } elseif (preg_match('/\+/', (string) $value) === 1) { + $item[$key] += (float) str_replace('+', '', (string) $value); } else { $item[$key] += (float) $value; } @@ -821,64 +712,25 @@ protected function updateQuantityRelative($item, $key, $value) return $item; } - /** - * update cart item quantity not relative to its current quantity value - * - * @return mixed - */ - protected function updateQuantityNotRelative($item, $key, $value) + protected function updateQuantityNotRelative(ItemCollection $item, string $key, mixed $value): ItemCollection { $item[$key] = (float) $value; return $item; } - /** - * Setter for decimals. Change value on demand. - */ - public function setDecimals($decimals) - { - $this->decimals = $decimals; - } - - protected function fireEvent($name, $type = null, $data = null) + protected function fireEvent(string $name, ?string $type = null, mixed $data = null): mixed { $eventData = [ - 'session_key' => $this->session->sessionKey, - 'session_driver' => $this->session->driver, - 'session_model' => $this->session->getSessionModel(), + 'session_key' => $this->driver->getSessionKey(), + 'session_driver' => $this->driver->getDriverName(), + 'session_model' => $this->driver->getSessionModel(), ]; if ($type) { $eventData[$type] = $data; } - return $this->events->dispatch('LaravelCart.' . $name, array_values([$eventData, $this]), true); - } - - /** - * Associate the cart item with the given id with the given model. - * - * @param string $id - * @param mixed $model - * @return void - */ - public function associate($model) - { - if (is_string($model) && ! class_exists($model)) { - throw new UnknownModelException("The supplied model {$model} does not exist."); - } - - $cart = $this->getContent(); - - $item = $cart->pull($this->currentItemId); - - $item['associatedModel'] = $model; - - $cart->put($this->currentItemId, new ItemCollection($item, $this->config)); - - $this->save($cart); - - return $this; + return $this->events->dispatch('LaravelCart.' . $name, [$eventData, $this], true); } } diff --git a/src/CartCondition.php b/src/CartCondition.php index 0908655..1612baa 100644 --- a/src/CartCondition.php +++ b/src/CartCondition.php @@ -78,6 +78,14 @@ public function getMaximum(): int|float|null return $this->args['maximum'] ?? null; } + /** + * Get the number of items this condition applies to (null = all items) + */ + public function getAppliesToQuantity(): int|null + { + return isset($this->args['applies_to']) ? (int) $this->args['applies_to'] : null; + } + /** * get the additional attributes of a condition * @@ -105,13 +113,11 @@ public function setValue($value): void } /** - * Set the order to apply this condition. If no argument order is applied we return 0 as - * indicator that no assignment has been made + * Set the order to apply this condition. * * @param int $order - * @return int */ - public function setOrder($order = 1) + public function setOrder($order = 1): void { $this->args['order'] = $order; } @@ -139,8 +145,6 @@ public function applyCondition($totalOrSubTotalOrPrice) /** * get the calculated value of this condition supplied by the subtotal|price - * - * @return mixed */ public function getCalculatedValue($totalOrSubTotalOrPrice = null): int|float { diff --git a/src/CartManager.php b/src/CartManager.php new file mode 100644 index 0000000..5ca36c4 --- /dev/null +++ b/src/CartManager.php @@ -0,0 +1,72 @@ +config->get('cart.driver', 'session'); + } + + private function sessionKey(): string + { + return $this->container['session']->getId(); + } + + public function createSessionDriver(): CartDriver + { + return new SessionDriver( + $this->container['session'], + $this->sessionKey(), + ); + } + + public function createDatabaseDriver(): CartDriver + { + $config = $this->config->get('cart.storage.database'); + + return new DatabaseDriver( + $config['model'], + $config['id'], + $config['items'], + $config['conditions'], + $this->sessionKey(), + ); + } + + public function createRedisDriver(): CartDriver + { + $config = $this->config->get('cart.drivers.redis', []); + + return new RedisDriver( + $this->container['redis'], + $this->sessionKey(), + $config['connection'] ?? 'default', + $config['ttl'] ?? 604800, + ); + } + + public function createNullDriver(): CartDriver + { + return new NullDriver($this->sessionKey()); + } + + public function createMultiDriver(): CartDriver + { + $names = $this->config->get('cart.drivers.multi', ['session', 'database']); + $drivers = array_map(fn(string $name) => $this->driver($name), $names); + + return new MultiDriver($drivers); + } +} diff --git a/src/CartServiceProvider.php b/src/CartServiceProvider.php index 08facff..2aeb010 100644 --- a/src/CartServiceProvider.php +++ b/src/CartServiceProvider.php @@ -1,63 +1,53 @@ app->runningInConsole()) { $this->publishes([ __DIR__ . '/../config/config.php' => config_path('cart.php'), - ], 'config'); + ], 'cart-config'); + + $this->commands([ + InstallCommand::class, + MakeCouponCommand::class, + MakeTaxCommand::class, + MakeShippingCommand::class, + MakeDriverCommand::class, + DebugCommand::class, + ]); } } - /** - * Register the service provider. - * - * @return void - */ - public function register() + public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'cart'); + $this->app->singleton('cart.manager', fn($app) => new CartManager($app)); + $this->app->singleton('cart', function ($app) { $config = config('cart'); $events = $app['events']; + $driver = $app['cart.manager']->driver(); - $storage = $config['driver'] === 'database' - ? new $config['storage']['database']['model'] - : $app['session']; - - return new Cart( - $storage, - $events, - 'cart', - session()->getId(), - $config - ); + return new Cart($driver, $events, 'cart', $config); }); } - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() + public function provides(): array { return []; } diff --git a/src/CartSession.php b/src/CartSession.php deleted file mode 100644 index 8803e50..0000000 --- a/src/CartSession.php +++ /dev/null @@ -1,172 +0,0 @@ -sessionKey = $sessionKey; - $this->driver = $config['driver']; - - if ($this->driver === 'session') { - $this->customStorage = false; - $this->session = $session; - $this->itemsKey = $this->sessionKey . '_cart_items'; - $this->conditionsKey = $this->sessionKey . '_cart_conditions'; - } elseif ($this->driver === 'database') { - $this->customStorage = true; - - $this->sessionId = $config['storage']['database']['id']; - $this->itemsKey = $config['storage']['database']['items']; - $this->conditionsKey = $config['storage']['database']['conditions']; - - $this->session = $session->firstOrNew([$this->sessionId => $this->sessionKey]); - - $this->session[$this->itemsKey] = $this->session[$this->itemsKey] ?? []; - $this->session[$this->conditionsKey] = $this->session[$this->conditionsKey] ?? []; - } - } - - public function isInternalSession($session) - { - return in_array(is_object($session) ? get_class($session) : $session, [ - 'Illuminate\Session\SessionManager', - 'Wearepixel\Cart\Tests\Helpers\SessionMock', - ]); - } - - public function getSessionKey() - { - return $this->sessionKey; - } - - public function setSessionKey(string $sessionKey) - { - // set the session key and then update the session - $this->sessionKey = $sessionKey; - - if ($this->driver == 'session') { - $session = $this->session->get($this->itemsKey); - $sessionKey = $this->session->get($this->conditionsKey); - - $this->session->forget($this->itemsKey); - $this->session->forget($this->conditionsKey); - - $this->itemsKey = $this->sessionKey . '_cart_items'; - $this->conditionsKey = $this->sessionKey . '_cart_conditions'; - - $this->session->put($this->itemsKey, $session); - $this->session->put($this->conditionsKey, $sessionKey); - } elseif ($this->driver == 'database') { - $this->session[$this->sessionId] = $sessionKey; - $this->session->save(); - } - } - - public function has($key) - { - return $this->session->has($key) ?? $this->session; - } - - public function getItems() - { - return $this->driver == 'session' ? $this->session->get($this->itemsKey) : $this->session[$this->itemsKey]; - } - - public function getConditions() - { - return $this->driver == 'session' ? $this->session->get($this->conditionsKey) : $this->session[$this->conditionsKey]; - } - - public function putItems($value) - { - if ($this->driver == 'session') { - return $this->session->put($this->itemsKey, $value); - } - - // convert any conditions to arrays - $value = is_array($value) ? $value : $value->toArray(); - - foreach ($value as $key => $item) { - if ($item['conditions']) { - if (is_array($item['conditions'])) { - $value[$key]['conditions'] = collect($item['conditions'])->map(function ($condition) { - return $condition->toArray(); - }); - } else { - $value[$key]['conditions'] = $item['conditions']->toArray(); - } - } - } - - $this->session[$this->itemsKey] = $value; - $this->session->save(); - - return $this->session; - } - - public function putConditions($value) - { - if ($this->driver == 'session') { - return $this->session->put($this->conditionsKey, $value); - } - - if (! empty($value)) { - $value = $value->mapWithKeys(function ($condition) { - return [$condition->getName() => $condition->toArray()]; - }); - } - - $this->session[$this->conditionsKey] = $value; - $this->session->save(); - - return $this->session; - } - - public function clearItems() - { - if ($this->driver == 'session') { - $this->session->forget($this->itemsKey); - - return true; - } - - $this->session[$this->itemsKey] = []; - - return $this->session->save(); - } - - public function clear() - { - if ($this->driver == 'session') { - $this->session->forget($this->itemsKey); - $this->session->forget($this->conditionsKey); - - return true; - } - - $deleted = $this->session->delete(); - - $this->session[$this->itemsKey] = []; - $this->session[$this->conditionsKey] = []; - - return $deleted; - } - - public function getSessionModel() - { - return $this->session; - } -} diff --git a/src/Commands/DebugCommand.php b/src/Commands/DebugCommand.php new file mode 100644 index 0000000..5607654 --- /dev/null +++ b/src/Commands/DebugCommand.php @@ -0,0 +1,67 @@ +isProduction()) { + $this->error('cart:debug is not available in production.'); + return self::FAILURE; + } + + $cart = app('cart'); + $data = static::debugData($cart); + + $this->info('Cart Debug'); + $this->line(''); + $this->line(" Driver: {$data['driver']}"); + $this->line(" Items: {$data['item_count']}"); + $this->line(" Total Quantity: {$data['total_quantity']}"); + $this->line(" Subtotal: {$data['subtotal']}"); + $this->line(" Total: {$data['total']}"); + + if (! empty($data['conditions'])) { + $this->line(" Conditions: " . implode(', ', $data['conditions']) . ''); + } else { + $this->line(' Conditions: none'); + } + + if (! empty($data['items'])) { + $this->line(''); + $this->table(['ID', 'Name', 'Price', 'Qty'], $data['items']); + } + + return self::SUCCESS; + } + + public static function debugData(Cart $cart): array + { + $items = $cart->getContent(); + + return [ + 'driver' => $cart->getDriver()->getDriverName(), + 'item_count' => $items->count(), + 'total_quantity' => (int) $cart->getTotalQuantity(), + 'subtotal' => (float) $cart->getSubTotal(false), + 'total' => (float) $cart->getTotal(), + 'conditions' => $cart->getConditions()->map(fn($c) => $c->getName())->values()->toArray(), + 'items' => $items->map(fn($item) => [ + $item->id, + $item->name, + $item->price, + $item->quantity, + ])->values()->toArray(), + ]; + } +} diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php new file mode 100644 index 0000000..a1c114b --- /dev/null +++ b/src/Commands/InstallCommand.php @@ -0,0 +1,41 @@ +call('vendor:publish', ['--tag' => 'cart-config', '--ansi' => true]); + + static::scaffoldDirectories(app_path('Cart')); + + $this->info('Cart installed successfully.'); + $this->newLine(); + $this->line(' Next steps:'); + $this->line(' 1. Review config/cart.php and set your preferred storage driver.'); + $this->line(' 2. Run php artisan cart:make:coupon MyCoupon to create your first coupon.'); + $this->line(' 3. Run php artisan cart:make:tax MyTax to add a tax rule.'); + $this->line(' 4. Run php artisan cart:make:shipping MyShipping to add a shipping rate.'); + + return self::SUCCESS; + } + + public static function scaffoldDirectories(string $base): void + { + foreach (['Coupons', 'Tax', 'Shipping', 'Drivers'] as $subdir) { + $path = "{$base}/{$subdir}"; + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + } + } +} diff --git a/src/Commands/MakeCouponCommand.php b/src/Commands/MakeCouponCommand.php new file mode 100644 index 0000000..b29305a --- /dev/null +++ b/src/Commands/MakeCouponCommand.php @@ -0,0 +1,75 @@ +argument('name'); + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) { + $this->error("Invalid class name: [{$name}]. Must be a valid PHP class name."); + + return self::FAILURE; + } + + $directory = app_path('Cart/Coupons'); + $path = "{$directory}/{$name}.php"; + + if (file_exists($path)) { + $this->error("Coupon already exists: app/Cart/Coupons/{$name}.php"); + + return self::FAILURE; + } + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + if (file_put_contents($path, static::stub($name)) === false) { + $this->error("Failed to write file: {$path}"); + + return self::FAILURE; + } + + $this->info("Coupon created: app/Cart/Coupons/{$name}.php"); + + return self::SUCCESS; + } + + public static function stub(string $className): string + { + return <<argument('name'); + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) { + $this->error("Invalid class name: [{$name}]. Must be a valid PHP class name."); + + return self::FAILURE; + } + + $directory = app_path('Cart/Drivers'); + $path = "{$directory}/{$name}.php"; + + if (file_exists($path)) { + $this->error("Driver already exists: app/Cart/Drivers/{$name}.php"); + + return self::FAILURE; + } + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + if (file_put_contents($path, static::stub($name)) === false) { + $this->error("Failed to write file: {$path}"); + + return self::FAILURE; + } + + $this->info("Driver created: app/Cart/Drivers/{$name}.php"); + $this->line(" Register it in config/cart.php under drivers."); + + return self::SUCCESS; + } + + public static function stub(string $className): string + { + return <<sessionKey = \$sessionKey; + } + + public function getSessionKey(): string + { + return \$this->sessionKey; + } + + public function setSessionKey(string \$sessionKey): void + { + \$this->sessionKey = \$sessionKey; + } + + public function getDriverName(): string + { + return 'custom'; + } + + public function getItems(): array + { + // TODO: retrieve items from your storage backend + return []; + } + + public function putItems(array \$items): void + { + // TODO: persist items to your storage backend + } + + public function getConditions(): array + { + // TODO: retrieve conditions from your storage backend + return []; + } + + public function putConditions(array \$conditions): void + { + // TODO: persist conditions to your storage backend + } + + public function clearItems(): void + { + // TODO: remove only items from your storage backend + } + + public function flush(): void + { + // TODO: remove both items and conditions from your storage backend + } + + public function getSessionModel(): mixed + { + return null; + } +} +PHP; + } +} diff --git a/src/Commands/MakeShippingCommand.php b/src/Commands/MakeShippingCommand.php new file mode 100644 index 0000000..ebe36c9 --- /dev/null +++ b/src/Commands/MakeShippingCommand.php @@ -0,0 +1,75 @@ +argument('name'); + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) { + $this->error("Invalid class name: [{$name}]. Must be a valid PHP class name."); + + return self::FAILURE; + } + + $directory = app_path('Cart/Shipping'); + $path = "{$directory}/{$name}.php"; + + if (file_exists($path)) { + $this->error("Shipping rate already exists: app/Cart/Shipping/{$name}.php"); + + return self::FAILURE; + } + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + if (file_put_contents($path, static::stub($name)) === false) { + $this->error("Failed to write file: {$path}"); + + return self::FAILURE; + } + + $this->info("Shipping rate created: app/Cart/Shipping/{$name}.php"); + + return self::SUCCESS; + } + + public static function stub(string $className): string + { + return <<argument('name'); + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) { + $this->error("Invalid class name: [{$name}]. Must be a valid PHP class name."); + + return self::FAILURE; + } + + $directory = app_path('Cart/Tax'); + $path = "{$directory}/{$name}.php"; + + if (file_exists($path)) { + $this->error("Tax rule already exists: app/Cart/Tax/{$name}.php"); + + return self::FAILURE; + } + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + if (file_put_contents($path, static::stub($name)) === false) { + $this->error("Failed to write file: {$path}"); + + return self::FAILURE; + } + + $this->info("Tax rule created: app/Cart/Tax/{$name}.php"); + + return self::SUCCESS; + } + + public static function stub(string $className): string + { + return <<getCart(); + + $this->cartItems = $cart->getContent()->toArray(); + $this->cartSubTotal = (float) $cart->getSubTotal(false); + $this->cartTotal = (float) $cart->getTotal(); + $this->cartCount = (int) $cart->getTotalQuantity(); + } + + public function cartAdd( + int|string $id, + string $name, + float $price, + int $quantity = 1, + array $attributes = [], + ): void { + $this->getCart()->add($id, $name, $price, $quantity, $attributes); + $this->refreshCart(); + } + + public function cartRemove(int|string $id): void + { + $this->getCart()->remove($id); + $this->refreshCart(); + } + + public function cartUpdate(int|string $id, array $data): void + { + $this->getCart()->update($id, $data); + $this->refreshCart(); + } + + public function cartClear(): void + { + $this->getCart()->clear(); + $this->refreshCart(); + } +} diff --git a/src/Concerns/ShareCartWithInertia.php b/src/Concerns/ShareCartWithInertia.php new file mode 100644 index 0000000..b5b733b --- /dev/null +++ b/src/Concerns/ShareCartWithInertia.php @@ -0,0 +1,22 @@ + $cart->getContent()->toArray(), + 'subtotal' => (float) $cart->getSubTotal(false), + 'total' => (float) $cart->getTotal(), + 'count' => (int) $cart->getTotalQuantity(), + ]; + } +} diff --git a/src/Contracts/CartConditionable.php b/src/Contracts/CartConditionable.php new file mode 100644 index 0000000..f477588 --- /dev/null +++ b/src/Contracts/CartConditionable.php @@ -0,0 +1,12 @@ +code; + } + + public function toCondition(): CartCondition + { + if (! $this->isValid()) { + throw new InvalidCouponException("Coupon [{$this->code}] is not valid."); + } + + $data = [ + 'name' => $this->code, + 'type' => 'coupon', + 'value' => $this->value, + 'target' => $this->target, + ]; + + if ($this->minimum !== null) { + $data['minimum'] = $this->minimum; + } + + if ($this->maximum !== null) { + $data['maximum'] = $this->maximum; + } + + return new CartCondition($data); + } +} diff --git a/src/Drivers/Contracts/CartDriver.php b/src/Drivers/Contracts/CartDriver.php new file mode 100644 index 0000000..730c6fe --- /dev/null +++ b/src/Drivers/Contracts/CartDriver.php @@ -0,0 +1,28 @@ +model = (new $modelClass)->firstOrNew([$idColumn => $sessionKey]); + $this->model[$itemsColumn] ??= []; + $this->model[$conditionsColumn] ??= []; + } + + public function getSessionKey(): string + { + return $this->sessionKey; + } + + public function setSessionKey(string $sessionKey): void + { + $this->sessionKey = $sessionKey; + $this->model[$this->idColumn] = $sessionKey; + $this->model->save(); + } + + public function getDriverName(): string + { + return 'database'; + } + + public function getItems(): array + { + return $this->model[$this->itemsColumn] ?? []; + } + + public function putItems(array $items): void + { + $this->model[$this->itemsColumn] = $items; + $this->model->save(); + } + + public function getConditions(): array + { + return $this->model[$this->conditionsColumn] ?? []; + } + + public function putConditions(array $conditions): void + { + $this->model[$this->conditionsColumn] = $conditions; + $this->model->save(); + } + + public function clearItems(): void + { + $this->model[$this->itemsColumn] = []; + $this->model->save(); + } + + public function flush(): void + { + $this->model->delete(); + $this->model[$this->itemsColumn] = []; + $this->model[$this->conditionsColumn] = []; + } + + public function getSessionModel(): mixed + { + return $this->model; + } +} diff --git a/src/Drivers/MultiDriver.php b/src/Drivers/MultiDriver.php new file mode 100644 index 0000000..4278006 --- /dev/null +++ b/src/Drivers/MultiDriver.php @@ -0,0 +1,84 @@ +drivers[0]; + } + + public function getSessionKey(): string + { + return $this->primary()->getSessionKey(); + } + + public function setSessionKey(string $sessionKey): void + { + foreach ($this->drivers as $driver) { + $driver->setSessionKey($sessionKey); + } + } + + public function getDriverName(): string + { + return 'multi'; + } + + public function getItems(): array + { + return $this->primary()->getItems(); + } + + public function putItems(array $items): void + { + foreach ($this->drivers as $driver) { + $driver->putItems($items); + } + } + + public function getConditions(): array + { + return $this->primary()->getConditions(); + } + + public function putConditions(array $conditions): void + { + foreach ($this->drivers as $driver) { + $driver->putConditions($conditions); + } + } + + public function clearItems(): void + { + foreach ($this->drivers as $driver) { + $driver->clearItems(); + } + } + + public function flush(): void + { + foreach ($this->drivers as $driver) { + $driver->flush(); + } + } + + public function getSessionModel(): mixed + { + return $this->primary()->getSessionModel(); + } +} diff --git a/src/Drivers/NullDriver.php b/src/Drivers/NullDriver.php new file mode 100644 index 0000000..91187d2 --- /dev/null +++ b/src/Drivers/NullDriver.php @@ -0,0 +1,67 @@ +sessionKey; + } + + public function setSessionKey(string $sessionKey): void + { + $this->sessionKey = $sessionKey; + } + + public function getDriverName(): string + { + return 'null'; + } + + public function getItems(): array + { + return $this->items; + } + + public function putItems(array $items): void + { + $this->items = $items; + } + + public function getConditions(): array + { + return $this->conditions; + } + + public function putConditions(array $conditions): void + { + $this->conditions = $conditions; + } + + public function clearItems(): void + { + $this->items = []; + } + + public function flush(): void + { + $this->items = []; + $this->conditions = []; + } + + public function getSessionModel(): mixed + { + return null; + } +} diff --git a/src/Drivers/RedisDriver.php b/src/Drivers/RedisDriver.php new file mode 100644 index 0000000..7f8b9df --- /dev/null +++ b/src/Drivers/RedisDriver.php @@ -0,0 +1,102 @@ +sessionKey}:items"; + } + + private function conditionsKey(): string + { + return "cart:{$this->sessionKey}:conditions"; + } + + public function getSessionKey(): string + { + return $this->sessionKey; + } + + public function setSessionKey(string $sessionKey): void + { + $items = $this->getItems(); + $conditions = $this->getConditions(); + + $this->flush(); + + $this->sessionKey = $sessionKey; + + if (! empty($items)) { + $this->putItems($items); + } + + if (! empty($conditions)) { + $this->putConditions($conditions); + } + } + + public function getDriverName(): string + { + return 'redis'; + } + + public function getItems(): array + { + $data = $this->redis->connection($this->connection)->get($this->itemsKey()); + + return $data ? (json_decode($data, true) ?? []) : []; + } + + public function putItems(array $items): void + { + $this->redis->connection($this->connection)->setex( + $this->itemsKey(), + $this->ttl, + json_encode($items), + ); + } + + public function getConditions(): array + { + $data = $this->redis->connection($this->connection)->get($this->conditionsKey()); + + return $data ? (json_decode($data, true) ?? []) : []; + } + + public function putConditions(array $conditions): void + { + $this->redis->connection($this->connection)->setex( + $this->conditionsKey(), + $this->ttl, + json_encode($conditions), + ); + } + + public function clearItems(): void + { + $this->redis->connection($this->connection)->del($this->itemsKey()); + } + + public function flush(): void + { + $this->redis->connection($this->connection)->del($this->itemsKey(), $this->conditionsKey()); + } + + public function getSessionModel(): mixed + { + return null; + } +} diff --git a/src/Drivers/SessionDriver.php b/src/Drivers/SessionDriver.php new file mode 100644 index 0000000..dc4c1f8 --- /dev/null +++ b/src/Drivers/SessionDriver.php @@ -0,0 +1,91 @@ +session ??= app('session'); + $this->sessionKey ??= $this->session->getId(); + $this->itemsKey = $this->sessionKey . '_cart_items'; + $this->conditionsKey = $this->sessionKey . '_cart_conditions'; + } + + public function getSessionKey(): string + { + return $this->sessionKey; + } + + public function setSessionKey(string $sessionKey): void + { + $items = $this->session->get($this->itemsKey); + $conditions = $this->session->get($this->conditionsKey); + + $this->session->forget($this->itemsKey); + $this->session->forget($this->conditionsKey); + + $this->sessionKey = $sessionKey; + $this->itemsKey = $sessionKey . '_cart_items'; + $this->conditionsKey = $sessionKey . '_cart_conditions'; + + if ($items !== null) { + $this->session->put($this->itemsKey, $items); + } + + if ($conditions !== null) { + $this->session->put($this->conditionsKey, $conditions); + } + } + + public function getDriverName(): string + { + return 'session'; + } + + public function getItems(): array + { + return $this->session->get($this->itemsKey) ?? []; + } + + public function putItems(array $items): void + { + $this->session->put($this->itemsKey, $items); + } + + public function getConditions(): array + { + return $this->session->get($this->conditionsKey) ?? []; + } + + public function putConditions(array $conditions): void + { + $this->session->put($this->conditionsKey, $conditions); + } + + public function clearItems(): void + { + $this->session->forget($this->itemsKey); + } + + public function flush(): void + { + $this->session->forget($this->itemsKey); + $this->session->forget($this->conditionsKey); + } + + public function getSessionModel(): mixed + { + return null; + } +} diff --git a/src/Exceptions/InvalidCouponException.php b/src/Exceptions/InvalidCouponException.php new file mode 100644 index 0000000..b0d2dbe --- /dev/null +++ b/src/Exceptions/InvalidCouponException.php @@ -0,0 +1,7 @@ + $id, string|null $name = null, float|null $price = null, int|float|null $quantity = null, array $attributes = [], CartCondition|array $conditions = [], string|null $associatedModel = null) + * @method static Cart add(string|int|array $id, string|null $name = null, float|null $price = null, int|float|null $quantity = null, array $attributes = [], CartCondition|array $conditions = [], string|null $associatedModel = null) + * @method static bool removeInvalidItems() * @method static bool update(int|string $id, array $data) - * @method static void remove(int|string $id) - * @method static void clear() - * @method static void clearItems() + * @method static void setConfig(array $config) + * @method static Cart addItemCondition(int|string $productId, CartCondition $itemCondition) + * @method static bool remove(int|string $id) + * @method static bool clear() + * @method static bool clearItems() * @method static Cart condition(array|CartCondition $condition) + * @method static Cart coupon(Coupon $coupon) + * @method static Cart tax(TaxRule $taxRule) + * @method static Cart shipping(ShippingRate $shippingRate) * @method static CartConditionCollection|array getConditions(bool $array = false, bool $active = false) * @method static CartCondition|null getCondition(string $conditionName) * @method static CartConditionCollection getConditionsByType(string $type) * @method static void removeConditionsByType(string $type) * @method static void removeCartCondition(string $conditionName) - * @method static void clearItemConditions(int|string $itemId) + * @method static bool removeItemCondition(int|string $itemId, string $conditionName) + * @method static bool clearItemConditions(int|string $itemId) + * @method static void clearCartConditions() * @method static void clearAllConditions() + * @method static int|float|string getSubTotalWithoutConditions(bool $formatted = true) + * @method static int|float|string getSubTotal(bool $formatted = true) + * @method static int|float getTotal() + * @method static int|float getCalculatedValueForCondition(string $conditionName) + * @method static int|float getTotalQuantity() * @method static CartCollection getContent() * @method static bool isEmpty() - * @method static int getTotalQuantity() - * @method static int|float|string getSubTotal(bool $formatted = true) - * @method static int|float|string getSubTotalWithoutConditions(bool $formatted = true) - * @method static int|float getTotal() + * @method static Cart associate(mixed $model) */ class CartFacade extends Facade { diff --git a/src/ItemCollection.php b/src/ItemCollection.php index 9d1d6d5..1f5d563 100644 --- a/src/ItemCollection.php +++ b/src/ItemCollection.php @@ -5,6 +5,14 @@ use Illuminate\Support\Collection; use Wearepixel\Cart\Helpers\Helpers; +/** + * @property mixed $id + * @property string $name + * @property float|int $price + * @property int|float $quantity + * @property array $attributes + * @property mixed $conditions + */ class ItemCollection extends Collection { /** @@ -46,9 +54,9 @@ public function __get($name) /** * return the associated model of an item * - * @return bool + * @return mixed */ - protected function getAssociatedModel() + protected function getAssociatedModel(): mixed { if (! $this->has('associatedModel')) { return null; @@ -97,7 +105,7 @@ public function getConditions() // check if we're already an array of CartConditions foreach ($conditions as $key => $condition) { - if ($condition instanceof CartCondition) { + if (is_object($condition) && $condition instanceof CartCondition) { return $conditions; } } @@ -105,7 +113,7 @@ public function getConditions() $hasSubArray = false; foreach ($conditions as $key => $condition) { - if (is_array($condition) && ! $condition instanceof CartCondition) { + if (is_array($condition)) { $hasSubArray = true; $conditionsArray[] = new CartCondition($condition); } elseif ($condition instanceof CartCondition) { @@ -162,6 +170,31 @@ public function getPriceWithConditions($formatted = true) */ public function getPriceSumWithConditions($formatted = true) { - return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config); + $conditions = $this->hasConditions() ? $this->getConditions() : []; + $conditions = is_array($conditions) ? $conditions : [$conditions]; + + $hasLimit = collect($conditions)->some(fn ($c) => $c->getAppliesToQuantity() !== null); + + if (! $hasLimit) { + return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config); + } + + $total = 0.0; + + for ($i = 1; $i <= $this->quantity; $i++) { + $price = $this->price; + + foreach ($conditions as $condition) { + $limit = $condition->getAppliesToQuantity(); + + if ($limit === null || $i <= $limit) { + $price = $condition->applyCondition($price); + } + } + + $total += $price; + } + + return Helpers::formatValue($total, $formatted, $this->config); } } diff --git a/src/Shipping/ShippingRate.php b/src/Shipping/ShippingRate.php new file mode 100644 index 0000000..3baa6f1 --- /dev/null +++ b/src/Shipping/ShippingRate.php @@ -0,0 +1,48 @@ +isApplicable()) { + throw new InvalidShippingRateException("Shipping rate [{$this->name}] is not applicable."); + } + + $data = [ + 'name' => $this->name, + 'type' => 'shipping', + 'value' => $this->value, + 'target' => $this->target, + ]; + + if ($this->minimum !== null) { + $data['minimum'] = $this->minimum; + } + + if ($this->maximum !== null) { + $data['maximum'] = $this->maximum; + } + + return new CartCondition($data); + } +} diff --git a/src/Tax/TaxRule.php b/src/Tax/TaxRule.php new file mode 100644 index 0000000..d1422f2 --- /dev/null +++ b/src/Tax/TaxRule.php @@ -0,0 +1,48 @@ +isApplicable()) { + throw new InvalidTaxRuleException("Tax rule [{$this->name}] is not applicable."); + } + + $data = [ + 'name' => $this->name, + 'type' => 'tax', + 'value' => $this->value, + 'target' => $this->target, + ]; + + if ($this->minimum !== null) { + $data['minimum'] = $this->minimum; + } + + if ($this->maximum !== null) { + $data['maximum'] = $this->maximum; + } + + return new CartCondition($data); + } +} diff --git a/src/Testing/CartFactory.php b/src/Testing/CartFactory.php new file mode 100644 index 0000000..bc8cc78 --- /dev/null +++ b/src/Testing/CartFactory.php @@ -0,0 +1,38 @@ +cart->getContent()->count() + 1; + for ($i = $start; $i < $start + $count; $i++) { + $this->cart->add($i, "Test Item {$i}", 10.00, 1); + } + + return $this; + } + + public function withCondition(CartCondition $condition): static + { + $this->cart->condition($condition); + + return $this; + } + + public function withCoupon(Coupon $coupon): static + { + $this->cart->coupon($coupon); + + return $this; + } +} diff --git a/src/Validators/Validator.php b/src/Validators/Validator.php index 0a3db44..5fa5060 100644 --- a/src/Validators/Validator.php +++ b/src/Validators/Validator.php @@ -7,6 +7,9 @@ use Illuminate\Translation\FileLoader; use Illuminate\Translation\Translator; +/** + * @method static \Illuminate\Validation\Validator make(array $data, array $rules, array $messages = [], array $attributes = []) + */ abstract class Validator { protected static $factory; diff --git a/tests/Unit/CartConditionsTest.php b/tests/Unit/CartConditionsTest.php index 333dba7..3ece093 100644 --- a/tests/Unit/CartConditionsTest.php +++ b/tests/Unit/CartConditionsTest.php @@ -3,6 +3,7 @@ use Wearepixel\Cart\Cart; use Wearepixel\Cart\CartCondition; use Wearepixel\Cart\CartConditionCollection; +use Wearepixel\Cart\Drivers\SessionDriver; use Wearepixel\Cart\Tests\Helpers\SessionMock; beforeEach(function () { @@ -10,10 +11,9 @@ $events->shouldReceive('dispatch'); $this->cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigConditionsMock.php') ); }); @@ -950,8 +950,8 @@ expect($this->cart->getCalculatedValueForCondition('Coupon Discount'))->toEqual(200.0, 'Coupon Discount should be 200.0'); expect($this->cart->getCalculatedValueForCondition('Gift Card'))->toEqual(0, 'Gift Card should be 0'); - expect($couponDiscount->getCalculatedValue())->toEqual(200.0, 'Coupon Discount value should be 200.0'); - expect($giftCard->getCalculatedValue())->toEqual(0, 'Gift Card value should be 0'); + expect($this->cart->getCondition('Coupon Discount')->getCalculatedValue(200.0))->toEqual(200.0, 'Coupon Discount value should be 200.0'); + expect($this->cart->getCondition('Gift Card')->getCalculatedValue(0))->toEqual(0, 'Gift Card value should be 0'); }); test('calculate the subtotal correctly when one condition makes it $0, and the other adds $10', function () { diff --git a/tests/Unit/CartFirstClassObjectsTest.php b/tests/Unit/CartFirstClassObjectsTest.php new file mode 100644 index 0000000..650959b --- /dev/null +++ b/tests/Unit/CartFirstClassObjectsTest.php @@ -0,0 +1,100 @@ +shouldReceive('dispatch'); + + return new Cart( + new SessionDriver(new SessionMock, 'TESTKEY'), + $events, + 'cart', + require __DIR__ . '/../Helpers/ConfigMock.php', + ); +} + +class TestCoupon extends Coupon +{ + protected string $code = 'SAVE10'; + protected string $value = '-10%'; + public function isValid(): bool { return true; } +} + +class InvalidTestCoupon extends Coupon +{ + protected string $code = 'EXPIRED'; + protected string $value = '-10%'; + public function isValid(): bool { return false; } +} + +class TestTax extends TaxRule +{ + protected string $name = 'GST'; + protected string $value = '10%'; + public function isApplicable(): bool { return true; } +} + +class TestShipping extends ShippingRate +{ + protected string $name = 'Flat Rate'; + protected string $value = '+10'; + public function isApplicable(): bool { return true; } +} + +beforeEach(function () { + $this->cart = makeCart(); + $this->cart->add(1, 'Widget', 100.00, 1); +}); + +afterEach(fn() => Mockery::close()); + +describe('Cart first-class objects', function () { + test('Cart::coupon() applies condition from a Coupon instance', function () { + $this->cart->coupon(new TestCoupon); + + expect($this->cart->getCondition('SAVE10'))->toBeInstanceOf(CartCondition::class); + expect($this->cart->getSubTotal())->toEqual(90.00); + }); + + test('Cart::coupon() throws when coupon is invalid', function () { + expect(fn() => $this->cart->coupon(new InvalidTestCoupon)) + ->toThrow(InvalidCouponException::class); + }); + + test('Cart::tax() applies condition from a TaxRule instance', function () { + $this->cart->tax(new TestTax); + + expect($this->cart->getCondition('GST'))->toBeInstanceOf(CartCondition::class); + expect($this->cart->getSubTotal())->toEqual(110.00); + }); + + test('Cart::shipping() applies condition from a ShippingRate instance', function () { + $this->cart->shipping(new TestShipping); + + expect($this->cart->getCondition('Flat Rate'))->toBeInstanceOf(CartCondition::class); + expect($this->cart->getTotal())->toEqual(110.00); + }); + + test('raw Cart::condition() still works alongside first-class objects', function () { + $this->cart->coupon(new TestCoupon); + $this->cart->condition(new CartCondition([ + 'name' => 'Manual Discount', + 'type' => 'discount', + 'value' => '-5', + 'target' => 'subtotal', + ])); + + expect($this->cart->getConditions()->count())->toBe(2); + }); +}); diff --git a/tests/Unit/CartTest.php b/tests/Unit/CartTest.php index 509c567..dd47fd9 100644 --- a/tests/Unit/CartTest.php +++ b/tests/Unit/CartTest.php @@ -1,6 +1,7 @@ shouldReceive('dispatch'); $this->cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'shopping', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); }); diff --git a/tests/Unit/CartTestEventsTest.php b/tests/Unit/CartTestEventsTest.php index 6bc0ead..88b45e4 100644 --- a/tests/Unit/CartTestEventsTest.php +++ b/tests/Unit/CartTestEventsTest.php @@ -1,6 +1,7 @@ shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true); $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); @@ -31,10 +31,9 @@ $events->shouldReceive('dispatch')->once()->with('LaravelCart.Added', Mockery::type('array'), true); $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); @@ -50,10 +49,9 @@ $events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Added', Mockery::type('array'), true); $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); @@ -94,10 +92,9 @@ ]; $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); @@ -139,10 +136,9 @@ ]; $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); @@ -186,10 +182,9 @@ ]; $cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); diff --git a/tests/Unit/CartTestMultipleInstancesTest.php b/tests/Unit/CartTestMultipleInstancesTest.php index eb1017c..b9fcdf9 100644 --- a/tests/Unit/CartTestMultipleInstancesTest.php +++ b/tests/Unit/CartTestMultipleInstancesTest.php @@ -1,6 +1,7 @@ shouldReceive('dispatch'); $this->cart1 = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'uniquesessionkey123'), $events, 'shopping', - 'uniquesessionkey123', require (__DIR__ . '/../Helpers/ConfigMock.php') ); $this->cart2 = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'uniquesessionkey456'), $events, 'wishlist', - 'uniquesessionkey456', require (__DIR__ . '/../Helpers/ConfigMock.php') ); }); diff --git a/tests/Unit/Commands/DebugCommandTest.php b/tests/Unit/Commands/DebugCommandTest.php new file mode 100644 index 0000000..b43f7df --- /dev/null +++ b/tests/Unit/Commands/DebugCommandTest.php @@ -0,0 +1,71 @@ +shouldReceive('dispatch'); + + return new Cart( + new NullDriver('debug-session'), + $events, + 'cart', + require __DIR__ . '/../../Helpers/ConfigMock.php', + ); +} + +afterEach(fn() => Mockery::close()); + +describe('DebugCommand::debugData()', function () { + test('returns driver name', function () { + $cart = makeDebugCart(); + $data = DebugCommand::debugData($cart); + expect($data['driver'])->toBe('null'); + }); + + test('returns item count', function () { + $cart = makeDebugCart(); + $cart->add(1, 'Widget', 10.00, 1); + $data = DebugCommand::debugData($cart); + expect($data['item_count'])->toBe(1); + }); + + test('returns total quantity', function () { + $cart = makeDebugCart(); + $cart->add(1, 'Widget', 10.00, 3); + $data = DebugCommand::debugData($cart); + expect($data['total_quantity'])->toBe(3); + }); + + test('returns subtotal', function () { + $cart = makeDebugCart(); + $cart->add(1, 'Widget', 25.00, 2); + $data = DebugCommand::debugData($cart); + expect($data['subtotal'])->toBe(50.00); + }); + + test('returns total', function () { + $cart = makeDebugCart(); + $cart->add(1, 'Widget', 25.00, 2); + $data = DebugCommand::debugData($cart); + expect($data['total'])->toBe(50.00); + }); + + test('returns empty items array when cart is empty', function () { + $cart = makeDebugCart(); + $data = DebugCommand::debugData($cart); + expect($data['items'])->toBeArray()->toBeEmpty(); + }); + + test('returns condition names', function () { + $cart = makeDebugCart(); + $cart->condition(new \Wearepixel\Cart\CartCondition([ + 'name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal', + ])); + $data = DebugCommand::debugData($cart); + expect($data['conditions'])->toContain('GST'); + }); +}); diff --git a/tests/Unit/Commands/InstallCommandTest.php b/tests/Unit/Commands/InstallCommandTest.php new file mode 100644 index 0000000..c9887c3 --- /dev/null +++ b/tests/Unit/Commands/InstallCommandTest.php @@ -0,0 +1,33 @@ +toBeTrue(); + expect(is_dir("{$base}/Tax"))->toBeTrue(); + expect(is_dir("{$base}/Shipping"))->toBeTrue(); + expect(is_dir("{$base}/Drivers"))->toBeTrue(); + + foreach (["{$base}/Coupons", "{$base}/Tax", "{$base}/Shipping", "{$base}/Drivers", $base] as $dir) { + @rmdir($dir); + } + }); + + test('scaffoldDirectories() is idempotent - does not throw if dirs already exist', function () { + $base = sys_get_temp_dir() . '/cart-install-idem-' . uniqid(); + + InstallCommand::scaffoldDirectories($base); + InstallCommand::scaffoldDirectories($base); + + expect(is_dir("{$base}/Coupons"))->toBeTrue(); + + foreach (["{$base}/Coupons", "{$base}/Tax", "{$base}/Shipping", "{$base}/Drivers", $base] as $dir) { + @rmdir($dir); + } + }); +}); diff --git a/tests/Unit/Commands/MakeCouponCommandTest.php b/tests/Unit/Commands/MakeCouponCommandTest.php new file mode 100644 index 0000000..9de2516 --- /dev/null +++ b/tests/Unit/Commands/MakeCouponCommandTest.php @@ -0,0 +1,30 @@ +toContain('class TenPercentCoupon extends Coupon'); + }); + + test('stub uses the App\\Cart\\Coupons namespace', function () { + $stub = MakeCouponCommand::stub('TenPercentCoupon'); + expect($stub)->toContain('namespace App\\Cart\\Coupons'); + }); + + test('stub imports the Coupon base class', function () { + $stub = MakeCouponCommand::stub('TenPercentCoupon'); + expect($stub)->toContain('use Wearepixel\\Cart\\Coupons\\Coupon'); + }); + + test('stub declares isValid() method', function () { + $stub = MakeCouponCommand::stub('TenPercentCoupon'); + expect($stub)->toContain('public function isValid(): bool'); + }); + + test('stub uses strict_types', function () { + $stub = MakeCouponCommand::stub('TenPercentCoupon'); + expect($stub)->toContain('declare(strict_types=1)'); + }); +}); diff --git a/tests/Unit/Commands/MakeDriverCommandTest.php b/tests/Unit/Commands/MakeDriverCommandTest.php new file mode 100644 index 0000000..2b4d623 --- /dev/null +++ b/tests/Unit/Commands/MakeDriverCommandTest.php @@ -0,0 +1,34 @@ +toContain('class RedisDriver implements CartDriver'); + }); + + test('stub uses the App\\Cart\\Drivers namespace', function () { + $stub = MakeDriverCommand::stub('RedisDriver'); + expect($stub)->toContain('namespace App\\Cart\\Drivers'); + }); + + test('stub imports the CartDriver interface', function () { + $stub = MakeDriverCommand::stub('RedisDriver'); + expect($stub)->toContain('use Wearepixel\\Cart\\Drivers\\Contracts\\CartDriver'); + }); + + test('stub declares all CartDriver interface methods', function () { + $stub = MakeDriverCommand::stub('RedisDriver'); + expect($stub)->toContain('public function getSessionKey(): string'); + expect($stub)->toContain('public function setSessionKey(string $sessionKey): void'); + expect($stub)->toContain('public function getDriverName(): string'); + expect($stub)->toContain('public function getItems(): array'); + expect($stub)->toContain('public function putItems(array $items): void'); + expect($stub)->toContain('public function getConditions(): array'); + expect($stub)->toContain('public function putConditions(array $conditions): void'); + expect($stub)->toContain('public function clearItems(): void'); + expect($stub)->toContain('public function flush(): void'); + expect($stub)->toContain('public function getSessionModel(): mixed'); + }); +}); diff --git a/tests/Unit/Commands/MakeShippingCommandTest.php b/tests/Unit/Commands/MakeShippingCommandTest.php new file mode 100644 index 0000000..97e9d71 --- /dev/null +++ b/tests/Unit/Commands/MakeShippingCommandTest.php @@ -0,0 +1,30 @@ +toContain('class FlatRateShipping extends ShippingRate'); + }); + + test('stub uses the App\\Cart\\Shipping namespace', function () { + $stub = MakeShippingCommand::stub('FlatRateShipping'); + expect($stub)->toContain('namespace App\\Cart\\Shipping'); + }); + + test('stub imports the ShippingRate base class', function () { + $stub = MakeShippingCommand::stub('FlatRateShipping'); + expect($stub)->toContain('use Wearepixel\\Cart\\Shipping\\ShippingRate'); + }); + + test('stub declares isApplicable() method', function () { + $stub = MakeShippingCommand::stub('FlatRateShipping'); + expect($stub)->toContain('public function isApplicable(): bool'); + }); + + test('stub defaults target to total', function () { + $stub = MakeShippingCommand::stub('FlatRateShipping'); + expect($stub)->toContain("'total'"); + }); +}); diff --git a/tests/Unit/Commands/MakeTaxCommandTest.php b/tests/Unit/Commands/MakeTaxCommandTest.php new file mode 100644 index 0000000..2404fd8 --- /dev/null +++ b/tests/Unit/Commands/MakeTaxCommandTest.php @@ -0,0 +1,30 @@ +toContain('class GstTaxRule extends TaxRule'); + }); + + test('stub uses the App\\Cart\\Tax namespace', function () { + $stub = MakeTaxCommand::stub('GstTaxRule'); + expect($stub)->toContain('namespace App\\Cart\\Tax'); + }); + + test('stub imports the TaxRule base class', function () { + $stub = MakeTaxCommand::stub('GstTaxRule'); + expect($stub)->toContain('use Wearepixel\\Cart\\Tax\\TaxRule'); + }); + + test('stub declares isApplicable() method', function () { + $stub = MakeTaxCommand::stub('GstTaxRule'); + expect($stub)->toContain('public function isApplicable(): bool'); + }); + + test('stub uses strict_types', function () { + $stub = MakeTaxCommand::stub('GstTaxRule'); + expect($stub)->toContain('declare(strict_types=1)'); + }); +}); diff --git a/tests/Unit/Concerns/HasCartTest.php b/tests/Unit/Concerns/HasCartTest.php new file mode 100644 index 0000000..e2bd8fb --- /dev/null +++ b/tests/Unit/Concerns/HasCartTest.php @@ -0,0 +1,87 @@ +cart; + } +} + +function makeComponent(): FakeComponent +{ + $events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher'); + $events->shouldReceive('dispatch'); + + $cart = new Cart( + new SessionDriver(new SessionMock, 'TESTKEY'), + $events, + 'cart', + require __DIR__ . '/../../Helpers/ConfigMock.php', + ); + + return new FakeComponent($cart); +} + +beforeEach(function () { + $this->component = makeComponent(); +}); + +afterEach(fn() => Mockery::close()); + +describe('HasCart trait', function () { + test('refreshCart() populates cartItems', function () { + $this->component->cartAdd(1, 'Widget', 50.00, 2); + + expect($this->component->cartItems)->not->toBeEmpty(); + }); + + test('cartAdd() adds an item and refreshes state', function () { + $this->component->cartAdd(1, 'Widget', 50.00, 1); + + expect($this->component->cartCount)->toBe(1); + expect($this->component->cartSubTotal)->toBe(50.00); + }); + + test('cartRemove() removes an item and refreshes state', function () { + $this->component->cartAdd(1, 'Widget', 50.00, 1); + $this->component->cartRemove(1); + + expect($this->component->cartCount)->toBe(0); + }); + + test('cartUpdate() updates an item and refreshes state', function () { + $this->component->cartAdd(1, 'Widget', 50.00, 1); + $this->component->cartUpdate(1, ['quantity' => ['relative' => false, 'value' => 3]]); + + expect($this->component->cartCount)->toBe(3); + }); + + test('cartClear() empties the cart and refreshes state', function () { + $this->component->cartAdd(1, 'Widget', 50.00, 1); + $this->component->cartClear(); + + expect($this->component->cartCount)->toBe(0); + expect($this->component->cartItems)->toBeEmpty(); + }); + + test('cartTotal reflects the cart total', function () { + $this->component->cartAdd(1, 'Widget', 100.00, 1); + + expect($this->component->cartTotal)->toBe(100.00); + }); +}); diff --git a/tests/Unit/Concerns/ShareCartWithInertiaTest.php b/tests/Unit/Concerns/ShareCartWithInertiaTest.php new file mode 100644 index 0000000..895de26 --- /dev/null +++ b/tests/Unit/Concerns/ShareCartWithInertiaTest.php @@ -0,0 +1,54 @@ +shouldReceive('dispatch'); + + return new Cart( + new SessionDriver(new SessionMock, 'TESTKEY'), + $events, + 'cart', + require __DIR__ . '/../../Helpers/ConfigMock.php', + ); +} + +beforeEach(function () { + $this->cart = makeCartForInertia(); +}); + +afterEach(fn() => Mockery::close()); + +describe('ShareCartWithInertia', function () { + test('data() returns items, total, subtotal, and count', function () { + $this->cart->add(1, 'Widget', 50.00, 2); + + $data = ShareCartWithInertia::data($this->cart); + + expect($data)->toHaveKeys(['items', 'total', 'subtotal', 'count']); + expect($data['count'])->toBe(2); + expect($data['subtotal'])->toBe(100.00); + expect($data['total'])->toBe(100.00); + }); + + test('data() returns empty state when cart is empty', function () { + $data = ShareCartWithInertia::data($this->cart); + + expect($data['items'])->toBeEmpty(); + expect($data['count'])->toBe(0); + }); + + test('data() items are plain arrays suitable for JSON serialization', function () { + $this->cart->add(1, 'Widget', 50.00, 1); + + $data = ShareCartWithInertia::data($this->cart); + + expect($data['items'])->toBeArray(); + expect(json_encode($data))->toBeString(); + }); +}); diff --git a/tests/Unit/Coupons/CouponTest.php b/tests/Unit/Coupons/CouponTest.php new file mode 100644 index 0000000..120665e --- /dev/null +++ b/tests/Unit/Coupons/CouponTest.php @@ -0,0 +1,72 @@ +toCondition(); + + expect($condition)->toBeInstanceOf(CartCondition::class); + expect($condition->getName())->toBe('SAVE10'); + expect($condition->getValue())->toBe('-10%'); + expect($condition->getType())->toBe('coupon'); + expect($condition->getTarget())->toBe('subtotal'); + }); + + test('respects custom target', function () { + $coupon = new class extends Coupon { + protected string $code = 'TOTAL10'; + protected string $value = '-10%'; + protected string $target = 'total'; + public function isValid(): bool { return true; } + }; + + expect($coupon->toCondition()->getTarget())->toBe('total'); + }); + + test('includes minimum in condition when set', function () { + $coupon = new class extends Coupon { + protected string $code = 'BIG10'; + protected string $value = '-10%'; + protected ?float $minimum = 100.0; + public function isValid(): bool { return true; } + }; + + expect($coupon->toCondition()->getMinimum())->toBe(100.0); + }); + + test('isValid() returning false causes toCondition() to throw', function () { + expect(fn() => (new NeverValidCoupon)->toCondition()) + ->toThrow(InvalidCouponException::class); + }); + + test('getCode returns the coupon code', function () { + expect((new AlwaysValidCoupon)->getCode())->toBe('SAVE10'); + }); +}); diff --git a/tests/Unit/DatabaseCartTest.php b/tests/Unit/DatabaseCartTest.php index 924ff07..1a232f0 100644 --- a/tests/Unit/DatabaseCartTest.php +++ b/tests/Unit/DatabaseCartTest.php @@ -2,19 +2,23 @@ use Wearepixel\Cart\Cart; use Wearepixel\Cart\CartCondition; +use Wearepixel\Cart\Drivers\DatabaseDriver; use Wearepixel\Cart\Tests\Helpers\MockCartModel; beforeEach(function () { $events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher'); $events->shouldReceive('dispatch'); - $storage = new MockCartModel; - $this->cart = new Cart( - $storage, + new DatabaseDriver( + MockCartModel::class, + 'session_id', + 'items', + 'conditions', + 'SAMPLESESSIONKEY', + ), $events, 'cart', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigDatabaseMock.php') ); }); @@ -108,6 +112,38 @@ expect($this->cart->getTotal())->toEqual(18.45, 'Cart should have total of 18.45'); }); + test('cart-level conditions survive database round-trip', function () { + $this->cart->condition(new CartCondition([ + 'name' => 'GST', + 'type' => 'tax', + 'value' => '10%', + 'target' => 'subtotal', + ])); + + // Create a fresh cart instance pointing at the same DB row + $events2 = Mockery::mock('Illuminate\Contracts\Events\Dispatcher'); + $events2->shouldReceive('dispatch'); + + $cart2 = new \Wearepixel\Cart\Cart( + new \Wearepixel\Cart\Drivers\DatabaseDriver( + \Wearepixel\Cart\Tests\Helpers\MockCartModel::class, + 'session_id', + 'items', + 'conditions', + 'SAMPLESESSIONKEY', + ), + $events2, + 'cart', + require (__DIR__ . '/../Helpers/ConfigDatabaseMock.php') + ); + + $condition = $cart2->getCondition('GST'); + + expect($condition)->not->toBeNull(); + expect($condition->getName())->toBe('GST'); + expect($condition->getValue())->toBe('10%'); + }); + test('can clear a simple cart', function () { $this->cart->add(1, 'Sample Item', 100.99, 2, []); diff --git a/tests/Unit/Drivers/DatabaseDriverTest.php b/tests/Unit/Drivers/DatabaseDriverTest.php new file mode 100644 index 0000000..b2ffc08 --- /dev/null +++ b/tests/Unit/Drivers/DatabaseDriverTest.php @@ -0,0 +1,79 @@ +driver = new DatabaseDriver( + MockCartModel::class, + 'session_id', + 'items', + 'conditions', + 'test-session', + ); +}); + +describe('DatabaseDriver', function () { + test('returns its session key', function () { + expect($this->driver->getSessionKey())->toBe('test-session'); + }); + + test('returns database as driver name', function () { + expect($this->driver->getDriverName())->toBe('database'); + }); + + test('starts with empty items', function () { + expect($this->driver->getItems())->toBe([]); + }); + + test('stores and retrieves items', function () { + $items = [['id' => 1, 'name' => 'Widget']]; + $this->driver->putItems($items); + + $fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session'); + expect($fresh->getItems())->toBe($items); + }); + + test('starts with empty conditions', function () { + expect($this->driver->getConditions())->toBe([]); + }); + + test('stores and retrieves conditions', function () { + $conditions = [['name' => 'GST', 'type' => 'tax', 'value' => '10%']]; + $this->driver->putConditions($conditions); + + $fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session'); + expect($fresh->getConditions())->toBe($conditions); + }); + + test('flush deletes the database record', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->flush(); + + expect(MockCartModel::count())->toBe(0); + }); + + test('clearItems empties items but keeps conditions', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->clearItems(); + + $fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session'); + expect($fresh->getItems())->toBe([]); + expect($fresh->getConditions())->toBe([['name' => 'GST']]); + }); + + test('getSessionModel returns the Eloquent model', function () { + expect($this->driver->getSessionModel())->toBeInstanceOf(MockCartModel::class); + }); + + test('setSessionKey updates the session key on the model', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->setSessionKey('new-session'); + + expect($this->driver->getSessionKey())->toBe('new-session'); + expect($this->driver->getItems())->toBe([['id' => 1]]); + }); +}); diff --git a/tests/Unit/Drivers/MultiDriverTest.php b/tests/Unit/Drivers/MultiDriverTest.php new file mode 100644 index 0000000..c182c5a --- /dev/null +++ b/tests/Unit/Drivers/MultiDriverTest.php @@ -0,0 +1,84 @@ +primary = new NullDriver('test-session'); + $this->secondary = new NullDriver('test-session'); + $this->driver = new MultiDriver([$this->primary, $this->secondary]); + }); + + test('returns session key from primary driver', function () { + expect($this->driver->getSessionKey())->toBe('test-session'); + }); + + test('returns multi as driver name', function () { + expect($this->driver->getDriverName())->toBe('multi'); + }); + + test('reads items from primary driver', function () { + $this->primary->putItems([['id' => 1]]); + $this->secondary->putItems([['id' => 2]]); + + expect($this->driver->getItems())->toBe([['id' => 1]]); + }); + + test('writes items to all drivers', function () { + $this->driver->putItems([['id' => 1]]); + + expect($this->primary->getItems())->toBe([['id' => 1]]); + expect($this->secondary->getItems())->toBe([['id' => 1]]); + }); + + test('reads conditions from primary driver', function () { + $this->primary->putConditions([['name' => 'GST']]); + $this->secondary->putConditions([['name' => 'Other']]); + + expect($this->driver->getConditions())->toBe([['name' => 'GST']]); + }); + + test('writes conditions to all drivers', function () { + $this->driver->putConditions([['name' => 'GST']]); + + expect($this->primary->getConditions())->toBe([['name' => 'GST']]); + expect($this->secondary->getConditions())->toBe([['name' => 'GST']]); + }); + + test('clearItems clears items on all drivers', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->clearItems(); + + expect($this->primary->getItems())->toBe([]); + expect($this->secondary->getItems())->toBe([]); + }); + + test('flush flushes all drivers', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->flush(); + + expect($this->primary->getItems())->toBe([]); + expect($this->primary->getConditions())->toBe([]); + expect($this->secondary->getItems())->toBe([]); + expect($this->secondary->getConditions())->toBe([]); + }); + + test('setSessionKey updates all drivers', function () { + $this->driver->setSessionKey('new-key'); + + expect($this->primary->getSessionKey())->toBe('new-key'); + expect($this->secondary->getSessionKey())->toBe('new-key'); + }); + + test('throws when constructed with no drivers', function () { + expect(fn() => new MultiDriver([]))->toThrow(InvalidArgumentException::class); + }); + + test('getSessionModel delegates to primary driver', function () { + expect($this->driver->getSessionModel())->toBeNull(); + }); +}); diff --git a/tests/Unit/Drivers/NullDriverTest.php b/tests/Unit/Drivers/NullDriverTest.php new file mode 100644 index 0000000..a79265a --- /dev/null +++ b/tests/Unit/Drivers/NullDriverTest.php @@ -0,0 +1,64 @@ +driver = new NullDriver('test-session'); + }); + + test('returns its session key', function () { + expect($this->driver->getSessionKey())->toBe('test-session'); + }); + + test('returns \'null\' as driver name', function () { + expect($this->driver->getDriverName())->toBe('null'); + }); + + test('starts with empty items', function () { + expect($this->driver->getItems())->toBe([]); + }); + + test('stores and retrieves items', function () { + $this->driver->putItems([['id' => 1, 'name' => 'Widget']]); + expect($this->driver->getItems())->toBe([['id' => 1, 'name' => 'Widget']]); + }); + + test('starts with empty conditions', function () { + expect($this->driver->getConditions())->toBe([]); + }); + + test('stores and retrieves conditions', function () { + $this->driver->putConditions([['name' => 'GST', 'type' => 'tax', 'value' => '10%']]); + expect($this->driver->getConditions())->toBe([['name' => 'GST', 'type' => 'tax', 'value' => '10%']]); + }); + + test('clearItems empties items but keeps conditions', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->clearItems(); + + expect($this->driver->getItems())->toBe([]); + expect($this->driver->getConditions())->toBe([['name' => 'GST']]); + }); + + test('flush empties items and conditions', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->flush(); + + expect($this->driver->getItems())->toBe([]); + expect($this->driver->getConditions())->toBe([]); + }); + + test('setSessionKey updates the key', function () { + $this->driver->setSessionKey('new-key'); + expect($this->driver->getSessionKey())->toBe('new-key'); + }); + + test('getSessionModel returns null', function () { + expect($this->driver->getSessionModel())->toBeNull(); + }); +}); diff --git a/tests/Unit/Drivers/SessionDriverTest.php b/tests/Unit/Drivers/SessionDriverTest.php new file mode 100644 index 0000000..5fd7724 --- /dev/null +++ b/tests/Unit/Drivers/SessionDriverTest.php @@ -0,0 +1,94 @@ +session = new SessionMock; + $this->driver = new SessionDriver($this->session, 'test-session'); + }); + + test('returns its session key', function () { + expect($this->driver->getSessionKey())->toBe('test-session'); + }); + + test('returns session as driver name', function () { + expect($this->driver->getDriverName())->toBe('session'); + }); + + test('starts with empty items', function () { + expect($this->driver->getItems())->toBe([]); + }); + + test('stores and retrieves items', function () { + $items = [['id' => 1, 'name' => 'Widget', 'price' => 10.0]]; + $this->driver->putItems($items); + expect($this->driver->getItems())->toBe($items); + }); + + test('starts with empty conditions', function () { + expect($this->driver->getConditions())->toBe([]); + }); + + test('stores and retrieves conditions', function () { + $conditions = [['name' => 'GST', 'type' => 'tax', 'value' => '10%']]; + $this->driver->putConditions($conditions); + expect($this->driver->getConditions())->toBe($conditions); + }); + + test('clearItems removes items from session', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->clearItems(); + + expect($this->driver->getItems())->toBe([]); + expect($this->driver->getConditions())->toBe([['name' => 'GST']]); + }); + + test('flush removes items and conditions from session', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->flush(); + + expect($this->driver->getItems())->toBe([]); + expect($this->driver->getConditions())->toBe([]); + }); + + test('setSessionKey migrates data to new key', function () { + $this->driver->putItems([['id' => 1]]); + $this->driver->putConditions([['name' => 'GST']]); + $this->driver->setSessionKey('new-key'); + + expect($this->driver->getSessionKey())->toBe('new-key'); + expect($this->driver->getItems())->toBe([['id' => 1]]); + expect($this->driver->getConditions())->toBe([['name' => 'GST']]); + }); + + test('getSessionModel returns null', function () { + expect($this->driver->getSessionModel())->toBeNull(); + }); + + test('accepts explicit session and key, using them directly', function () { + $session = new SessionMock; + $driver = new SessionDriver($session, 'explicit-key'); + + $driver->putItems([['id' => 1]]); + + expect($driver->getSessionKey())->toBe('explicit-key'); + expect($driver->getItems())->toBe([['id' => 1]]); + }); + + test('setSessionKey on empty cart does not write null to session', function () { + $this->driver->setSessionKey('new-key'); + + // After key change, empty cart should still return empty arrays, not null + expect($this->driver->getItems())->toBe([]); + expect($this->driver->getConditions())->toBe([]); + // And the session should not have null values written + expect($this->session->get('new-key_cart_items'))->toBeNull(); + expect($this->session->get('new-key_cart_conditions'))->toBeNull(); + }); +}); diff --git a/tests/Unit/ItemTest.php b/tests/Unit/ItemTest.php index fd18d39..a35febd 100644 --- a/tests/Unit/ItemTest.php +++ b/tests/Unit/ItemTest.php @@ -2,6 +2,7 @@ use Wearepixel\Cart\Cart; use Wearepixel\Cart\CartCondition; +use Wearepixel\Cart\Drivers\SessionDriver; use Wearepixel\Cart\Tests\Helpers\MockProduct; use Wearepixel\Cart\Tests\Helpers\SessionMock; @@ -10,10 +11,9 @@ $events->shouldReceive('dispatch'); $this->cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'shopping', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMock.php') ); }); diff --git a/tests/Unit/ItemTestOtherFormatTest.php b/tests/Unit/ItemTestOtherFormatTest.php index 928e8af..0852d60 100644 --- a/tests/Unit/ItemTestOtherFormatTest.php +++ b/tests/Unit/ItemTestOtherFormatTest.php @@ -1,6 +1,7 @@ shouldReceive('dispatch'); $this->cart = new Cart( - new SessionMock, + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), $events, 'shopping', - 'SAMPLESESSIONKEY', require (__DIR__ . '/../Helpers/ConfigMockOtherFormat.php') ); }); diff --git a/tests/Unit/QuantityLimitedConditionTest.php b/tests/Unit/QuantityLimitedConditionTest.php new file mode 100644 index 0000000..6a0a14a --- /dev/null +++ b/tests/Unit/QuantityLimitedConditionTest.php @@ -0,0 +1,159 @@ +shouldReceive('dispatch'); + + $this->cart = new Cart( + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), + $events, + 'cart', + require (__DIR__ . '/../Helpers/ConfigConditionsMock.php') + ); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('quantity-limited item conditions', function () { + test('condition without applies_to applies to all items', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // Without applies_to: 50*0.95 * 2 = 95 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00); + expect($this->cart->getSubTotal())->toEqual(95.00); + }); + + test('condition with applies_to only discounts that many items', function () { + $discount = new CartCondition([ + 'name' => '5% Off First Item', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 1, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to: 1 → (50*0.95) + 50 = 97.5 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(97.50); + expect($this->cart->getSubTotal())->toEqual(97.50); + }); + + test('applies_to greater than or equal to quantity discounts all items', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 5, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to 5 >= qty 2, so all items discounted: 50*0.95 * 2 = 95 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00); + }); + + test('applies_to of zero means condition does not apply to any item', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 0, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to 0: no items discounted → 50 * 2 = 100 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(100.00); + }); + + test('multiple conditions where only one has applies_to', function () { + $limitedDiscount = new CartCondition([ + 'name' => '10% Off First Item', + 'type' => 'discount', + 'value' => '-10%', + 'applies_to' => 1, + ]); + + $globalDiscount = new CartCondition([ + 'name' => '5% Off All', + 'type' => 'discount', + 'value' => '-5%', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 100.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => [$limitedDiscount, $globalDiscount], + ]); + + // Item 1: 100 * 0.90 * 0.95 = 85.50 + // Item 2: 100 * 0.95 = 95.00 (only global applies) + // Total: 180.50 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(180.50); + }); + + test('getAppliesToQuantity returns null when not set', function () { + $condition = new CartCondition([ + 'name' => 'Discount', + 'type' => 'discount', + 'value' => '-5%', + ]); + + expect($condition->getAppliesToQuantity())->toBeNull(); + }); + + test('getAppliesToQuantity returns the set value', function () { + $condition = new CartCondition([ + 'name' => 'Discount', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 3, + ]); + + expect($condition->getAppliesToQuantity())->toBe(3); + }); +}); diff --git a/tests/Unit/Shipping/ShippingRateTest.php b/tests/Unit/Shipping/ShippingRateTest.php new file mode 100644 index 0000000..918925a --- /dev/null +++ b/tests/Unit/Shipping/ShippingRateTest.php @@ -0,0 +1,68 @@ +toCondition(); + + expect($condition)->toBeInstanceOf(CartCondition::class); + expect($condition->getName())->toBe('Standard Shipping'); + expect($condition->getValue())->toBe('+10'); + expect($condition->getType())->toBe('shipping'); + expect($condition->getTarget())->toBe('total'); + }); + + test('isApplicable() returning false causes toCondition() to throw', function () { + expect(fn() => (new FreeShipping)->toCondition()) + ->toThrow(InvalidShippingRateException::class); + }); + + test('supports subtotal as target', function () { + $rate = new class extends ShippingRate { + protected string $name = 'Express'; + protected string $value = '+25'; + protected string $target = 'subtotal'; + public function isApplicable(): bool { return true; } + }; + + expect($rate->toCondition()->getTarget())->toBe('subtotal'); + }); + + test('supports maximum free shipping threshold', function () { + $rate = new class extends ShippingRate { + protected string $name = 'Conditional Shipping'; + protected string $value = '+15'; + protected ?float $maximum = 100.0; + public function isApplicable(): bool { return true; } + }; + + expect($rate->toCondition()->getMaximum())->toBe(100.0); + }); +}); diff --git a/tests/Unit/Tax/TaxRuleTest.php b/tests/Unit/Tax/TaxRuleTest.php new file mode 100644 index 0000000..368a79f --- /dev/null +++ b/tests/Unit/Tax/TaxRuleTest.php @@ -0,0 +1,68 @@ +toCondition(); + + expect($condition)->toBeInstanceOf(CartCondition::class); + expect($condition->getName())->toBe('GST'); + expect($condition->getValue())->toBe('10%'); + expect($condition->getType())->toBe('tax'); + expect($condition->getTarget())->toBe('subtotal'); + }); + + test('isApplicable() returning false causes toCondition() to throw', function () { + expect(fn() => (new NotApplicableTax)->toCondition()) + ->toThrow(InvalidTaxRuleException::class); + }); + + test('supports total as target', function () { + $tax = new class extends TaxRule { + protected string $name = 'Sales Tax'; + protected string $value = '8%'; + protected string $target = 'total'; + public function isApplicable(): bool { return true; } + }; + + expect($tax->toCondition()->getTarget())->toBe('total'); + }); + + test('supports minimum threshold', function () { + $tax = new class extends TaxRule { + protected string $name = 'Luxury Tax'; + protected string $value = '15%'; + protected ?float $minimum = 500.0; + public function isApplicable(): bool { return true; } + }; + + expect($tax->toCondition()->getMinimum())->toBe(500.0); + }); +}); diff --git a/tests/Unit/Testing/CartFactoryTest.php b/tests/Unit/Testing/CartFactoryTest.php new file mode 100644 index 0000000..441601f --- /dev/null +++ b/tests/Unit/Testing/CartFactoryTest.php @@ -0,0 +1,82 @@ +shouldReceive('dispatch'); + + return new Cart( + new SessionDriver(new SessionMock, 'TESTKEY'), + $events, + 'cart', + require __DIR__ . '/../../Helpers/ConfigMock.php', + ); +} + +beforeEach(function () { + $this->cart = makeCartForFactory(); + $this->factory = new CartFactory($this->cart); +}); + +afterEach(fn() => Mockery::close()); + +describe('CartFactory', function () { + test('withItems adds N generic items to the cart', function () { + $this->factory->withItems(3); + + expect($this->cart->getContent()->count())->toBe(3); + expect($this->cart->getTotalQuantity())->toBe(3); + }); + + test('withCondition adds a condition to the cart', function () { + $condition = new CartCondition(['name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal']); + $this->factory->withCondition($condition); + + expect($this->cart->getCondition('GST'))->not->toBeNull(); + }); + + test('methods are chainable', function () { + $result = $this->factory->withItems(2)->withCondition( + new CartCondition(['name' => 'Discount', 'type' => 'discount', 'value' => '-5%', 'target' => 'subtotal']) + ); + + expect($result)->toBeInstanceOf(CartFactory::class); + expect($this->cart->getContent()->count())->toBe(2); + expect($this->cart->getCondition('Discount'))->not->toBeNull(); + }); + + test('withItems generates unique item IDs', function () { + $this->factory->withItems(3); + $ids = $this->cart->getContent()->keys()->toArray(); + + expect(array_unique($ids))->toHaveCount(3); + }); + + test('consecutive withItems calls produce unique IDs', function () { + $this->factory->withItems(2)->withItems(2); + + $ids = $this->cart->getContent()->keys()->toArray(); + expect(array_unique($ids))->toHaveCount(4); + expect($this->cart->getContent()->count())->toBe(4); + }); + + test('withCoupon adds a coupon condition to the cart', function () { + $coupon = new class extends \Wearepixel\Cart\Coupons\Coupon { + protected string $code = 'SAVE10'; + protected string $value = '-10%'; + protected string $target = 'subtotal'; + + public function isValid(): bool { return true; } + }; + + $this->factory->withCoupon($coupon); + + expect($this->cart->getCondition('SAVE10'))->not->toBeNull(); + }); +}); diff --git a/tests/Unit/Testing/FakeCartTest.php b/tests/Unit/Testing/FakeCartTest.php new file mode 100644 index 0000000..58073ef --- /dev/null +++ b/tests/Unit/Testing/FakeCartTest.php @@ -0,0 +1,112 @@ +shouldReceive('dispatch'); + + return new Cart( + new SessionDriver(new SessionMock, 'TESTKEY'), + $events, + 'cart', + require __DIR__ . '/../../Helpers/ConfigMock.php', + ); +} + +beforeEach(function () { + $this->cart = makeFakeableCart(); +}); + +afterEach(fn() => Mockery::close()); + +describe('Cart::fake()', function () { + test('returns the CartFactory instance', function () { + expect($this->cart->fake())->toBeInstanceOf(\Wearepixel\Cart\Testing\CartFactory::class); + }); + + test('swaps driver to NullDriver', function () { + $this->cart->fake(); + expect($this->cart->getDriver()->getDriverName())->toBe('null'); + }); + + test('Cart operations work after fake()', function () { + $this->cart->fake(); + $this->cart->add(1, 'Widget', 50.00, 2); + + expect($this->cart->isEmpty())->toBeFalse(); + expect($this->cart->getContent()->count())->toBe(1); + }); + + test('assertContains passes when item is in cart', function () { + $this->cart->fake(); + $this->cart->add(1, 'Widget', 50.00, 1); + + expect(fn() => $this->cart->assertContains(1))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertContains fails when item is not in cart', function () { + $this->cart->fake(); + + expect(fn() => $this->cart->assertContains(99))->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertCount passes with correct count', function () { + $this->cart->fake(); + $this->cart->add(1, 'A', 10.00, 1); + $this->cart->add(2, 'B', 20.00, 1); + + expect(fn() => $this->cart->assertCount(2))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertTotalQuantity passes with correct quantity', function () { + $this->cart->fake(); + $this->cart->add(1, 'A', 10.00, 3); + + expect(fn() => $this->cart->assertTotalQuantity(3))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertSubTotal passes with correct subtotal', function () { + $this->cart->fake(); + $this->cart->add(1, 'Widget', 50.00, 2); + + expect(fn() => $this->cart->assertSubTotal(100.00))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertTotal passes with correct total', function () { + $this->cart->fake(); + $this->cart->add(1, 'Widget', 50.00, 1); + + expect(fn() => $this->cart->assertTotal(50.00))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertConditionApplied passes when condition exists', function () { + $this->cart->fake(); + $this->cart->condition(new CartCondition(['name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal'])); + + expect(fn() => $this->cart->assertConditionApplied('GST'))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertConditionApplied fails when condition does not exist', function () { + $this->cart->fake(); + + expect(fn() => $this->cart->assertConditionApplied('GST'))->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertEmpty passes when cart is empty', function () { + $this->cart->fake(); + + expect(fn() => $this->cart->assertEmpty())->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); + + test('assertNotEmpty passes when cart has items', function () { + $this->cart->fake(); + $this->cart->add(1, 'Widget', 10.00, 1); + + expect(fn() => $this->cart->assertNotEmpty())->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class); + }); +});