Compare commits

..
80 changed files with 2313 additions and 8482 deletions
+5 -21
View File
@@ -1,33 +1,17 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "master"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
groups:
actions:
patterns: ["*"]
commit-message:
prefix: "chore"
include: "scope"
labels:
- "dependencies"
- "deps"
- package-ecosystem: "composer"
directory: "/"
target-branch: "master"
schedule:
interval: "weekly"
day: "tuesday"
open-pull-requests-limit: 10
groups:
php-minor-patch:
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore"
include: "scope"
labels:
- "dependencies"
- "deps"
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
uses: dependabot/fetch-metadata@v2.2.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+7 -10
View File
@@ -9,19 +9,19 @@ on:
jobs:
tests:
name: Tests
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
strategy:
fail-fast: true
matrix:
php: ["8.3", "8.4", "8.5"]
php: ['8.1', '8.2', '8.3']
stability: [prefer-stable]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached
@@ -29,15 +29,12 @@ jobs:
coverage: none
- name: Install dependencies
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 5
command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress ${{ matrix.flags }}
- name: Execute tests
run: vendor/bin/pest
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress
if: matrix.php == '8.3'
- name: Execute tests
run: vendor/bin/pest
+1 -3
View File
@@ -3,6 +3,4 @@
.idea
.phpunit.result.cache
READMETODO.md
build
docs/superpowers/
.claude/
build
-61
View File
@@ -1,61 +0,0 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) Joel Male <joel@joelmale.com>
Copyright (c) Pixel Digital Pty Ltd <hello@wearepixel.com.au>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+80 -519
View File
@@ -1,88 +1,49 @@
<a name="top"></a>
[![Laravel Cart](./docs/laravel-cart.png)](https://joelmale.com)
[![Latest Version on Packagist](https://img.shields.io/packagist/v/wearepixel/laravel-cart.svg?style=flat-square)](https://packagist.org/packages/wearepixel/laravel-cart)
[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/wearepixel/laravel-cart/tests.yml?branch=master&label=Tests)](https://github.com/wearepixel/laravel-cart/actions?query=workflow%3ATests+branch%3Amaster)
[![Total Downloads](https://img.shields.io/packagist/dt/wearepixel/laravel-cart.svg?style=flat-square)](https://packagist.org/packages/wearepixel/laravel-cart)
[![GitHub last commit](https://img.shields.io/github/last-commit/wearepixel/laravel-cart)](#)
[![License](https://poser.pugx.org/wearepixel/laravel-cart/license.svg)](https://packagist.org/packages/wearepixel/laravel-cart)
[![Free](https://img.shields.io/badge/free_for_non_commercial_use-brightgreen)](#-license)
A Cart Implementation for Laravel.
Supported Laravel Versions: 10, 11, 12, and 13.
Supported Laravel Versions: 9, 10, 11
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)
- [Demo](#-deni)
- [Documentation](#-documentation)
- [Configuration](#configuration)
- [Basic Usage](#basic-usage)
- [Conditions](#conditions)
- [Cart Items](#cart-items)
- [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)
## 🚀 Getting Started
### 🔥 Installing
Install the package through [Composer](http://getcomposer.org/).
`composer require wearepixel/laravel-cart`
## 🧑‍🍳 Demo
## Quick Demo
```php
// Add an item to the cart
\Cart::add(
1, // any unique id
'Product 1', // product name
19.99, // product price
2, // quantity
['size' => 'large'] // an array of extra attributes
);
\Cart::add(1, 'Product 1', 19.99, 2, ['size' => 'large']);
// get the entire cart
// Get the cart contents
$cartContents = \Cart::getContent();
// update an item already in the cart
\Cart::update(
1, // the same unique id that was used to add the item
['quantity' => 3] // the quantity to update
);
// Update item quantity by its id
\Cart::update(1, ['quantity' => 3]);
// remove the item by its id
// Remove an item from the cart by its id
\Cart::remove(1);
// get the total of the cart
$total = Cart::getTotal();
// Get cart total
\$total = Cart::getTotal();
// clear it all when you've finished (like when you've stored the order)
// Clear the entire cart
\Cart::clear();
```
## 📚 Documentation
## Installing
### ⚙️ Configuration
Install the package through [Composer](http://getcomposer.org/).
`composer require "wearepixel/laravel-cart"`
## Configuration
You can publish the configuration file to customize various options.
```php
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="cart-config"
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="config"
```
#### Formatting Numbers
### Formatting Numbers
The package by default does not use round to format numbers, and instead returns the number using floatval().
@@ -95,7 +56,7 @@ #### Formatting Numbers
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
#### Decimals
### Decimals
You can customize the number of decimals in the configuration file.
@@ -105,7 +66,7 @@ #### Decimals
'decimals' => env('LARAVEL_CART_DECIMALS', 2),
```
#### Round Mode
### Round Mode
The package uses the `round` function to round the prices. You can customize the rounding mode in the configuration file.
@@ -115,22 +76,40 @@ #### Round Mode
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
### Basic Usage
## Usage
The cart has a default sessionKey that holds the cart data and stores it in the session, so you can have multiple carts for multiple users.
- [Basic Usage](#basic-usage)
- [Conditions](#conditions)
- [Items](#items)
- [Associating Models](#associating-models)
- [Instances](#instances)
- [Exceptions](#exceptions)
- [Events](#events)
- [Format Response](#format)
- [Examples](#examples)
- [Using Different Storage](#storage)
- [License](#license)
This also serves as a cart unique identifier which you can use to bind a cart to a specific user if you want to.
## Usage
Make sure to call `\Cart::setSessionKey($sessionKey)` before calling any other cart methods.
The cart has a default sessionKey that holds the cart data and stores it in the session.
This
also serves as a cart unique identifier which you can use to bind a cart to a specific user if you want to.
Make sure to call `\Cart::session($sessionKey)` before calling any other cart methods.
Usually this is not required.
```php
// Binds the cart to a unique id (user id, session id, etc.)
\Cart::setSessionKey(User::first()->id);
\Cart::session(User::first()->id);
// If you don't want to bind the cart on a project level, you can add it to each call
\Cart::session(User::first()->id)->add(1, 'Product 1', 19.99, 2, ['size' => 'large']);
```
#### Adding to the cart: **Cart::add()**
### Adding to the cart: **Cart::add()**
There are a few ways to add items to the cart.
@@ -175,8 +154,8 @@ #### Adding to the cart: **Cart::add()**
[], // attributes
[
[
'name' => '10% Off',
'type' => 'discount',
'name' => '10% Off',
'type' => 'discount',
'value' => '-10%'
]
] // conditions
@@ -201,12 +180,12 @@ #### Adding to the cart: **Cart::add()**
);
```
#### Updating an item on a cart: **Cart::update()**
### Updating an item on a cart: **Cart::update()**
```php
Cart::update(
456, # product id
[
[
'name' => 'New Item Name', // new item name
'price' => 98.67, // new item price as a float or string
]
@@ -222,7 +201,7 @@ #### Updating an item on a cart: **Cart::update()**
// reducing it...
Cart::update(
456,
456,
[
'quantity' => -1, // so if from 4 to 3
]
@@ -240,14 +219,14 @@ #### Updating an item on a cart: **Cart::update()**
);
```
#### Removing an item on a cart: **Cart::remove()**
### Removing an item on a cart: **Cart::remove()**
```php
// Remove an item from the cart by its id
Cart::remove(456);
```
#### Getting an item on a cart: **Cart::get()**
### Getting an item on a cart: **Cart::get()**
```php
// Get an item from the cart by its id
@@ -257,7 +236,7 @@ #### Getting an item on a cart: **Cart::get()**
$summedPrice = Cart::get($itemId)->getPriceSum();
```
#### Getting the cart content: **Cart::getContent()**
### Getting the cart content: **Cart::getContent()**
```php
// Returns a collection of the cart's contents
@@ -277,13 +256,13 @@ #### Getting the cart content: **Cart::getContent()**
$cartTotalQuantity = Cart::getTotalQuantity();
```
#### Cart subtotal: **Cart::getSubTotal()**
### Cart subtotal: **Cart::getSubTotal()**
```php
$subTotal = Cart::getSubTotal();
```
#### Cart subtotal without conditions: **Cart::getSubTotalWithoutConditions()**
### Cart subtotal without conditions: **Cart::getSubTotalWithoutConditions()**
```php
$subTotalWithoutConditions = Cart::getSubTotalWithoutConditions();
@@ -295,29 +274,19 @@ #### Cart subtotal without conditions: **Cart::getSubTotalWithoutConditions()**
$total = Cart::getTotal();
```
#### Check if cart is empty: **Cart::isEmpty()**
### Check if cart is empty: **Cart::isEmpty()**
```php
Cart::isEmpty();
```
#### Clearing the Cart: **Cart::clear()**
This clears all items and conditions from the cart.
### Clearing the Cart: **Cart::clear()**
```php
Cart::clear();
```
#### Clearing the cart items only: **Cart::clearItems()**
This clears all items, but keeps the conditions (useful for when you want to keep the conditions but remove the items)
```php
Cart::clearItems();
```
### Conditions
## Conditions
Conditions are very useful for adding things like discounts, taxes, shipping, etc.
@@ -329,9 +298,9 @@ ### Conditions
You can also provide an `order` to cart conditions which tells the cart in what order to apply the conditions. Item level conditions do not support the `order` parameter.
#### Conditions on the cart
### Conditions on the cart
##### Adding a condition to the cart: **Cart::condition()**
#### Adding a condition to the cart: **Cart::condition()**
```php
// Add a single condition to the cart
@@ -382,7 +351,7 @@ ##### Adding a condition to the cart: **Cart::condition()**
Cart::condition($shipping);
```
##### Getting conditions on the cart: **Cart::getConditions()**
#### Getting conditions on the cart: **Cart::getConditions()**
```php
// To get all applied conditions on a cart, use below:
@@ -401,7 +370,7 @@ ##### Getting conditions on the cart: **Cart::getConditions()**
}
```
##### Getting conditions on the cart as an array: **Cart::getConditions(array: true)**
#### Getting conditions on the cart as an array: **Cart::getConditions(array: true)**
You can get all cart conditions in array format by passing "array: true". This is useful if you want to store the carts conditions on a Livewire component since by default we have collections inside collections for conditions which Livewire does not support.
@@ -420,7 +389,7 @@ ##### Getting conditions on the cart as an array: **Cart::getConditions(array: t
}
```
##### Getting conditions by name: **Cart::getCondition($conditionName)**
#### Getting conditions by name: **Cart::getCondition($conditionName)**
```php
$condition = Cart::getCondition('GST');
@@ -434,7 +403,7 @@ ##### Getting conditions by name: **Cart::getCondition($conditionName)**
$condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added
```
##### Getting active conditions on the cart: **Cart::getConditions(active: true)**
#### Getting active conditions on the cart: **Cart::getConditions(active: true)**
You can get only active conditions by passsing `active: true` to the `getConditions()` method.
@@ -470,14 +439,14 @@ ##### Getting active conditions on the cart: **Cart::getConditions(active: true)
// will return no conditions if the subtotal is $210
```
##### Calculating condition value
### Calculating condition value
There are 2 ways to calculate the value of a condition:
1. Using the `getCalculatedValue` method on the condition instance
2. Using the `getCalculatedValueForCondition` method on the cart instance and passing the condition name
##### Using the `getCalculatedValue` method on the condition instance
#### Using the `getCalculatedValue` method on the condition instance
```php
$subTotal = Cart::getSubTotal();
@@ -485,7 +454,7 @@ ##### Using the `getCalculatedValue` method on the condition instance
$conditionCalculatedValue = $condition->getCalculatedValue($subTotal);
```
##### Using the `getCalculatedValueForCondition` method on the cart instance
#### Using the `getCalculatedValueForCondition` method on the cart instance
This method automatically calculates the value of a condition by it's name based on the order of the conditions.
@@ -518,7 +487,7 @@ ##### Using the `getCalculatedValueForCondition` method on the cart instance
Cart::getCalculatedValueForCondition('Gift Card'); // returns 0 as the coupon discount is applied first and brings the subtotal to 0
```
##### Adding conditions that activate once a minimum value is met
### Adding conditions that activate once a minimum value is met
You can add a `minimum` amount required for a condition to activate.
@@ -537,7 +506,7 @@ ##### Adding conditions that activate once a minimum value is met
Cart::condition($tenPercentOff)
```
##### Adding conditions that only activate up to a maximum value
### Adding conditions that only activate up to a maximum value
You can add a `maximum` amount required for a condition to activate.
@@ -556,7 +525,7 @@ ##### Adding conditions that only activate up to a maximum value
Cart::condition($shipping)
```
#### Conditions on items
### Condition on items
Item conditions are useful if you have discounts to be applied specifically on an item and not on the whole cart value.
@@ -607,42 +576,6 @@ #### 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.
@@ -653,7 +586,7 @@ ##### Limiting a condition to a specific quantity of items
$cartSubTotal = Cart::getSubTotal();
```
##### Add a condition to an existing item on the cart: **Cart::addItemCondition($productId, $itemCondition)**
#### Add a condition to an existing item on the cart: **Cart::addItemCondition($productId, $itemCondition)**
Adding Condition to an existing Item on the cart is simple as well.
@@ -667,7 +600,7 @@ ##### Add a condition to an existing item on the cart: **Cart::addItemCondition(
Cart::addItemCondition(456, $condition);
```
#### Clearing Cart Conditions: **Cart::clearCartConditions()**
### Clearing Cart Conditions: **Cart::clearCartConditions()**
This clears all cart level conditions, and does not affect item level conditions.
@@ -681,25 +614,25 @@ #### Clearing Cart Conditions: **Cart::clearCartConditions()**
Cart::clearAllConditions()
```
##### Remove a specific cart condtion: **Cart::removeCartCondition($conditionName)**
### Remove a specific cart condtion: **Cart::removeCartCondition($conditionName)**
```php
Cart::removeCartCondition('Summer Sale 5%')
```
##### Remove a specific item condition: **Cart::removeItemCondition($itemId, $conditionName)**
### Remove a specific item condition: **Cart::removeItemCondition($itemId, $conditionName)**
```php
Cart::removeItemCondition(456, 'SALE 5%')
```
##### Clear all item conditions: **Cart::clearItemConditions($itemId)**
### Clear all item conditions: **Cart::clearItemConditions($itemId)**
```php
Cart::clearItemConditions(456)
```
#### Get conditions by type: **Cart::getConditionsByType($type)**
### Get conditions by type: **Cart::getConditionsByType($type)**
This returns all conditions that has been added to the cart by the type specified.
@@ -707,13 +640,13 @@ #### Get conditions by type: **Cart::getConditionsByType($type)**
$tax = Cart::getConditionsByType('tax');
```
##### Remove conditions by type: **Cart::removeConditionsByType($type)**
### Remove conditions by type: **Cart::removeConditionsByType($type)**
```php
Cart::removeConditionsByType('tax');
```
### Cart Items
## Cart Items
The method **Cart::getContent()** returns a collection of items.
@@ -735,380 +668,8 @@ ### Cart Items
$item->getPriceSumWithConditions();
```
### Storage Drivers
## Credits
The cart supports multiple storage backends via a driver system.
Huge credits to the original maintainer of the package, located at [darryldecode/laravelshoppingcart](https://github.com/darryldecode/laravelshoppingcart)
#### Session (default)
Cart data is stored in the Laravel session. No configuration needed.
#### Database
Store the cart in a database table for persistence across sessions.
**1. Create a migration:**
```php
$table->string('session_id');
$table->text('items');
$table->text('conditions');
```
**2. Add casts to your model:**
```php
protected $guarded = [];
protected $casts = [
'items' => 'array',
'conditions' => 'array',
];
```
**3. Update `config/cart.php`:**
```php
'driver' => 'database',
'drivers' => [
'database' => [
'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.
#### LaravelCart.Created
This fires every time a cart is instantiated (i.e every time \Cart::add() is called)
```
Event::listen('LaravelCart.Added', function () {
// cart was created
});
```
#### LaravelCart.Adding
This fires every time an item is being added to the cart
```
Event::listen('LaravelCart.Adding', function ($item) {
// item is being added
});
```
#### LaravelCart.Added
This fires every time an item is successfully added to the cart
```
Event::listen('LaravelCart.Added', function ($item) {
// item was added
});
```
#### LaravelCart.Updating
This fires every time an item is being updated
```
Event::listen('LaravelCart.Updating', function ($item) {
// item is being updated
});
```
#### LaravelCart.Updated
This fires every time an item is successfully updated
```
Event::listen('LaravelCart.Updated', function ($item) {
// item was updated
});
```
#### LaravelCart.Removing
This fires every time an item is being removed
```
Event::listen('LaravelCart.Removing', function ($item) {
// item is being removed
});
```
#### LaravelCart.Removed
This fires every time an item is successfully removed
```
Event::listen('LaravelCart.Removed', function ($item) {
// item was removed
});
```
#### LaravelCart.Clearing
This fires every time the cart is being cleared
```
Event::listen('LaravelCart.Clearing', function () {
// cart is being cleared
});
```
#### LaravelCart.Cleared
This fires every time the cart is successfully cleared
```
Event::listen('LaravelCart.Cleared', function () {
// cart was cleared
});
```
## 🫡 Credits
This package was orignally created by [darryldecode](https://github.com/darryldecode) but has since seen almost no updates. I have decided to take the old package and transform it into a new package with new features and updates.
## 📓 License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
We have forked this package to update it to Laravel 11 and to add some features and support for newer PHP versions.
-11
View File
@@ -1,11 +0,0 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability, please report it privately using the below channel:
**GitHub Private Vulnerability Reporting** — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
All security vulnerabilities will be promptly addressed.
-165
View File
@@ -1,165 +0,0 @@
# 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.
+10 -18
View File
@@ -1,6 +1,6 @@
{
"name": "wearepixel/laravel-cart",
"version": "3.0.0",
"version": "1.0.8",
"description": "A cart implementation for Laravel",
"keywords": [
"laravel",
@@ -15,34 +15,26 @@
},
{
"name": "Joel Male",
"email": "joel@joelmale.com"
"email": "joel@wearepixel.com.au"
}
],
"require": {
"php": ">=8.3",
"illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/auth": "^11.0|^12.0|^13.0",
"illuminate/container": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/validation": "^11.0|^12.0|^13.0"
"php": ">=8.1",
"illuminate/support": "^9.0|^10.0|^11.0",
"illuminate/validation": "^9.0|^10.0|^11.0",
"illuminate/translation": "^9.0|^10.0|^11.0"
},
"require-dev": {
"mockery/mockery": "^2.0",
"laravel/pint": "^1.21",
"pestphp/pest": "^4.7",
"larapack/dd": "dev-master",
"phpstan/phpstan": "2.2.x-dev"
"mockery/mockery": "^1.4.2",
"laravel/pint": "^1.17",
"pestphp/pest": "2.35.1",
"larapack/dd": "1.1"
},
"autoload": {
"psr-4": {
"Wearepixel\\Cart\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"extra": {
"laravel": {
Generated
+1604 -4100
View File
File diff suppressed because it is too large Load Diff
+4 -21
View File
@@ -14,32 +14,15 @@
/*
* ---------------------------------------------------------------
* Storage
* Persistence
* ---------------------------------------------------------------
* Supported: "session", "database", "redis", "null", "multi"
*/
'driver' => 'session',
'storage' => [
'database' => [
'model' => '',
'id' => '',
'items' => '',
'conditions' => '',
],
],
'storage' => null,
/*
* ---------------------------------------------------------------
* Driver-specific options
* Events
* ---------------------------------------------------------------
*/
'drivers' => [
'redis' => [
'connection' => 'default',
'ttl' => 604800, // 7 days in seconds
],
'multi' => ['session', 'database'],
],
'events' => null,
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

-42
View File
@@ -1,42 +0,0 @@
<?php
/**
* PHPStan bootstrap — stubs for Laravel global helpers that are always
* available at runtime in a Laravel application but are not present in the
* standalone illuminate/* packages this package depends on.
*/
if (! function_exists('app')) {
function app(string $abstract = null, array $parameters = []): mixed
{
return null;
}
}
if (! function_exists('config')) {
function config(string|null $key = null, mixed $default = null): mixed
{
return null;
}
}
if (! function_exists('config_path')) {
function config_path(string $path = ''): string
{
return '';
}
}
if (! function_exists('app_path')) {
function app_path(string $path = ''): string
{
return '';
}
}
if (! function_exists('session')) {
function session(string|array|null $key = null, mixed $default = null): mixed
{
return null;
}
}
-14
View File
@@ -1,14 +0,0 @@
parameters:
level: 5
phpVersion: 80300
paths:
- src
bootstrapFiles:
- phpstan-bootstrap.php
ignoreErrors:
# HasCart is a trait for external use in Livewire components — not used within this package.
- identifier: trait.unused
# PHPStan 2.x bundles PHP 8.4 stubs where PHP_ROUND_HALF_UP/DOWN map to 1/2 instead of 0/1.
# The code is correct at runtime on PHP 8.3. Remove once PHPStan stubs are corrected.
- message: '#Parameter \#3 \$mode of function round expects#'
path: src/Helpers/Helpers.php
+7 -6
View File
@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="tests/bootstrap.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd">
<source ignoreIndirectDeprecations="true">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="vendor/autoload.php"
colors="true"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.4/phpunit.xsd"
>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
<testsuites>
<testsuites>
<testsuite name="Laravel Cart Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<php>
<env name="DB_CONNECTION" value="testing"/>
</php>
</phpunit>
+449 -330
View File
File diff suppressed because it is too large Load Diff
+6 -10
View File
@@ -78,14 +78,6 @@ 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
*
@@ -113,11 +105,13 @@ public function setValue($value): void
}
/**
* Set the order to apply this condition.
* Set the order to apply this condition. If no argument order is applied we return 0 as
* indicator that no assignment has been made
*
* @param int $order
* @return int
*/
public function setOrder($order = 1): void
public function setOrder($order = 1)
{
$this->args['order'] = $order;
}
@@ -145,6 +139,8 @@ 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
{
-72
View File
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart;
use Illuminate\Support\Manager;
use Wearepixel\Cart\Drivers\MultiDriver;
use Wearepixel\Cart\Drivers\NullDriver;
use Wearepixel\Cart\Drivers\RedisDriver;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Drivers\DatabaseDriver;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class CartManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->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);
}
}
+42 -27
View File
@@ -1,53 +1,68 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart;
use Illuminate\Support\ServiceProvider;
use Wearepixel\Cart\Commands\DebugCommand;
use Wearepixel\Cart\Commands\InstallCommand;
use Wearepixel\Cart\Commands\MakeCouponCommand;
use Wearepixel\Cart\Commands\MakeDriverCommand;
use Wearepixel\Cart\Commands\MakeShippingCommand;
use Wearepixel\Cart\Commands\MakeTaxCommand;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Boot the service provider.
*/
public function boot()
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/config.php' => config_path('cart.php'),
], 'cart-config');
$this->commands([
InstallCommand::class,
MakeCouponCommand::class,
MakeTaxCommand::class,
MakeShippingCommand::class,
MakeDriverCommand::class,
DebugCommand::class,
]);
], 'config');
}
}
public function register(): void
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$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();
$storageClass = config('cart.storage');
$eventsClass = config('cart.events');
return new Cart($driver, $events, 'cart', $config);
$storage = $storageClass ? new $storageClass : $app['session'];
$events = $eventsClass ? new $eventsClass : $app['events'];
$instanceName = 'cart';
// default session or cart identifier. This will be overridden when calling Cart::session($sessionKey)->add() etc..
// like when adding a cart for a specific user name. Session Key can be string or maybe a unique identifier to bind a cart
// to a specific user, this can also be a user ID
$session_key = '4yTlTDKu3oJOfzD';
return new Cart(
$storage,
$events,
$instanceName,
$session_key,
config('cart')
);
});
}
public function provides(): array
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [];
}
-67
View File
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
use Wearepixel\Cart\Cart;
class DebugCommand extends Command
{
protected $signature = 'cart:debug';
protected $description = 'Dump the current cart state. Only runs outside production.';
public function handle(): int
{
if (app()->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: <comment>{$data['driver']}</comment>");
$this->line(" Items: <comment>{$data['item_count']}</comment>");
$this->line(" Total Quantity: <comment>{$data['total_quantity']}</comment>");
$this->line(" Subtotal: <comment>{$data['subtotal']}</comment>");
$this->line(" Total: <comment>{$data['total']}</comment>");
if (! empty($data['conditions'])) {
$this->line(" Conditions: <comment>" . implode(', ', $data['conditions']) . '</comment>');
} else {
$this->line(' Conditions: <comment>none</comment>');
}
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(),
];
}
}
-41
View File
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class InstallCommand extends Command
{
protected $signature = 'cart:install';
protected $description = 'Publish the cart config and scaffold the app/Cart directory structure.';
public function handle(): int
{
$this->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 <comment>config/cart.php</comment> and set your preferred storage driver.');
$this->line(' 2. Run <comment>php artisan cart:make:coupon MyCoupon</comment> to create your first coupon.');
$this->line(' 3. Run <comment>php artisan cart:make:tax MyTax</comment> to add a tax rule.');
$this->line(' 4. Run <comment>php artisan cart:make:shipping MyShipping</comment> 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);
}
}
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeCouponCommand extends Command
{
protected $signature = 'cart:make:coupon {name : The class name of the coupon}';
protected $description = 'Create a new Coupon class in app/Cart/Coupons.';
public function handle(): int
{
$name = $this->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 <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Coupons;
use Wearepixel\Cart\Coupons\Coupon;
class {$className} extends Coupon
{
protected string \$code = 'COUPON_CODE';
protected string \$value = '-10%';
protected string \$target = 'subtotal';
public function isValid(): bool
{
return true;
}
}
PHP;
}
}
-124
View File
@@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeDriverCommand extends Command
{
protected $signature = 'cart:make:driver {name : The class name of the custom cart driver}';
protected $description = 'Create a new CartDriver implementation in app/Cart/Drivers.';
public function handle(): int
{
$name = $this->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 <comment>drivers</comment>.");
return self::SUCCESS;
}
public static function stub(string $className): string
{
return <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class {$className} implements CartDriver
{
private string \$sessionKey;
public function __construct(string \$sessionKey)
{
\$this->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;
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeShippingCommand extends Command
{
protected $signature = 'cart:make:shipping {name : The class name of the shipping rate}';
protected $description = 'Create a new ShippingRate class in app/Cart/Shipping.';
public function handle(): int
{
$name = $this->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 <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Shipping;
use Wearepixel\Cart\Shipping\ShippingRate;
class {$className} extends ShippingRate
{
protected string \$name = 'Standard Shipping';
protected string \$value = '+10';
protected string \$target = 'total';
public function isApplicable(): bool
{
return true;
}
}
PHP;
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeTaxCommand extends Command
{
protected $signature = 'cart:make:tax {name : The class name of the tax rule}';
protected $description = 'Create a new TaxRule class in app/Cart/Tax.';
public function handle(): int
{
$name = $this->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 <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Tax;
use Wearepixel\Cart\Tax\TaxRule;
class {$className} extends TaxRule
{
protected string \$name = 'Tax Name';
protected string \$value = '10%';
protected string \$target = 'subtotal';
public function isApplicable(): bool
{
return true;
}
}
PHP;
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Concerns;
use Wearepixel\Cart\Cart;
trait HasCart
{
public array $cartItems = [];
public float $cartTotal = 0.0;
public float $cartSubTotal = 0.0;
public int $cartCount = 0;
protected function getCart(): Cart
{
return app('cart');
}
public function refreshCart(): void
{
$cart = $this->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();
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Concerns;
use Wearepixel\Cart\Cart;
class ShareCartWithInertia
{
public static function data(?Cart $cart = null): array
{
$cart ??= app('cart');
return [
'items' => $cart->getContent()->toArray(),
'subtotal' => (float) $cart->getSubTotal(false),
'total' => (float) $cart->getTotal(),
'count' => (int) $cart->getTotalQuantity(),
];
}
}
-12
View File
@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Contracts;
use Wearepixel\Cart\CartCondition;
interface CartConditionable
{
public function toCondition(): CartCondition;
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Coupons;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
abstract class Coupon implements CartConditionable
{
protected string $code;
protected string $value;
protected string $target = 'subtotal';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isValid(): bool;
public function getCode(): string
{
return $this->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);
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers\Contracts;
interface CartDriver
{
public function getSessionKey(): string;
public function setSessionKey(string $sessionKey): void;
public function getDriverName(): string;
public function getItems(): array;
public function putItems(array $items): void;
public function getConditions(): array;
public function putConditions(array $conditions): void;
public function clearItems(): void;
public function flush(): void;
public function getSessionModel(): mixed;
}
-82
View File
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Illuminate\Database\Eloquent\Model;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class DatabaseDriver implements CartDriver
{
private Model $model;
public function __construct(
private string $modelClass,
private string $idColumn,
private string $itemsColumn,
private string $conditionsColumn,
private string $sessionKey,
) {
$this->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;
}
}
-84
View File
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use InvalidArgumentException;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class MultiDriver implements CartDriver
{
/** @param CartDriver[] $drivers */
public function __construct(private array $drivers)
{
if (empty($drivers)) {
throw new InvalidArgumentException('MultiDriver requires at least one driver.');
}
}
private function primary(): CartDriver
{
return $this->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();
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class NullDriver implements CartDriver
{
private array $items = [];
private array $conditions = [];
public function __construct(private string $sessionKey) {}
public function getSessionKey(): string
{
return $this->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;
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class RedisDriver implements CartDriver
{
public function __construct(
private mixed $redis,
private string $sessionKey,
private string $connection = 'default',
private int $ttl = 604800,
) {}
private function itemsKey(): string
{
return "cart:{$this->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;
}
}
-91
View File
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class SessionDriver implements CartDriver
{
private string $itemsKey;
private string $conditionsKey;
public function __construct(
private mixed $session = null,
private ?string $sessionKey = null,
) {
$this->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;
}
}
@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidCouponException extends \Exception {}
@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidShippingRateException extends \Exception {}
@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidTaxRuleException extends \Exception {}
+1 -59
View File
@@ -3,68 +3,10 @@
namespace Wearepixel\Cart\Facades;
use Illuminate\Support\Facades\Facade;
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCollection;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\CartConditionCollection;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
use Wearepixel\Cart\ItemCollection;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Testing\CartFactory;
/**
* @mixin Cart
*
* @method static CartDriver getDriver()
* @method static CartFactory fake()
* @method static void assertContains(int|string $id)
* @method static void assertCount(int $count)
* @method static void assertTotalQuantity(int|float $quantity)
* @method static void assertSubTotal(float $amount)
* @method static void assertTotal(float $amount)
* @method static void assertConditionApplied(string $conditionName)
* @method static void assertEmpty()
* @method static void assertNotEmpty()
* @method static string getSessionKey()
* @method static void setSessionKey(string $sessionKey)
* @method static string getInstanceName()
* @method static ItemCollection|null get(int|string $itemId)
* @method static bool has(int|string $itemId)
* @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 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 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 Cart associate(mixed $model)
*/
class CartFacade extends Facade
{
protected static function getFacadeAccessor(): string
protected static function getFacadeAccessor()
{
return 'cart';
}
+7 -70
View File
@@ -5,14 +5,6 @@
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<string, mixed> $attributes
* @property mixed $conditions
*/
class ItemCollection extends Collection
{
/**
@@ -54,9 +46,9 @@ public function __get($name)
/**
* return the associated model of an item
*
* @return mixed
* @return bool
*/
protected function getAssociatedModel(): mixed
protected function getAssociatedModel()
{
if (! $this->has('associatedModel')) {
return null;
@@ -99,37 +91,7 @@ public function getConditions()
return [];
}
$conditionsArray = [];
$conditions = $this['conditions'];
// check if we're already an array of CartConditions
foreach ($conditions as $key => $condition) {
if (is_object($condition) && $condition instanceof CartCondition) {
return $conditions;
}
}
$hasSubArray = false;
foreach ($conditions as $key => $condition) {
if (is_array($condition)) {
$hasSubArray = true;
$conditionsArray[] = new CartCondition($condition);
} elseif ($condition instanceof CartCondition) {
$conditionsArray[] = $condition;
}
}
if (! $hasSubArray) {
if ($conditions instanceof CartCondition) {
return $conditions;
}
$conditionsArray[] = new CartCondition($conditions);
}
return $conditionsArray;
return $this['conditions'];
}
/**
@@ -146,14 +108,14 @@ public function getPriceWithConditions($formatted = true)
$processed = 0;
if ($this->hasConditions()) {
if (is_array($this->getConditions())) {
foreach ($this->getConditions() as $condition) {
if (is_array($this->conditions)) {
foreach ($this->conditions as $condition) {
($processed > 0) ? $toBeCalculated = $newPrice : $toBeCalculated = $originalPrice;
$newPrice = $condition->applyCondition($toBeCalculated);
$processed++;
}
} else {
$newPrice = $this->getConditions()->applyCondition($originalPrice);
$newPrice = $this['conditions']->applyCondition($originalPrice);
}
return Helpers::formatValue($newPrice, $formatted, $this->config);
@@ -170,31 +132,6 @@ public function getPriceWithConditions($formatted = true)
*/
public function getPriceSumWithConditions($formatted = true)
{
$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);
return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config);
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Shipping;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidShippingRateException;
abstract class ShippingRate implements CartConditionable
{
protected string $name;
protected string $value;
protected string $target = 'total';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isApplicable(): bool;
public function toCondition(): CartCondition
{
if (! $this->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);
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Tax;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidTaxRuleException;
abstract class TaxRule implements CartConditionable
{
protected string $name;
protected string $value;
protected string $target = 'subtotal';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isApplicable(): bool;
public function toCondition(): CartCondition
{
if (! $this->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);
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Testing;
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
class CartFactory
{
public function __construct(private Cart $cart) {}
public function withItems(int $count): static
{
$start = $this->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;
}
}
-3
View File
@@ -7,9 +7,6 @@
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;
+2 -1
View File
@@ -4,5 +4,6 @@
'format_numbers' => true,
'decimals' => 2,
'dec_point' => '.',
'driver' => 'session',
'thousands_sep' => ',',
'use_cents' => false,
];
-17
View File
@@ -1,17 +0,0 @@
<?php
return [
'format_numbers' => true,
'decimals' => 2,
'round_mode' => 'up',
'driver' => 'database',
'storage' => [
'session',
'database' => [
'model' => \Wearepixel\Cart\Tests\Helpers\MockCartModel::class,
'id' => 'session_id',
'items' => 'items',
'conditions' => 'conditions',
],
],
];
-1
View File
@@ -4,5 +4,4 @@
'format_numbers' => true,
'decimals' => 2,
'round_mode' => 'up',
'driver' => 'session',
];
+1 -1
View File
@@ -4,5 +4,5 @@
'format_numbers' => false,
'decimals' => 3,
'dec_point' => ',',
'driver' => 'session',
'thousands_sep' => '.',
];
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace Wearepixel\Cart\Tests\Helpers;
use Illuminate\Database\Eloquent\Model;
class MockCartModel extends Model
{
protected $fillable = [
'session_id',
'items',
'conditions',
];
public $timestamps = false;
protected $casts = [
'items' => 'array',
'conditions' => 'array',
];
protected $table = 'carts';
}
-5
View File
@@ -20,9 +20,4 @@ public function put($key, $value)
{
$this->session[$key] = $value;
}
public function forget($key)
{
unset($this->session[$key]);
}
}
+26 -2
View File
@@ -1,5 +1,29 @@
<?php
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
uses(TestCase::class)->in(__DIR__);
// pest()->extend(Tests\TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
+2 -44
View File
@@ -1,52 +1,10 @@
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Capsule\Manager as DB;
use PHPUnit\Framework\TestCase as BaseTestCase;
class TestCase extends BaseTestCase
abstract class TestCase extends BaseTestCase
{
protected DB $db;
protected function setUp(): void
{
parent::setUp();
// Create a Laravel container
$container = Container::getInstance();
$dispatcher = new Dispatcher;
// Setup Capsule (Eloquent ORM)
$this->db = new DB($container);
$this->db->addConnection([
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$this->db->setAsGlobal();
$this->db->bootEloquent();
$this->db->getConnection()->setEventDispatcher($dispatcher);
// Run migrations (optional, for testing models)
$this->migrateDatabase();
}
protected function migrateDatabase()
{
$schema = $this->db->schema();
$schema->create('carts', function (Blueprint $table) {
$table->id();
$table->string('session_id');
$table->text('items');
$table->text('conditions');
$table->timestamps();
});
}
//
}
+9 -9
View File
@@ -3,7 +3,6 @@
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 () {
@@ -11,9 +10,10 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigConditionsMock.php')
);
});
@@ -119,7 +119,7 @@
// now we should have only 1 condition left on that item
expect($this->cart->getConditions())->toHaveCount(1, 'Item should have one condition left');
expect($this->cart->getConditions()->first()->getName())->toEqual('Mother\'s Day Gift Pack');
expect($this->cart->getConditions()[0]->getName())->toEqual('Mother\'s Day Gift Pack');
});
});
@@ -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($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');
expect($couponDiscount->getCalculatedValue())->toEqual(200.0, 'Coupon Discount value should be 200.0');
expect($giftCard->getCalculatedValue())->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 () {
@@ -1065,7 +1065,7 @@
expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90');
$this->cart->clearItems();
$this->cart->clear();
$this->cart->add([
'id' => 1,
@@ -1099,7 +1099,7 @@
expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90');
$this->cart->clearItems();
$this->cart->clear();
$this->cart->add([
'id' => 1,
@@ -1201,7 +1201,7 @@
expect($this->cart->getConditions(active: true))->toHaveCount(0, 'Cart should have 0 conditions');
$this->cart->clearItems();
$this->cart->clear();
$this->cart->add([
'id' => 1,
@@ -1310,7 +1310,7 @@
// now we should have only 1 condition left on that item
expect($this->cart->get(1)['conditions'])->toHaveCount(1, 'Item should have one condition left');
expect($this->cart->get(1)['conditions'][1]->getName())->toEqual('Mother\'s Day Gift Pack');
expect($this->cart->get(1)['conditions'][0]->getName())->toEqual('Mother\'s Day Gift Pack');
});
test('can add item with multiple negative item conditions', function () {
-100
View File
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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);
});
});
+2 -14
View File
@@ -1,7 +1,6 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -10,9 +9,10 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
@@ -689,15 +689,3 @@
expect($this->cart->getTotalQuantity())->toBeInt('Return type should be INT');
expect($this->cart->getTotalQuantity())->toEqual(12, 'Cart\'s quantity should be 4.');
});
test('can add to cart and then update the session key', function () {
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1');
expect($this->cart->getContent()->first()['id'])->toEqual(1, 'Item added has ID of 1 so first content ID should be 1');
$this->cart->setSessionKey('new_session_key');
expect($this->cart->getSessionKey())->toEqual('new_session_key', 'Session key should be updated to new_session_key');
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should still be 1');
});
+38 -33
View File
@@ -1,7 +1,6 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {});
@@ -10,14 +9,15 @@
Mockery::close();
});
test('event cart Created', function () {
test('event cart created', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -25,15 +25,16 @@
});
test('event cart adding', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Added', Mockery::type('array'), true);
$events = Mockery::mock('Illuminate\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.added', Mockery::type('array'), true);
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -43,15 +44,16 @@
});
test('event cart adding multiple times', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Added', Mockery::type('array'), true);
$events = Mockery::mock('Illuminate\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(2)->with('cart.adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(2)->with('cart.added', Mockery::type('array'), true);
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -62,10 +64,10 @@
});
test('event cart adding multiple times scenario two', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$events = Mockery::mock('Illuminate\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true);
$items = [
[
@@ -92,9 +94,10 @@
];
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -104,12 +107,12 @@
});
test('event cart remove item', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('LaravelCart.Removing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('LaravelCart.Removed', Mockery::type('array'), true);
$events = Mockery::mock('Illuminate\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('cart.removing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('cart.removed', Mockery::type('array'), true);
$items = [
[
@@ -136,9 +139,10 @@
];
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -150,12 +154,12 @@
});
test('event cart clear', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Clearing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Cleared', Mockery::type('array'), true);
$events = Mockery::mock('Illuminate\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.clearing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.cleared', Mockery::type('array'), true);
$items = [
[
@@ -182,9 +186,10 @@
];
$cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
+4 -3
View File
@@ -1,7 +1,6 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -9,16 +8,18 @@
$events->shouldReceive('dispatch');
$this->cart1 = new Cart(
new SessionDriver(new SessionMock, 'uniquesessionkey123'),
new SessionMock,
$events,
'shopping',
'uniquesessionkey123',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
$this->cart2 = new Cart(
new SessionDriver(new SessionMock, 'uniquesessionkey456'),
new SessionMock,
$events,
'wishlist',
'uniquesessionkey456',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
-71
View File
@@ -1,71 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Commands\DebugCommand;
use Wearepixel\Cart\Drivers\NullDriver;
function makeDebugCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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');
});
});
@@ -1,33 +0,0 @@
<?php
use Wearepixel\Cart\Commands\InstallCommand;
describe('InstallCommand', function () {
test('scaffoldDirectories() creates the expected subdirectories', function () {
$base = sys_get_temp_dir() . '/cart-install-' . uniqid();
InstallCommand::scaffoldDirectories($base);
expect(is_dir("{$base}/Coupons"))->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);
}
});
});
@@ -1,30 +0,0 @@
<?php
use Wearepixel\Cart\Commands\MakeCouponCommand;
describe('MakeCouponCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->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)');
});
});
@@ -1,34 +0,0 @@
<?php
use Wearepixel\Cart\Commands\MakeDriverCommand;
describe('MakeDriverCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeDriverCommand::stub('RedisDriver');
expect($stub)->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');
});
});
@@ -1,30 +0,0 @@
<?php
use Wearepixel\Cart\Commands\MakeShippingCommand;
describe('MakeShippingCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->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'");
});
});
@@ -1,30 +0,0 @@
<?php
use Wearepixel\Cart\Commands\MakeTaxCommand;
describe('MakeTaxCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->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)');
});
});
-87
View File
@@ -1,87 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Concerns\HasCart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
class FakeComponent
{
use HasCart;
public array $cartItems = [];
public float $cartTotal = 0.0;
public float $cartSubTotal = 0.0;
public int $cartCount = 0;
public function __construct(private Cart $cart) {}
protected function getCart(): Cart
{
return $this->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);
});
});
@@ -1,54 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Concerns\ShareCartWithInertia;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCartForInertia(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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();
});
});
-72
View File
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
class AlwaysValidCoupon extends Coupon
{
protected string $code = 'SAVE10';
protected string $value = '-10%';
public function isValid(): bool
{
return true;
}
}
class NeverValidCoupon extends Coupon
{
protected string $code = 'EXPIRED';
protected string $value = '-10%';
public function isValid(): bool
{
return false;
}
}
describe('Coupon', function () {
test('converts to a CartCondition', function () {
$condition = (new AlwaysValidCoupon)->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');
});
});
-181
View File
@@ -1,181 +0,0 @@
<?php
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');
$this->cart = new Cart(
new DatabaseDriver(
MockCartModel::class,
'session_id',
'items',
'conditions',
'SAMPLESESSIONKEY',
),
$events,
'cart',
require (__DIR__ . '/../Helpers/ConfigDatabaseMock.php')
);
});
afterEach(function () {
Mockery::close();
});
describe('database cart', function () {
test('can add an item', function () {
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1');
expect($this->cart->getContent()->first()['id'])->toEqual(1, 'Item added has ID of 1 so first content ID should be 1');
expect($this->cart->getContent()->first()['price'])->toEqual(100.99, 'Item added has price of 100.99 so first content price should be 100.99');
expect(MockCartModel::all()->count())->toEqual(1, 'The carts database table should have 1 row');
});
test('can add and remove an item', function () {
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1');
expect($this->cart->getContent()->first()['id'])->toEqual(1, 'Item added has ID of 1 so first content ID should be 1');
expect($this->cart->getContent()->first()['price'])->toEqual(100.99, 'Item added has price of 100.99 so first content price should be 100.99');
expect(MockCartModel::all()->count())->toEqual(1, 'The carts database table should have 1 row');
$this->cart->remove(1);
$cartModel = MockCartModel::first();
expect($this->cart->isEmpty())->toBeTrue('Cart should be empty');
expect($this->cart->getContent()->count())->toEqual(0, 'Cart content should be 0');
expect($cartModel->items)->toEqual([], 'The carts database table should have 0 row');
});
test('can add an item with an item level condition', function () {
$saleCondition = new CartCondition([
'name' => '5% Discount',
'type' => 'sale',
'value' => '-5%',
]);
$item = [
'id' => 1,
'name' => 'Chicken Curry',
'price' => 18.95,
'quantity' => 1,
'attributes' => [],
'conditions' => $saleCondition,
];
$this->cart->add($item);
expect($this->cart->get(1)->getPriceSum())->toEqual(18.95);
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(18);
expect($this->cart->getSubTotal())->toEqual(18, 'Cart should have subtotal of 18');
expect($this->cart->getTotal())->toEqual(18, 'Cart should have total of 18');
});
test('can add an item with multiple item level conditions', function () {
$saleCondition = new CartCondition([
'name' => '5% Discount',
'type' => 'sale',
'value' => '-5%',
]);
$gstCondition = new CartCondition([
'name' => 'GST',
'type' => 'gst',
'value' => '2.5%',
]);
$item = [
'id' => 1,
'name' => 'Chicken Curry',
'price' => 18.95,
'quantity' => 1,
'attributes' => [],
'conditions' => [$saleCondition, $gstCondition],
];
$this->cart->add($item);
expect($this->cart->get(1)->getPriceSum())->toEqual(18.95);
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(18.45);
expect($this->cart->getSubTotal())->toEqual(18.45, 'Cart should have subtotal of 18.45');
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, []);
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1');
expect($this->cart->getContent()->first()['id'])->toEqual(1, 'Item added has ID of 1 so first content ID should be 1');
expect($this->cart->getContent()->first()['price'])->toEqual(100.99, 'Item added has price of 100.99 so first content price should be 100.99');
expect(MockCartModel::all()->count())->toEqual(1, 'The carts database table should have 1 row');
$this->cart->clear();
expect($this->cart->isEmpty())->toBeTrue('Cart should be empty');
expect($this->cart->getContent()->count())->toEqual(0, 'Cart content should be 0');
expect(MockCartModel::all()->count())->toEqual(0, 'The carts database table should have 0 rows');
});
test('can clear cart conditions after clearing the cart', function () {
$condition = new CartCondition([
'name' => '10% Off',
'type' => 'coupon',
'target' => 'subtotal',
'value' => '-10%',
]);
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
$this->cart->condition($condition);
$this->cart->clear();
// This was throwing "Call to a member function save() on array"
// because clear() replaced $this->session with a plain array
expect(fn () => $this->cart->clearCartConditions())->not->toThrow(Error::class);
expect($this->cart->getConditions())->toBeEmpty();
});
});
-79
View File
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\DatabaseDriver;
use Wearepixel\Cart\Tests\Helpers\MockCartModel;
beforeEach(function () {
$this->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]]);
});
});
-84
View File
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\MultiDriver;
use Wearepixel\Cart\Drivers\NullDriver;
describe('MultiDriver', function () {
beforeEach(function () {
$this->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();
});
});
-64
View File
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\NullDriver;
describe('NullDriver', function () {
beforeEach(function () {
$this->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();
});
});
-94
View File
@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
describe('SessionDriver', function () {
beforeEach(function () {
$this->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();
});
});
+2 -2
View File
@@ -2,7 +2,6 @@
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;
@@ -11,9 +10,10 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+2 -2
View File
@@ -1,7 +1,6 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -9,9 +8,10 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
new SessionMock,
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMockOtherFormat.php')
);
});
-159
View File
@@ -1,159 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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);
});
});
-68
View File
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Exceptions\InvalidShippingRateException;
class FlatRateShipping extends ShippingRate
{
protected string $name = 'Standard Shipping';
protected string $value = '+10';
public function isApplicable(): bool
{
return true;
}
}
class FreeShipping extends ShippingRate
{
protected string $name = 'Free Shipping';
protected string $value = '+0';
public function isApplicable(): bool
{
return false;
}
}
describe('ShippingRate', function () {
test('converts to a CartCondition', function () {
$condition = (new FlatRateShipping)->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);
});
});
-68
View File
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Exceptions\InvalidTaxRuleException;
class AuGst extends TaxRule
{
protected string $name = 'GST';
protected string $value = '10%';
public function isApplicable(): bool
{
return true;
}
}
class NotApplicableTax extends TaxRule
{
protected string $name = 'VAT';
protected string $value = '20%';
public function isApplicable(): bool
{
return false;
}
}
describe('TaxRule', function () {
test('converts to a CartCondition', function () {
$condition = (new AuGst)->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);
});
});
-82
View File
@@ -1,82 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Testing\CartFactory;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCartForFactory(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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();
});
});
-112
View File
@@ -1,112 +0,0 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeFakeableCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->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);
});
});
-14
View File
@@ -1,14 +0,0 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
// Suppress PHP 8.4 deprecation notices that originate from vendor code.
// These are typically from third-party packages (e.g. Mockery) that have not
// yet updated implicit-nullable parameter signatures and are not actionable here.
set_error_handler(static function (int $errno, string $errstr, string $errfile): bool {
if ($errno === E_DEPRECATED && str_contains($errfile, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR)) {
return true;
}
return false;
}, E_DEPRECATED);